From e2595bec13e5bcd64a713e16465bdb27cdf9e4dc Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Mon, 29 Jun 2026 15:40:34 +0800 Subject: [PATCH] Wire VedAstro official snapshot priority --- SKILL.md | 1 + jyotish-app/main.js | 2 +- mcp_server.py | 53 +- scripts/jyotish_api_server.py | 53 +- scripts/jyotish_engine.py | 94 +++ scripts/vedastro_evidence_orchestrator.py | 16 +- scripts/vedastro_priority.py | 167 +++++ scripts/vedastro_service_adapter.py | 590 +++++++++++++++++- ...st_vedastro_external_technique_evidence.py | 39 ++ tests/test_vedastro_official_full_snapshot.py | 442 +++++++++++++ 10 files changed, 1445 insertions(+), 12 deletions(-) create mode 100644 scripts/vedastro_priority.py create mode 100644 tests/test_vedastro_official_full_snapshot.py diff --git a/SKILL.md b/SKILL.md index 93bd9031..625e8617 100644 --- a/SKILL.md +++ b/SKILL.md @@ -64,6 +64,7 @@ description: 印度占星(Jyotish)专业解盘与推运系统。核心能力 2. **阶段一**(仅B):PDF/图片提取 + Quality Gate 3. **阶段二**:意图识别 → 路由目标宫位(无明确意图→Level 2综合解盘) 4. **阶段二点五**:若 `full-reading` 或网页/API 返回 `ai_prompt_pack`,必须优先读取 `prompt_zh`、`evidence_snapshot`、`retrieval_plan` 作为 AI/RAG 主上下文;若没有该字段,再退回传统 JSON 摘要。 +4.1 **VedAstro 官方优先级**:用户给出生信息后,网页、Skill、MCP 都必须默认走同一条数据优先级:`VedAstro official snapshot -> local supplemental modules -> local fallback only when official blocked`。用户不需要主动要求“调用 VedAstro”。若 `evidence_snapshot.vedastro_official_full_snapshot.status` 为 `ok/partial` 且官方 chart 可用,D1/分盘/官方返回的原始字段以 VedAstro 为主;本地引擎只做补充、交叉检查或官方 blocked 时 fallback。 4. **阶段三**:静态分析10步(宫位→承诺→Yoga→Argala→逆行→NK→Shadbala→AV→Ketu→分盘) 5. **阶段四**:动态推运7步(Dasha→五系统Convergence→Transit→Double Transit→Jaimini→KP→Varshaphala) 6. **阶段五**:应期输出(五层验证→时间窗口→Actionable Output+案例检索) diff --git a/jyotish-app/main.js b/jyotish-app/main.js index bd1f1555..7d990b48 100644 --- a/jyotish-app/main.js +++ b/jyotish-app/main.js @@ -2439,7 +2439,7 @@ function renderVedAstroRangeScanResult(state = getVedAstroScanState()) { VedAstro Range Scan:${escapeHtml(status)} · ${escapeHtml(String(eventCount))} events · ${escapeHtml(state.start_date || '-')}/${escapeHtml(state.end_date || '-')}
- ${escapeHtml(result.reason || state.result?.boundary || '外部雷达结果已挂到 chartData.modules.vedastro_range_scan_result;本地 Jyotish gates 仍为主判断。')} + ${escapeHtml(result.reason || state.result?.boundary || 'VedAstro official snapshot 为主证据;range scan 是外部事件雷达,本地 Jyotish gates 作为补充与官方 blocked 时的 fallback。')} ${metadata.artifact_path ? ` artifact: ${escapeHtml(metadata.artifact_path)}` : ''}
${events.length ? ` diff --git a/mcp_server.py b/mcp_server.py index 10b4e20a..10203dd5 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -39,6 +39,7 @@ from local_env import load_local_env from mcp.server.fastmcp import FastMCP from functional_benefics import derive_functional_benefic_malefic +from vedastro_priority import official_snapshot_evidence load_local_env(SCRIPT_DIR) @@ -514,6 +515,29 @@ def _external_activation_audit(external_activation: Any) -> List[Dict[str, Any]] return [] +def _official_snapshot_audit(official_snapshot: Any) -> List[Dict[str, Any]]: + if not isinstance(official_snapshot, dict): + return [] + if official_snapshot.get("level") == "primary": + return [ + { + "technique": "VedAstro Official Full Snapshot", + "status": "used", + "role": "primary_raw_evidence", + "effect": "chart_and_varga_values_take_priority_over_local_engine", + } + ] + return [ + { + "technique": "VedAstro Official Full Snapshot", + "status": "blocked", + "role": "primary_raw_evidence", + "effect": "local_engine_fallback_only_with_boundary", + "reason": official_snapshot.get("reason") or official_snapshot.get("status"), + } + ] + + def _derive_external_technique_evidence(modules: Dict[str, Any], domain: str) -> Dict[str, Any]: ledger = _safe_get(modules, "external_technique_evidence", "evidence_ledger") if not isinstance(ledger, list): @@ -1581,9 +1605,11 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An present["argala_support"] = _derive_argala_support(modules, 10) present["external_activation"] = _derive_external_activation_support(modules, "career") present["external_technique_evidence"] = _derive_external_technique_evidence(modules, "career") + present["vedastro_official_snapshot"] = official_snapshot_evidence(modules) + present["source_priority"] = modules.get("source_priority") if isinstance(modules.get("source_priority"), dict) else {} present["functional_benefic_malefic"] = _derive_functional_benefic_malefic(modules) missing = [key for key, value in present.items() if key not in { - "external_activation", "external_technique_evidence", "argala_support", "shadbala", "shadbala_component_audit", "kakshya_career_support", "functional_benefic_malefic" + "external_activation", "external_technique_evidence", "vedastro_official_snapshot", "source_priority", "argala_support", "shadbala", "shadbala_component_audit", "kakshya_career_support", "functional_benefic_malefic" } and value in (None, {}, [], "")] convergence = present["career_convergence"] or {} confidence_cap = "medium" @@ -1612,7 +1638,8 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An ), } audit = ( - _external_activation_audit(present.get("external_activation")) + _official_snapshot_audit(present.get("vedastro_official_snapshot")) + + _external_activation_audit(present.get("external_activation")) + _external_technique_audit(present.get("external_technique_evidence")) ) if audit: @@ -1647,11 +1674,13 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An present["argala_support"] = _derive_argala_support(modules, 7) present["external_activation"] = _derive_external_activation_support(modules, "marriage") present["external_technique_evidence"] = _derive_external_technique_evidence(modules, "marriage") + present["vedastro_official_snapshot"] = official_snapshot_evidence(modules) + present["source_priority"] = modules.get("source_priority") if isinstance(modules.get("source_priority"), dict) else {} present["dignity_guardrail"] = _derive_dignity_guardrail(route, present) present["functional_benefic_malefic"] = _derive_functional_benefic_malefic(modules) missing = [ key for key, value in present.items() - if key not in {"chart", "external_activation", "external_technique_evidence", "dignity_guardrail", "jaimini_marriage_support", "jaimini_timing_support", "synastry_relationship_support", "argala_support", "shadbala", "shadbala_component_audit", "functional_benefic_malefic"} + if key not in {"chart", "external_activation", "external_technique_evidence", "vedastro_official_snapshot", "source_priority", "dignity_guardrail", "jaimini_marriage_support", "jaimini_timing_support", "synastry_relationship_support", "argala_support", "shadbala", "shadbala_component_audit", "functional_benefic_malefic"} and value in (None, {}, [], "") ] convergence = present["marriage_convergence"] or {} @@ -1683,7 +1712,8 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An ), } audit = ( - _external_activation_audit(present.get("external_activation")) + _official_snapshot_audit(present.get("vedastro_official_snapshot")) + + _external_activation_audit(present.get("external_activation")) + _external_technique_audit(present.get("external_technique_evidence")) ) if audit: @@ -1728,10 +1758,12 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An present["kakshya_finance_support"] = _derive_kakshya_finance_support(_safe_get(modules, "kakshya")) present["external_activation"] = _derive_external_activation_support(modules, "wealth") present["external_technique_evidence"] = _derive_external_technique_evidence(modules, "wealth") + present["vedastro_official_snapshot"] = official_snapshot_evidence(modules) + present["source_priority"] = modules.get("source_priority") if isinstance(modules.get("source_priority"), dict) else {} present["dignity_guardrail"] = _derive_dignity_guardrail(route, present) present["functional_benefic_malefic"] = _derive_functional_benefic_malefic(modules) missing = [key for key, value in present.items() if key not in { - "chart", "external_activation", "external_technique_evidence", "dignity_guardrail", "gains_convergence", "career_convergence", "avayogi_risk", "ashtakavarga_finance_support", "shadbala_component_audit", "asc_sign", "pav_finance_support", "sodhita_finance_support", "kakshya_finance_support", "functional_benefic_malefic" + "chart", "external_activation", "external_technique_evidence", "vedastro_official_snapshot", "source_priority", "dignity_guardrail", "gains_convergence", "career_convergence", "avayogi_risk", "ashtakavarga_finance_support", "shadbala_component_audit", "asc_sign", "pav_finance_support", "sodhita_finance_support", "kakshya_finance_support", "functional_benefic_malefic" } and value in (None, {}, [], "")] convergence_hits: List[Dict[str, Any]] = [ item for item in [ @@ -1772,7 +1804,8 @@ def _collect_strict_evidence(route: str, result: Dict[str, Any]) -> Dict[str, An ), } audit = ( - _external_activation_audit(present.get("external_activation")) + _official_snapshot_audit(present.get("vedastro_official_snapshot")) + + _external_activation_audit(present.get("external_activation")) + _external_technique_audit(present.get("external_technique_evidence")) ) if audit: @@ -1876,6 +1909,14 @@ def _maybe_attach_vedastro_evidence( enriched = dict(result) enriched["modules"] = dict(modules) enriched["modules"]["vedastro_range_scan_result"] = attached_scan + official_snapshot = attached_scan.get("official_full_snapshot") + if isinstance(official_snapshot, dict): + try: + from vedastro_priority import apply_vedastro_source_priority + + apply_vedastro_source_priority(enriched, official_snapshot=official_snapshot) + except Exception: + enriched["modules"]["vedastro_official_full_snapshot"] = official_snapshot return enriched diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index c0ee35a1..5f3740c7 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -57,6 +57,7 @@ def _attach_vedastro_main_entry_overview(chart_result, birth_payload): try: orchestrator = _load_local_module('vedastro_evidence_orchestrator') + priority = _load_local_module('vedastro_priority') except Exception: return chart_result @@ -65,7 +66,7 @@ def _attach_vedastro_main_entry_overview(chart_result, birth_payload): or birth_payload.get('today') or datetime.utcnow().strftime('%Y-%m-%d') )[:10] - modules['vedastro_range_scan_result'] = orchestrator.orchestrate_vedastro_evidence({ + vedastro_evidence = orchestrator.orchestrate_vedastro_evidence({ 'year': birth_payload.get('year'), 'month': birth_payload.get('month'), 'day': birth_payload.get('day'), @@ -78,6 +79,17 @@ def _attach_vedastro_main_entry_overview(chart_result, birth_payload): 'ayanamsa_policy': birth_payload.get('ayanamsa') or 'lahiri', 'node_policy': birth_payload.get('node_mode') or birth_payload.get('nodeMode') or 'mean', }, route='overview', reference_date=reference_date, case_id='api_chart') + if isinstance(vedastro_evidence, dict): + metadata = vedastro_evidence.get('source_metadata') + if not isinstance(metadata, dict): + metadata = {} + metadata.setdefault('ingestion_profile', 'main_entry_overview') + metadata.setdefault('reference_date', reference_date) + vedastro_evidence['source_metadata'] = metadata + modules['vedastro_range_scan_result'] = vedastro_evidence + official_snapshot = vedastro_evidence.get('official_full_snapshot') if isinstance(vedastro_evidence, dict) else None + if isinstance(official_snapshot, dict): + priority.apply_vedastro_source_priority(chart_result, official_snapshot=official_snapshot) return chart_result @@ -134,6 +146,39 @@ def _build_vedastro_overview_payload_from_chart(chart): 'visibility': 'user_visible_overview_only', } + +def _build_vedastro_official_full_snapshot_payload_from_chart(chart): + modules = chart.get('modules') if isinstance(chart, dict) else {} + snapshot = modules.get('vedastro_official_full_snapshot') if isinstance(modules, dict) else {} + if not isinstance(snapshot, dict) or not snapshot: + return { + 'status': 'blocked', + 'available': False, + 'operation': 'official_full_snapshot', + 'primary_source': 'vedastro_official', + 'boundary_note': 'VedAstro official full snapshot is not attached.', + } + manifest = snapshot.get('request_manifest') if isinstance(snapshot.get('request_manifest'), dict) else {} + requests = manifest.get('requests') if isinstance(manifest.get('requests'), list) else [] + sections = snapshot.get('snapshot_sections') if isinstance(snapshot.get('snapshot_sections'), dict) else {} + return { + 'status': snapshot.get('status') or 'blocked', + 'available': bool(snapshot.get('available')), + 'operation': snapshot.get('operation') or 'official_full_snapshot', + 'primary_source': snapshot.get('primary_source') or 'vedastro_official', + 'section_statuses': snapshot.get('section_statuses') or {}, + 'snapshot_section_keys': sorted(sections.keys()), + 'request_section_count': len(requests), + 'request_sections': [item.get('section') for item in requests if isinstance(item, dict)], + 'method_catalog': manifest.get('method_catalog') or {}, + 'user_visibility': snapshot.get('user_visibility') or 'backend_raw_evidence_not_direct_user_report', + 'source_metadata': snapshot.get('source_metadata') or {}, + 'boundary_note': ( + snapshot.get('reason') + or 'VedAstro official full snapshot is the primary raw evidence layer; user reports consume selected slices only.' + ), + } + SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', 'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] @@ -2300,6 +2345,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): shadbala = chart.get('shadbala') or {} functional_layer = self._functional_benefic_malefic_snapshot(planets, ascendant) vedastro_overview = _build_vedastro_overview_payload_from_chart(chart) + vedastro_official_full_snapshot = _build_vedastro_official_full_snapshot_payload_from_chart(chart) _attach_guided_topics(chart) modules = chart.get('modules') if isinstance(chart.get('modules'), dict) else {} guided_topics = modules.get('guided_topics') if isinstance(modules.get('guided_topics'), list) else [] @@ -2328,10 +2374,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): )[:7] core_planets = { planet: { + 'source': pdata.get('source'), 'sign': pdata.get('sign'), 'degree': pdata.get('degree'), + 'degree_in_sign': pdata.get('degree_in_sign'), 'house': pdata.get('house'), 'lon': pdata.get('lon'), + 'vargas': pdata.get('vargas'), } for planet, pdata in planets.items() if planet in {'Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'} @@ -2345,6 +2394,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): f'本盘使用 {ayanamsa_display} ayanamsa,节点口径为 {node_mode}。', '不要仅凭单一配置下结论;核心判断至少交叉 D1、D9、Dasha、Shadbala/Ashtakavarga 或 Transit 中的两个证据层。', '必须显式标注置信度和边界:Dasha/PDF 起点差异、Shadbala 外部绝对值 oracle 尚未完成时,不得声称已经完全校准。', + 'VedAstro 官方全量快照是第一原始证据层;若该层 blocked,必须把本地结果标记为 fallback。', ] oracle_progress = { 'scope': 'external_oracle_evidence_validation', @@ -2382,6 +2432,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'shadbala_ranking': top_strength, }, 'functional_benefic_malefic': functional_layer, + 'vedastro_official_full_snapshot': vedastro_official_full_snapshot, 'vedastro_overview': vedastro_overview, 'guided_topics': guided_topics, 'capability_evidence_pool': capability_evidence_pool, diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index e4896441..1699f892 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -822,10 +822,14 @@ def _planet_snapshot(planets, planet_name): if not isinstance(pdata, dict): return {} return { + 'source': pdata.get('source'), 'sign': pdata.get('sign'), 'sign_cn': pdata.get('sign_cn'), 'house': pdata.get('house'), + 'degree': pdata.get('degree'), + 'lon': pdata.get('lon'), 'degree_in_sign': pdata.get('degree_in_sign'), + 'vargas': pdata.get('vargas'), 'nakshatra': pdata.get('nakshatra'), 'nakshatra_pada': pdata.get('nakshatra_pada'), 'status': pdata.get('status'), @@ -1146,6 +1150,38 @@ def _build_vedastro_overview_payload(modules): } +def _build_vedastro_official_full_snapshot_payload(modules): + snapshot = modules.get('vedastro_official_full_snapshot') if isinstance(modules, dict) else {} + if not isinstance(snapshot, dict) or not snapshot: + return { + 'status': 'blocked', + 'available': False, + 'operation': 'official_full_snapshot', + 'primary_source': 'vedastro_official', + 'boundary_note': 'VedAstro official full snapshot is not attached.', + } + manifest = snapshot.get('request_manifest') if isinstance(snapshot.get('request_manifest'), dict) else {} + requests = manifest.get('requests') if isinstance(manifest.get('requests'), list) else [] + snapshot_sections = snapshot.get('snapshot_sections') if isinstance(snapshot.get('snapshot_sections'), dict) else {} + return { + 'status': snapshot.get('status') or 'blocked', + 'available': bool(snapshot.get('available')), + 'operation': snapshot.get('operation') or 'official_full_snapshot', + 'primary_source': snapshot.get('primary_source') or 'vedastro_official', + 'section_statuses': snapshot.get('section_statuses') or {}, + 'snapshot_section_keys': sorted(snapshot_sections.keys()), + 'request_section_count': len(requests), + 'request_sections': [item.get('section') for item in requests if isinstance(item, dict)], + 'method_catalog': manifest.get('method_catalog') or {}, + 'user_visibility': snapshot.get('user_visibility') or 'backend_raw_evidence_not_direct_user_report', + 'source_metadata': snapshot.get('source_metadata') or {}, + 'boundary_note': ( + snapshot.get('reason') + or 'VedAstro official full snapshot is the primary raw evidence layer; user reports consume selected slices only.' + ), + } + + def _build_ai_prompt_pack(report): """Build a compact, evidence-first prompt pack for downstream AI/RAG reading.""" modules = report.get('modules', {}) if isinstance(report, dict) else {} @@ -1167,6 +1203,7 @@ def _build_ai_prompt_pack(report): relationship_narrative = _build_relationship_narrative_payload(modules.get('relationship_strict_evidence')) vimsopaka_semantic_summary = _build_vimsopaka_semantic_summary(modules.get('vimsopaka')) vedastro_overview = _build_vedastro_overview_payload(modules) + vedastro_official_full_snapshot = _build_vedastro_official_full_snapshot_payload(modules) guided_topics = modules.get('guided_topics') if isinstance(modules.get('guided_topics'), list) else build_guided_topics(report) capability_evidence_pool = build_capability_evidence_pool_summary() @@ -1239,6 +1276,7 @@ def _build_ai_prompt_pack(report): }, 'oracle_progress': oracle_progress, 'functional_benefic_malefic': functional_layer, + 'vedastro_official_full_snapshot': vedastro_official_full_snapshot, 'vedastro_overview': vedastro_overview, 'guided_topics': guided_topics, 'capability_evidence_pool': capability_evidence_pool, @@ -1256,6 +1294,7 @@ def _build_ai_prompt_pack(report): "输出结构建议:参数声明、核心星盘、关系/事业/财富/健康分主题、当前时机、证据表、风险边界、可行动建议。", "若引用经典法则,请优先检索 retrieval_plan.local_reference_docs;需要外部断语时再做 web/source verification。", "若 evidence_snapshot.vedastro_overview.status 为 ok,请把它作为用户可见外部概览证据明确写出,但不要把 overview-only 结果误当作长周期精扫结论。", + "VedAstro 官方全量快照是第一原始证据层;若 evidence_snapshot.vedastro_official_full_snapshot.status 不是 ok/partial,必须说明官方全量资料 blocked,并把本地结果标记为 fallback。", "若 evidence_snapshot.capability_evidence_pool 存在,请把 89 项视为后台备选证据池;不要把所有能力条目平铺成结论,也不要让 audit_only/alias 条目影响占星判断。", ] @@ -1327,6 +1366,11 @@ def _attach_vedastro_main_entry_overview(report, args): 'tz': getattr(args, 'tz', None), 'ayanamsa_policy': getattr(args, 'ayanamsa', None) or _current_ayanamsa_name(args), 'node_policy': getattr(args, 'node_mode', 'mean'), + 'reference_date': ( + getattr(args, 'transit_date', None) + or getattr(args, 'today', None) + or datetime.now().strftime('%Y-%m-%d') + ), } def _scan_domain(domain: str): @@ -1411,6 +1455,51 @@ def _attach_vedastro_main_entry_overview(report, args): return report +def _attach_vedastro_official_full_snapshot(report, args): + if not isinstance(report, dict): + return report + modules = report.setdefault('modules', {}) + if not isinstance(modules, dict): + return report + if modules.get('vedastro_official_full_snapshot'): + return report + + try: + from vedastro_service_adapter import run_official_full_snapshot_for_case + from vedastro_priority import apply_vedastro_source_priority + except Exception as exc: # pragma: no cover - import guard + report.setdefault('warnings', []).append(f"vedastro-official-full-snapshot-import: {exc}") + return report + + case = { + 'year': getattr(args, 'year', None), + 'month': getattr(args, 'month', None), + 'day': getattr(args, 'day', None), + 'hour': getattr(args, 'hour', None), + 'minute': getattr(args, 'minute', None), + 'second': _arg_second(args), + 'lat': getattr(args, 'lat', None), + 'lon': getattr(args, 'lon', None), + 'tz': getattr(args, 'tz', None), + 'ayanamsa_policy': getattr(args, 'ayanamsa', None) or _current_ayanamsa_name(args), + 'node_policy': getattr(args, 'node_mode', 'mean'), + 'reference_date': ( + getattr(args, 'transit_date', None) + or getattr(args, 'today', None) + or datetime.now().strftime('%Y-%m-%d') + ), + } + modules['vedastro_official_full_snapshot'] = run_official_full_snapshot_for_case( + case, + case_id='full_reading_official_primary', + ) + apply_vedastro_source_priority( + report, + official_snapshot=modules['vedastro_official_full_snapshot'], + ) + return report + + def _load_relationship_strict_collector(): try: from mcp_server import _collect_strict_evidence as collector @@ -5132,6 +5221,11 @@ def cmd_full_reading(args): except Exception as e: report['errors'].append(f"relationship-strict-evidence: {e}") + try: + _attach_vedastro_official_full_snapshot(report, args) + except Exception as e: + report['warnings'].append(f"vedastro-official-full-snapshot: {e}") + try: _attach_vedastro_main_entry_overview(report, args) except Exception as e: diff --git a/scripts/vedastro_evidence_orchestrator.py b/scripts/vedastro_evidence_orchestrator.py index 6cebe9a0..60e944e0 100644 --- a/scripts/vedastro_evidence_orchestrator.py +++ b/scripts/vedastro_evidence_orchestrator.py @@ -14,10 +14,15 @@ from typing import Any try: from scripts.vedastro_service_adapter import ( VEDASTRO_CALCULATION_COVERAGE, + run_official_full_snapshot_for_case, run_range_scan_for_case, ) except ModuleNotFoundError: # pragma: no cover - script execution path - from vedastro_service_adapter import VEDASTRO_CALCULATION_COVERAGE, run_range_scan_for_case + from vedastro_service_adapter import ( + VEDASTRO_CALCULATION_COVERAGE, + run_official_full_snapshot_for_case, + run_range_scan_for_case, + ) ROUTE_DOMAIN_MAP = { @@ -75,6 +80,7 @@ def orchestrate_vedastro_evidence( domains = ROUTE_DOMAIN_MAP.get(route, ROUTE_DOMAIN_MAP["general"]) window_start, window_end = (start_date, end_date) if start_date and end_date else _default_window(reference_date) case = _normalize_case(birth_payload) + case["reference_date"] = str(reference_date or window_start)[:10] domain_reports: dict[str, Any] = {} evidence_ledger: list[dict[str, Any]] = [] top_events_by_domain: dict[str, Any] = {} @@ -82,6 +88,10 @@ def orchestrate_vedastro_evidence( domain_event_counts: dict[str, int] = {} available = False first_reason = None + official_full_snapshot = run_official_full_snapshot_for_case( + case, + case_id=f"{case_id}_official_full_snapshot", + ) for domain in domains: report = run_range_scan_for_case( @@ -115,11 +125,13 @@ def orchestrate_vedastro_evidence( "top_events_by_domain": top_events_by_domain, "evidence_ledger": evidence_ledger, "reason": None if status == "ok" else first_reason, + "official_full_snapshot": official_full_snapshot, "domain_reports": domain_reports, "source_metadata": { "auto_ingested_by": "VedAstroEvidenceOrchestrator", - "strategy": "minimal_route_scoped_orchestration", + "strategy": "official_full_snapshot_first_then_route_scoped_range_scan", "node_coverage": { + "official_full_snapshot_first": True, "strategy": "domain_scoped_range_scan", "official_calculation_coverage": VEDASTRO_CALCULATION_COVERAGE, "selected_domains": domains, diff --git a/scripts/vedastro_priority.py b/scripts/vedastro_priority.py new file mode 100644 index 00000000..de15b09b --- /dev/null +++ b/scripts/vedastro_priority.py @@ -0,0 +1,167 @@ +"""Shared VedAstro-first source priority helpers. + +This module keeps the user-facing data order identical across the CLI engine, +API server, and MCP strict workflow: + +1. VedAstro official full snapshot when it contains an official chart. +2. Local modules as supplemental evidence and cross-checks. +3. Local chart as fallback only when the official snapshot is blocked. +""" + +from __future__ import annotations + +from copy import deepcopy +from typing import Any + + +SOURCE_PRIORITY = [ + "vedastro_official_snapshot", + "local_supplemental_modules", + "local_engine_fallback_when_official_blocked", +] + + +def official_chart_available(official_snapshot: dict[str, Any] | None) -> bool: + if not isinstance(official_snapshot, dict): + return False + official_chart = official_snapshot.get("official_chart") + if not isinstance(official_chart, dict): + return False + return ( + isinstance(official_chart.get("planets"), dict) + and bool(official_chart.get("planets")) + and isinstance(official_chart.get("ascendant"), dict) + and bool(official_chart.get("ascendant")) + ) + + +def _local_chart_from(report: dict[str, Any], modules: dict[str, Any]) -> dict[str, Any]: + chart = report.get("chart") + if isinstance(chart, dict) and chart: + return chart + chart = modules.get("chart") + return chart if isinstance(chart, dict) else {} + + +def _blocked_reason(official_snapshot: dict[str, Any] | None) -> str: + status = "missing" + if isinstance(official_snapshot, dict): + status = str(official_snapshot.get("status") or "blocked") + return f"VedAstro official snapshot blocked: {status}" + + +def build_source_priority_metadata( + official_snapshot: dict[str, Any] | None, + *, + official_primary: bool, +) -> dict[str, Any]: + status = official_snapshot.get("status") if isinstance(official_snapshot, dict) else "missing" + return { + "mode": "vedastro_official_primary" if official_primary else "local_fallback_official_blocked", + "priority": list(SOURCE_PRIORITY), + "official_snapshot_first": True, + "official_snapshot_status": status or "blocked", + "local_engine_role": ( + "supplemental_crosscheck_or_fallback" + if official_primary + else "fallback_only_because_official_blocked" + ), + "user_visible_policy": ( + "show_vedastro_verified_when_official_chart_available" + if official_primary + else "show_local_fallback_with_official_blocked_boundary" + ), + } + + +def apply_vedastro_source_priority( + report: dict[str, Any], + *, + official_snapshot: dict[str, Any] | None, +) -> dict[str, Any]: + if not isinstance(report, dict): + return report + modules = report.setdefault("modules", {}) + if not isinstance(modules, dict): + modules = {} + report["modules"] = modules + + if isinstance(official_snapshot, dict): + modules["vedastro_official_full_snapshot"] = official_snapshot + + local_chart = _local_chart_from(report, modules) + + if official_chart_available(official_snapshot): + official_chart = official_snapshot.get("official_chart") # type: ignore[union-attr] + modules["local_engine_chart_fallback"] = deepcopy(local_chart) + public_chart = { + **deepcopy(local_chart), + "source": "vedastro_official_primary", + "primary_source": "vedastro_official", + "fallback_source": "local_engine", + "local_engine_role": "supplemental_crosscheck_or_fallback", + "local_crosscheck_status": "pending", + "source_priority": list(SOURCE_PRIORITY), + "planets": official_chart.get("planets", {}), + "ascendant": official_chart.get("ascendant", {}), + "houses": official_chart.get("houses", {}), + "birth_info": local_chart.get("birth_info", report.get("birth_info", report.get("birth", {}))), + "official_coverage": official_chart.get("coverage", {}), + } + report["chart"] = public_chart + modules["chart"] = public_chart + for key in ("source", "primary_source", "fallback_source", "planets", "ascendant", "houses"): + if key in public_chart: + report[key] = public_chart[key] + modules["source_priority"] = build_source_priority_metadata( + official_snapshot, + official_primary=True, + ) + return report + + if local_chart: + fallback_chart = { + **deepcopy(local_chart), + "source": "local_engine_fallback", + "primary_source": "local_engine", + "fallback_reason": _blocked_reason(official_snapshot), + "local_engine_role": "fallback_only_because_official_blocked", + "source_priority": list(SOURCE_PRIORITY), + } + report["chart"] = fallback_chart + modules["chart"] = fallback_chart + for key in ("source", "primary_source", "planets", "ascendant", "houses"): + if key in fallback_chart: + report[key] = fallback_chart[key] + + modules["source_priority"] = build_source_priority_metadata( + official_snapshot, + official_primary=False, + ) + return report + + +def official_snapshot_evidence(modules: dict[str, Any]) -> dict[str, Any]: + snapshot = modules.get("vedastro_official_full_snapshot") if isinstance(modules, dict) else {} + source_priority = modules.get("source_priority") if isinstance(modules, dict) else {} + if not isinstance(snapshot, dict) or not snapshot: + return { + "level": "blocked", + "source": "vedastro_official", + "status": "missing", + "operation": "official_full_snapshot", + "source_priority": source_priority if isinstance(source_priority, dict) else {}, + "reason": "VedAstro official full snapshot is not attached.", + } + level = "primary" if official_chart_available(snapshot) else "blocked" + return { + "level": level, + "source": "vedastro_official", + "status": snapshot.get("status") or "blocked", + "available": bool(snapshot.get("available")), + "operation": snapshot.get("operation") or "official_full_snapshot", + "source_priority": source_priority if isinstance(source_priority, dict) else {}, + "section_statuses": snapshot.get("section_statuses") or {}, + "chart_available": official_chart_available(snapshot), + "reason": snapshot.get("reason"), + } diff --git a/scripts/vedastro_service_adapter.py b/scripts/vedastro_service_adapter.py index 812c8144..d69669d7 100644 --- a/scripts/vedastro_service_adapter.py +++ b/scripts/vedastro_service_adapter.py @@ -15,6 +15,7 @@ import json import os import socket import time +from datetime import datetime from pathlib import Path from typing import Any from urllib import request, error @@ -86,6 +87,62 @@ SUPPORTED_EXTERNAL_TECHNIQUE_DOMAINS = {"marriage", "wealth", "career", "general OFFICIAL_SEARCH_EVENTS_ENDPOINT_PATH = "/Calculate/SearchEvents" OFFICIAL_SEARCH_EVENTS_METHOD = "POST" OFFICIAL_SEARCH_EVENTS_PROFILE_VERSION = "official_builder_search_events_v1" +OFFICIAL_FULL_SNAPSHOT_PROFILE_VERSION = "official_full_snapshot_v1" +OFFICIAL_METHOD_CATALOG_URL = "https://vedastro.org/Complete-List-VedAstro-API-Methods-Calculators.html" +OFFICIAL_FULL_SNAPSHOT_METHODS = [ + { + "section": "chart_core", + "endpoint_path": "/Calculate/AllPlanetData", + "calculator_name": "AllPlanetData", + "role": "core_chart_raw_evidence", + "description": "Core planet, ascendant, house, nakshatra, ayanamsa and node-mode evidence when supported by the official service.", + "fanout": "planetName", + }, + { + "section": "house_core", + "endpoint_path": "/Calculate/AllHouseData", + "calculator_name": "AllHouseData", + "role": "core_house_raw_evidence", + "description": "Official house data snapshot when supported by the official service.", + "fanout": "houseName", + }, + { + "section": "dasha_all", + "endpoint_path": "/Calculate/DasaAtRange", + "calculator_name": "DasaAtRange", + "role": "all_dasha_raw_evidence", + "description": "Official dasha timeline snapshot where available.", + }, + { + "section": "events_overview", + "endpoint_path": OFFICIAL_SEARCH_EVENTS_ENDPOINT_PATH, + "calculator_name": "SearchEvents", + "role": "life_event_raw_evidence", + "description": "Official event radar using SearchEvents for career, marriage and wealth tags.", + }, +] +OFFICIAL_FULL_SNAPSHOT_BACKLOG_SECTIONS = [ + { + "section": "varga_all", + "role": "all_varga_raw_evidence", + "status": "catalog_pending", + "description": "Awaiting official method mapping for all divisional charts; local varga remains fallback until mapped.", + }, + { + "section": "shadbala", + "role": "strength_raw_evidence", + "status": "catalog_pending", + "description": "Awaiting official method mapping for Shadbala; local Shadbala remains fallback until mapped.", + }, + { + "section": "ashtakavarga", + "role": "ashtakavarga_raw_evidence", + "status": "catalog_pending", + "description": "Awaiting official method mapping for Ashtakavarga; local Ashtakavarga remains fallback until mapped.", + }, +] +OFFICIAL_SNAPSHOT_PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu", "Ascendant"] +OFFICIAL_SNAPSHOT_HOUSES = [f"House{i}" for i in range(1, 13)] OFFICIAL_RANGE_SCAN_EVENT_TAGS = { "marriage": ["Marriage", "Personal", "General"], "wealth": ["LendingMoney", "BorrowingMoney", "BuyingSelling", "General"], @@ -418,7 +475,40 @@ def schema() -> dict[str, Any]: "AtTime | StartTime + EndTime + PrecisionHours", ], }, + "official_full_snapshot_request_contract": { + "profile_version": OFFICIAL_FULL_SNAPSHOT_PROFILE_VERSION, + "primary_source": "vedastro_official", + "method_catalog_url": OFFICIAL_METHOD_CATALOG_URL, + "strategy": "fetch_official_raw_sections_first_then_local_crosscheck", + "common_body_fields": [ + "BirthTime", + "Ayanamsa", + "NodeMode", + "CalculationPreferences", + ], + "request_sections": [ + { + "section": item["section"], + "endpoint_path": item["endpoint_path"], + "calculator_name": item.get("calculator_name"), + "role": item["role"], + } + for item in OFFICIAL_FULL_SNAPSHOT_METHODS + ], + "backlog_sections": OFFICIAL_FULL_SNAPSHOT_BACKLOG_SECTIONS, + "user_visibility": "backend_raw_evidence_not_direct_user_report", + }, "vedastro_calculation_coverage": VEDASTRO_CALCULATION_COVERAGE, + "official_full_snapshot_response_contract": [ + "backend", + "available", + "status", + "operation", + "primary_source", + "snapshot_sections", + "request_manifest", + "source_metadata", + ], "external_technique_request_contract": [ "operation", "role", @@ -515,9 +605,20 @@ def _format_std_time(date_text: str, hour: Any, minute: Any, tz: Any) -> str: return f"{hour_int:02d}:{minute_int:02d} {day}/{month}/{year} {tz}" -def _time_json_from_case(case: dict[str, Any], date_text: str) -> dict[str, Any]: +def _time_json_from_case( + case: dict[str, Any], + date_text: str, + *, + hour: Any | None = None, + minute: Any | None = None, +) -> dict[str, Any]: return { - "StdTime": _format_std_time(date_text, case.get("hour", 0), case.get("minute", 0), case.get("tz", "+00:00")), + "StdTime": _format_std_time( + date_text, + case.get("hour", 0) if hour is None else hour, + case.get("minute", 0) if minute is None else minute, + case.get("tz", "+00:00"), + ), "Location": { "Name": case.get("case_id") or "UserLocation", "Latitude": case.get("lat"), @@ -590,6 +691,108 @@ def _build_live_sampling_search_events_profile(request_preview: dict[str, Any]) } +def _official_common_body(case: dict[str, Any]) -> dict[str, Any]: + normalized = dict(case) + normalized["tz"] = _normalize_tz(normalized) + return { + "time": _time_json_from_case( + normalized, + f"{int(normalized['year']):04d}-{int(normalized['month']):02d}-{int(normalized['day']):02d}", + ), + "Ayanamsa": str(normalized.get("ayanamsa_policy") or "lahiri"), + "NodeMode": str(normalized.get("node_policy") or "mean"), + "CalculationPreferences": { + "scope": "all_supported_official_calculations", + "user_visibility": "backend_raw_evidence_not_direct_user_report", + }, + } + + +def _official_snapshot_reference_date(case: dict[str, Any]) -> str: + for key in ("reference_date", "today", "transit_date", "current_date"): + value = case.get(key) + if not value: + continue + raw = str(value)[:10] + try: + datetime.strptime(raw, "%Y-%m-%d") + return raw + except ValueError: + continue + return datetime.utcnow().strftime("%Y-%m-%d") + + +def _official_dasha_range_body(case: dict[str, Any], common_body: dict[str, Any]) -> dict[str, Any]: + normalized = dict(case) + normalized["tz"] = _normalize_tz(normalized) + reference = datetime.strptime(_official_snapshot_reference_date(normalized), "%Y-%m-%d").date() + start_date = reference.replace(month=1, day=1) + end_date = reference.replace(month=12, day=31) + return { + "birthTime": common_body["time"], + "startTime": _time_json_from_case(normalized, start_date.isoformat(), hour=0, minute=0), + "endTime": _time_json_from_case(normalized, end_date.isoformat(), hour=23, minute=59), + "levels": int(normalized.get("dasha_levels") or 3), + "precisionHours": int(normalized.get("dasha_precision_hours") or 100), + "Ayanamsa": common_body["Ayanamsa"], + } + + +def _official_full_snapshot_manifest(case: dict[str, Any], case_id: str = "user_chart") -> dict[str, Any]: + common_body = _official_common_body(case) + reference_date = _official_snapshot_reference_date(case) + headers: dict[str, str] = {"Content-Type": "application/json"} + api_key = os.environ.get("VEDASTRO_API_KEY", "").strip() + if api_key: + headers["x-api-key"] = api_key + requests = [] + for item in OFFICIAL_FULL_SNAPSHOT_METHODS: + body = dict(common_body) + if item["section"] == "events_overview": + body = { + "BirthTime": common_body["time"], + "Ayanamsa": common_body["Ayanamsa"], + "EventTagList": sorted({tag for tags in OFFICIAL_RANGE_SCAN_EVENT_TAGS.values() for tag in tags}), + "AtTime": common_body["time"], + } + if item["section"] == "dasha_all": + body = _official_dasha_range_body(case, common_body) + fanout_values = [] + if item.get("fanout") == "planetName": + fanout_values = OFFICIAL_SNAPSHOT_PLANETS + elif item.get("fanout") == "houseName": + fanout_values = OFFICIAL_SNAPSHOT_HOUSES + requests.append( + { + "section": item["section"], + "role": item["role"], + "calculator_name": item.get("calculator_name"), + "endpoint_path": item["endpoint_path"], + "method": "POST", + "headers": headers, + "body": body, + "fanout_parameter": item.get("fanout"), + "fanout_values": fanout_values, + "description": item["description"], + } + ) + return { + "operation": "official_full_snapshot", + "profile_version": OFFICIAL_FULL_SNAPSHOT_PROFILE_VERSION, + "source_role": "primary_official_raw_evidence", + "primary_source": "vedastro_official", + "case_id": case_id, + "reference_date": reference_date, + "method_catalog": { + "url": OFFICIAL_METHOD_CATALOG_URL, + "declared_coverage": VEDASTRO_CALCULATION_COVERAGE, + "catalog_role": "all_supported_method_reference_not_user_visible_output", + "backlog_sections": OFFICIAL_FULL_SNAPSHOT_BACKLOG_SECTIONS, + }, + "requests": requests, + } + + def _external_technique_preview( case: dict[str, Any], domain: str, @@ -1114,6 +1317,385 @@ def run_case(case_id: str) -> dict[str, Any]: return _normalize_success(payload, endpoint, request_preview) +def _official_full_snapshot_metadata(endpoint: str | None, manifest: dict[str, Any]) -> dict[str, Any]: + metadata = { + "transport": "http_json_service_boundary", + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "provenance_mode": "vedastro_official_primary_candidate", + "timeout_seconds": _timeout_seconds(), + "retry_policy": {**RETRY_POLICY, "backoff_seconds": _backoff_seconds()}, + "network_execution_env": ALLOW_NETWORK_ENV, + "method_catalog_url": OFFICIAL_METHOD_CATALOG_URL, + "reference_date": manifest.get("reference_date"), + "request_hash": _hash_payload(manifest), + } + if endpoint: + metadata["endpoint"] = endpoint + metadata["endpoint_host"] = _endpoint_host(endpoint) + return metadata + + +def _payload_status(payload: dict[str, Any]) -> str: + if not isinstance(payload, dict): + return "invalid" + if str(payload.get("Status") or "").lower() == "fail": + failure_text = json.dumps(payload.get("Payload"), ensure_ascii=False).lower() + if "rate limit" in failure_text or "calls/minute" in failure_text or "too many requests" in failure_text: + return "rate_limited" + return "ok" if payload.get("Status") == "Pass" else "fail" + + +def _aggregate_section_status(statuses: list[str]) -> str: + if statuses and all(status == "ok" for status in statuses): + return "ok" + if any(status == "rate_limited" for status in statuses): + return "rate_limited" + return "partial" + + +def _degrees_from_sign_payload(value: Any) -> float | None: + if not isinstance(value, dict): + return None + degrees = value.get("DegreesIn") if isinstance(value.get("DegreesIn"), dict) else {} + raw = degrees.get("TotalDegrees") + try: + return float(raw) + except (TypeError, ValueError): + return None + + +def _sign_position(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + sign = value.get("Name") + degree = _degrees_from_sign_payload(value) + if not sign: + return None + return { + "sign": sign, + "degree_in_sign": degree, + } + + +def _extract_all_planet_data(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + body = payload.get("Payload") if isinstance(payload.get("Payload"), dict) else {} + data = body.get("AllPlanetData") if isinstance(body.get("AllPlanetData"), dict) else {} + return data if isinstance(data, dict) else {} + + +def _extract_all_house_data(payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + return {} + body = payload.get("Payload") if isinstance(payload.get("Payload"), dict) else {} + data = body.get("AllHouseData") if isinstance(body.get("AllHouseData"), dict) else {} + return data if isinstance(data, dict) else {} + + +def _official_planet_snapshot(planet_name: str, data: dict[str, Any]) -> dict[str, Any]: + d1 = _sign_position(data.get("PlanetRasiD1Sign")) or {} + raw_lon = None + nirayana = data.get("PlanetNirayanaLongitude") + if isinstance(nirayana, dict): + try: + raw_lon = float(nirayana.get("TotalDegrees")) + except (TypeError, ValueError): + raw_lon = None + house_text = data.get("HousePlanetOccupiesBasedOnSign") or data.get("HousePlanetOccupiesBasedOnLongitudes") + house = None + if isinstance(house_text, str) and house_text.lower().startswith("house"): + try: + house = int("".join(ch for ch in house_text if ch.isdigit())) + except ValueError: + house = None + return { + "source": "vedastro_official", + "name": planet_name, + "sign": d1.get("sign"), + "degree_in_sign": d1.get("degree_in_sign"), + "degree": raw_lon, + "lon": raw_lon, + "house": house, + "vargas": { + "D1": d1, + "D2": _sign_position(data.get("PlanetHoraD2Signs")), + "D3": _sign_position(data.get("PlanetDrekkanaD3Sign")), + "D4": _sign_position(data.get("PlanetChaturthamshaD4Sign")), + "D7": _sign_position(data.get("PlanetSaptamshaD7Sign")), + "D9": _sign_position(data.get("PlanetNavamshaD9Sign")), + "D10": _sign_position(data.get("PlanetDashamamshaD10Sign")), + "D12": _sign_position(data.get("PlanetDwadashamshaD12Sign")), + "D16": _sign_position(data.get("PlanetShodashamshaD16Sign")), + "D20": _sign_position(data.get("PlanetVimshamshaD20Sign")), + "D24": _sign_position(data.get("PlanetChaturvimshamshaD24Sign")), + "D27": _sign_position(data.get("PlanetBhamshaD27Sign")), + "D30": _sign_position(data.get("PlanetTrimshamshaD30Sign")), + "D40": _sign_position(data.get("PlanetKhavedamshaD40Sign")), + "D45": _sign_position(data.get("PlanetAkshavedamshaD45Sign")), + "D60": _sign_position(data.get("PlanetShashtyamshaD60Sign")), + }, + "nakshatra": data.get("PlanetConstellation"), + "raw_source_keys": sorted(data.keys()), + } + + +def _official_house_snapshot(house_name: str, data: dict[str, Any]) -> dict[str, Any]: + d1 = _sign_position(data.get("HouseRasiD1Sign") or data.get("HouseBhavaChalitSign")) or {} + return { + "source": "vedastro_official", + "name": house_name, + "sign": d1.get("sign"), + "degree_in_sign": d1.get("degree_in_sign"), + "vargas": { + "D1": d1, + "D2": _sign_position(data.get("HouseHoraD2Sign") or data.get("HouseHoraD2Signs")), + "D3": _sign_position(data.get("HouseDrekkanaD3Sign")), + "D4": _sign_position(data.get("HouseChaturthamshaD4Sign")), + "D7": _sign_position(data.get("HouseSaptamshaD7Sign")), + "D9": _sign_position(data.get("HouseNavamshaD9Sign") or data.get("HouseNavamsaD9Sign")), + "D10": _sign_position(data.get("HouseDashamamshaD10Sign")), + "D12": _sign_position(data.get("HouseDwadashamshaD12Sign")), + "D16": _sign_position(data.get("HouseShodashamshaD16Sign")), + "D20": _sign_position(data.get("HouseVimshamshaD20Sign")), + "D24": _sign_position(data.get("HouseChaturvimshamshaD24Sign")), + "D27": _sign_position(data.get("HouseBhamshaD27Sign")), + "D30": _sign_position(data.get("HouseTrimshamshaD30Sign")), + "D40": _sign_position(data.get("HouseKhavedamshaD40Sign")), + "D45": _sign_position(data.get("HouseAkshavedamshaD45Sign")), + "D60": _sign_position(data.get("HouseShashtyamshaD60Sign")), + }, + "nakshatra": data.get("HouseConstellation"), + "raw_source_keys": sorted(data.keys()), + } + + +def _build_official_chart_from_snapshot(sections: dict[str, Any]) -> dict[str, Any]: + chart_core = sections.get("chart_core") if isinstance(sections.get("chart_core"), dict) else {} + house_core = sections.get("house_core") if isinstance(sections.get("house_core"), dict) else {} + planets: dict[str, Any] = {} + for planet_name, payload in chart_core.items(): + data = _extract_all_planet_data(payload) + if data: + planets[planet_name] = _official_planet_snapshot(planet_name, data) + houses: dict[str, Any] = {} + for house_name, payload in house_core.items(): + data = _extract_all_house_data(payload) + if data: + houses[house_name] = _official_house_snapshot(house_name, data) + ascendant = houses.get("House1") or {} + return { + "source": "vedastro_official", + "primary_source": "vedastro_official", + "planets": planets, + "houses": houses, + "ascendant": ascendant, + "coverage": { + "planet_count": len(planets), + "house_count": len(houses), + "varga_keys": ["D1", "D2", "D3", "D4", "D7", "D9", "D10", "D12", "D16", "D20", "D24", "D27", "D30", "D40", "D45", "D60"], + }, + } + + +def _post_official_snapshot_section(endpoint: str, request_item: dict[str, Any]) -> tuple[dict[str, Any], int, list[int]]: + body = dict(request_item["body"]) + fanout_parameter = request_item.get("fanout_parameter") + fanout_value = request_item.get("fanout_value") + if fanout_parameter and fanout_value is not None: + if fanout_parameter == "planetName": + body[fanout_parameter] = {"Name": str(fanout_value)} + else: + body[fanout_parameter] = str(fanout_value) + section_preview = { + "operation": "official_full_snapshot", + "section": request_item["section"], + "official_request_profile": { + "profile_version": OFFICIAL_FULL_SNAPSHOT_PROFILE_VERSION, + "endpoint_path": request_item["endpoint_path"], + "method": request_item["method"], + "headers": request_item["headers"], + "body": body, + }, + } + return _post_json_with_retry(endpoint, section_preview) + + +def _normalize_official_full_snapshot_success( + endpoint: str, + manifest: dict[str, Any], + sections: dict[str, Any], + section_statuses: dict[str, str], + attempt_count: int, + retry_error_codes: list[int], +) -> dict[str, Any]: + primary_sections = [item["section"] for item in manifest["requests"]] + ok_count = sum(1 for section in primary_sections if section_statuses.get(section) == "ok") + status = "ok" if ok_count == len(primary_sections) else "partial" + rate_limited_sections = [ + section + for section in primary_sections + if section_statuses.get(section) == "rate_limited" + ] + metadata = { + **_official_full_snapshot_metadata(endpoint, manifest), + "called_at": _utc_timestamp(), + "section_statuses": section_statuses, + "section_count": len(primary_sections), + "section_ok_count": ok_count, + "rate_limited_sections": rate_limited_sections, + "attempt_count": attempt_count, + "retry_error_codes": retry_error_codes, + "response_hash": _hash_payload({"sections": sections, "section_statuses": section_statuses}), + } + if rate_limited_sections: + metadata["production_hint"] = "configure_vedastro_api_key_or_self_host_official_api" + result = { + "backend": "vedastro_service_adapter_candidate", + "available": ok_count > 0, + "status": status, + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "snapshot_sections": sections, + "official_chart": _build_official_chart_from_snapshot(sections), + "section_statuses": section_statuses, + "request_manifest": manifest, + "user_visibility": "backend_raw_evidence_not_direct_user_report", + "source_metadata": metadata, + } + result["source_metadata"]["artifact_path"] = _write_artifact(result) + return result + + +def _run_official_full_snapshot_case(case: dict[str, Any], case_id: str = "user_chart") -> dict[str, Any]: + user_case = { + "case_id": case_id, + "year": case.get("year"), + "month": case.get("month"), + "day": case.get("day"), + "hour": case.get("hour"), + "minute": case.get("minute"), + "second": case.get("second", 0), + "lat": case.get("lat"), + "lon": case.get("lon"), + "tz": case.get("tz"), + "ayanamsa_policy": case.get("ayanamsa_policy") or case.get("ayanamsa") or "lahiri", + "node_policy": case.get("node_policy") or case.get("node_mode") or "mean", + "reference_date": case.get("reference_date") or case.get("today") or case.get("transit_date") or case.get("current_date"), + "dasha_levels": case.get("dasha_levels"), + "dasha_precision_hours": case.get("dasha_precision_hours"), + } + manifest = _official_full_snapshot_manifest(user_case, case_id) + endpoint = os.environ.get("VEDASTRO_API_ENDPOINT", "").strip() + if not endpoint: + return { + "backend": "vedastro_service_adapter_candidate", + "available": False, + "status": "service_endpoint_not_configured", + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "reason": "VEDASTRO_API_ENDPOINT is not configured; official full snapshot stops before network access.", + "snapshot_sections": {}, + "request_manifest": manifest, + "user_visibility": "backend_raw_evidence_not_direct_user_report", + "source_metadata": _official_full_snapshot_metadata(None, manifest), + } + + if os.environ.get(ALLOW_NETWORK_ENV, "").strip().lower() not in {"1", "true", "yes"}: + return { + "backend": "vedastro_service_adapter_candidate", + "available": False, + "status": "network_execution_disabled", + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "reason": f"{ALLOW_NETWORK_ENV} is not enabled; official full snapshot stops after building request manifest.", + "snapshot_sections": {}, + "request_manifest": manifest, + "user_visibility": "backend_raw_evidence_not_direct_user_report", + "source_metadata": _official_full_snapshot_metadata(endpoint, manifest), + } + + sections: dict[str, Any] = {} + section_statuses: dict[str, str] = {} + attempt_count = 0 + retry_error_codes: list[int] = [] + for request_item in manifest["requests"]: + section = request_item["section"] + fanout_values = request_item.get("fanout_values") if isinstance(request_item.get("fanout_values"), list) else [] + if fanout_values: + section_payloads: dict[str, Any] = {} + fanout_statuses: dict[str, str] = {} + for value in fanout_values: + fanout_request = {**request_item, "fanout_value": value} + try: + payload, attempts, retries = _post_official_snapshot_section(endpoint, fanout_request) + section_payloads[str(value)] = payload + fanout_statuses[str(value)] = _payload_status(payload) + attempt_count += attempts + retry_error_codes.extend(retries) + except error.HTTPError as exc: + fanout_statuses[str(value)] = f"http_error:{exc.code}" + except (error.URLError, http.client.RemoteDisconnected) as exc: + fanout_statuses[str(value)] = f"network_error:{getattr(exc, 'reason', str(exc))}" + except (TimeoutError, socket.timeout): + fanout_statuses[str(value)] = "timeout" + except json.JSONDecodeError: + fanout_statuses[str(value)] = "invalid_json" + sections[section] = section_payloads + section_statuses[section] = _aggregate_section_status(list(fanout_statuses.values())) + section_statuses[f"{section}_fanout"] = fanout_statuses + continue + + try: + payload, attempts, retries = _post_official_snapshot_section(endpoint, request_item) + sections[section] = payload + section_statuses[section] = _payload_status(payload) + attempt_count += attempts + retry_error_codes.extend(retries) + except error.HTTPError as exc: + section_statuses[section] = f"http_error:{exc.code}" + except (error.URLError, http.client.RemoteDisconnected) as exc: + section_statuses[section] = f"network_error:{getattr(exc, 'reason', str(exc))}" + except (TimeoutError, socket.timeout): + section_statuses[section] = "timeout" + except json.JSONDecodeError: + section_statuses[section] = "invalid_json" + + return _normalize_official_full_snapshot_success( + endpoint, + manifest, + sections, + section_statuses, + attempt_count or 1, + retry_error_codes, + ) + + +def run_official_full_snapshot(case_id: str, reference_date: str | None = None) -> dict[str, Any]: + if case_id not in PARITY_CASES: + return { + "backend": "vedastro_service_adapter_candidate", + "available": False, + "status": "unknown_case_id", + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "reason": f"Unknown parity case: {case_id}", + } + case = dict(PARITY_CASES[case_id]) + if reference_date: + case["reference_date"] = reference_date + return _run_official_full_snapshot_case(case, case_id=case_id) + + +def run_official_full_snapshot_for_case( + case: dict[str, Any], + *, + case_id: str = "user_chart", +) -> dict[str, Any]: + return _run_official_full_snapshot_case(case, case_id=case_id) + + def _run_range_scan_case(case: dict[str, Any], domain: str, start_date: str, end_date: str) -> dict[str, Any]: if domain not in SUPPORTED_RANGE_SCAN_DOMAINS: return { @@ -1330,9 +1912,11 @@ def main() -> int: parser.add_argument("--print-schema", action="store_true") parser.add_argument("--case", default="beijing_first_use_demo") parser.add_argument("--range-scan", action="store_true") + parser.add_argument("--official-full-snapshot", action="store_true") parser.add_argument("--domain", choices=sorted(SUPPORTED_EXTERNAL_TECHNIQUE_DOMAINS), default="marriage") parser.add_argument("--start-date", default="2026-01-01") parser.add_argument("--end-date", default="2031-01-01") + parser.add_argument("--reference-date", default=None) parser.add_argument("--external-technique", action="store_true") parser.add_argument("--method", default="") parser.add_argument("--api-endpoint", default="") @@ -1340,6 +1924,8 @@ def main() -> int: if args.print_schema: result = schema() + elif args.official_full_snapshot: + result = run_official_full_snapshot(args.case, reference_date=args.reference_date) elif args.external_technique: result = run_external_technique(args.case, args.domain, args.method, args.api_endpoint) elif args.range_scan: diff --git a/tests/test_vedastro_external_technique_evidence.py b/tests/test_vedastro_external_technique_evidence.py index 9d127d87..3335d83f 100644 --- a/tests/test_vedastro_external_technique_evidence.py +++ b/tests/test_vedastro_external_technique_evidence.py @@ -198,6 +198,45 @@ def test_strict_workflow_requires_vedastro_range_scan_boundary_for_timing_routes } in strict["technique_audit"] +def test_strict_workflow_exposes_official_snapshot_as_primary_evidence_layer() -> None: + modules = _finance_modules() + modules["vedastro_official_full_snapshot"] = { + "status": "partial", + "available": True, + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "official_chart": { + "source": "vedastro_official", + "planets": {"Sun": {"source": "vedastro_official", "sign": "Aries"}}, + "ascendant": {"source": "vedastro_official", "sign": "Leo"}, + }, + } + modules["source_priority"] = { + "mode": "vedastro_official_primary", + "priority": [ + "vedastro_official_snapshot", + "local_supplemental_modules", + "local_engine_fallback_when_official_blocked", + ], + "local_engine_role": "supplemental_crosscheck_or_fallback", + "official_snapshot_status": "partial", + } + + strict = _collect_strict_evidence("finance", {"modules": modules}) + + official = strict["present_evidence"]["vedastro_official_snapshot"] + assert official["level"] == "primary" + assert official["source"] == "vedastro_official" + assert official["status"] == "partial" + assert strict["present_evidence"]["source_priority"]["mode"] == "vedastro_official_primary" + assert any( + row["technique"] == "VedAstro Official Full Snapshot" + and row["status"] == "used" + and row["role"] == "primary_raw_evidence" + for row in strict["technique_audit"] + ) + + def test_strict_workflow_marks_vedastro_range_scan_used_without_score_or_label_override() -> None: base_result = {"modules": _finance_modules()} with_range_scan = {"modules": deepcopy(base_result["modules"])} diff --git a/tests/test_vedastro_official_full_snapshot.py b/tests/test_vedastro_official_full_snapshot.py new file mode 100644 index 00000000..0f9f55de --- /dev/null +++ b/tests/test_vedastro_official_full_snapshot.py @@ -0,0 +1,442 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +import types +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _run_adapter(*args: str, env: dict[str, str] | None = None) -> dict: + completed = subprocess.run( + [sys.executable, "scripts/vedastro_service_adapter.py", *args], + cwd=ROOT, + text=True, + capture_output=True, + timeout=120, + check=False, + env=env, + ) + assert completed.returncode == 0, completed.stderr or completed.stdout + return json.loads(completed.stdout) + + +def test_schema_declares_official_full_snapshot_contract() -> None: + report = _run_adapter("--print-schema") + + assert "official_full_snapshot_request_contract" in report + assert "BirthTime" in report["official_full_snapshot_request_contract"]["common_body_fields"] + assert report["official_full_snapshot_request_contract"]["primary_source"] == "vedastro_official" + assert "official_full_snapshot_response_contract" in report + assert "snapshot_sections" in report["official_full_snapshot_response_contract"] + assert report["vedastro_calculation_coverage"]["official_api_builder_calculators"] == "600+" + + +def test_official_full_snapshot_unconfigured_builds_full_request_manifest() -> None: + env = os.environ.copy() + env.pop("VEDASTRO_API_ENDPOINT", None) + env.pop("VEDASTRO_ENABLE_NETWORK", None) + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" + + report = _run_adapter("--official-full-snapshot", "--case", "user_REDACTED_YEAR_test", env=env) + + assert report["backend"] == "vedastro_service_adapter_candidate" + assert report["operation"] == "official_full_snapshot" + assert report["primary_source"] == "vedastro_official" + assert report["status"] == "service_endpoint_not_configured" + assert report["available"] is False + assert report["snapshot_sections"] == {} + assert report["request_manifest"]["source_role"] == "primary_official_raw_evidence" + section_names = {item["section"] for item in report["request_manifest"]["requests"]} + assert {"chart_core", "house_core", "dasha_all", "events_overview"}.issubset(section_names) + backlog_names = { + item["section"] + for item in report["request_manifest"]["method_catalog"]["backlog_sections"] + } + assert {"varga_all", "shadbala", "ashtakavarga"}.issubset(backlog_names) + assert report["user_visibility"] == "backend_raw_evidence_not_direct_user_report" + assert report["source_metadata"]["provenance_mode"] == "vedastro_official_primary_candidate" + + +def test_official_full_snapshot_preview_when_network_disabled() -> None: + env = os.environ.copy() + env["VEDASTRO_API_ENDPOINT"] = "https://example.invalid/api" + env.pop("VEDASTRO_ENABLE_NETWORK", None) + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" + + report = _run_adapter("--official-full-snapshot", "--case", "beijing_first_use_demo", env=env) + + assert report["status"] == "network_execution_disabled" + assert report["request_manifest"]["requests"] + assert report["source_metadata"]["endpoint"] == "https://example.invalid/api" + assert report["source_metadata"]["provenance_mode"] == "vedastro_official_primary_candidate" + + +def test_official_full_snapshot_dasha_request_uses_official_range_contract(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.delenv("VEDASTRO_API_ENDPOINT", raising=False) + monkeypatch.delenv("VEDASTRO_ENABLE_NETWORK", raising=False) + + report = adapter.run_official_full_snapshot_for_case( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + }, + case_id="unit_dasha_contract", + ) + + dasha_request = next( + item for item in report["request_manifest"]["requests"] if item["section"] == "dasha_all" + ) + body = dasha_request["body"] + + assert report["request_manifest"]["reference_date"] == "2026-06-29" + assert report["source_metadata"]["reference_date"] == "2026-06-29" + assert dasha_request["calculator_name"] == "DasaAtRange" + assert body["birthTime"]["StdTime"] == "REDACTED_TIME 17/04/REDACTED_YEAR +08:00" + assert body["startTime"]["StdTime"] == "00:00 01/01/2026 +08:00" + assert body["endTime"]["StdTime"] == "23:59 31/12/2026 +08:00" + assert body["levels"] == 3 + assert body["precisionHours"] == 100 + assert body["Ayanamsa"] == "lahiri" + assert "StartTime" not in body + assert "EndTime" not in body + assert "time" not in body + + +def test_official_full_snapshot_marks_semantic_rate_limit_payloads(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + calls = [] + + def fake_post(endpoint, request_item): + calls.append((request_item["section"], request_item.get("fanout_value"))) + if request_item["section"] == "chart_core" and request_item.get("fanout_value") == "Mars": + return { + "Status": "Fail", + "Payload": "Free tier rate limit exceeded (5 calls/minute).", + }, 1, [] + return { + "Status": "Pass", + "Payload": { + "AllPlanetData": { + "PlanetRasiD1Sign": {"Name": "Aries", "DegreesIn": {"TotalDegrees": "3.5"}}, + "PlanetNirayanaLongitude": {"TotalDegrees": "3.5"}, + }, + "AllHouseData": { + "HouseRasiD1Sign": {"Name": "Leo", "DegreesIn": {"TotalDegrees": "13.0"}}, + }, + "DasaAtRange": {}, + "SearchEvents": [], + }, + }, 1, [] + + monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://example.invalid/api") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") + monkeypatch.setattr(adapter, "_post_official_snapshot_section", fake_post) + + result = adapter.run_official_full_snapshot_for_case( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + "reference_date": "2026-06-29", + }, + case_id="unit_rate_limit", + ) + + assert ("chart_core", "Mars") in calls + assert result["section_statuses"]["chart_core"] == "rate_limited" + assert result["section_statuses"]["chart_core_fanout"]["Mars"] == "rate_limited" + assert result["source_metadata"]["rate_limited_sections"] == ["chart_core"] + assert result["source_metadata"]["production_hint"] == "configure_vedastro_api_key_or_self_host_official_api" + + +def test_orchestrator_attaches_official_full_snapshot_before_range_scan(monkeypatch) -> None: + from scripts import vedastro_evidence_orchestrator as orchestrator + + calls: list[str] = [] + + def fake_snapshot(birth_payload, *, case_id="user_chart"): + calls.append("snapshot") + assert birth_payload["reference_date"] == "2026-06-29" + return { + "backend": "vedastro_service_adapter_candidate", + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "available": False, + "status": "network_execution_disabled", + "snapshot_sections": {}, + "source_metadata": {"provenance_mode": "vedastro_official_primary_candidate"}, + } + + def fake_scan(case, domain, start_date, end_date, case_id): + calls.append(f"range:{domain}") + return { + "backend": "vedastro_service_adapter_candidate", + "available": False, + "status": "network_execution_disabled", + "operation": "range_scan", + "domain": domain, + "event_count": 0, + "evidence_ledger": [], + } + + monkeypatch.setattr(orchestrator, "run_official_full_snapshot_for_case", fake_snapshot) + monkeypatch.setattr(orchestrator, "run_range_scan_for_case", fake_scan) + + result = orchestrator.orchestrate_vedastro_evidence( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8.0, + }, + route="overview", + reference_date="2026-06-29", + ) + + assert calls[0] == "snapshot" + assert result["official_full_snapshot"]["primary_source"] == "vedastro_official" + assert result["source_metadata"]["node_coverage"]["official_full_snapshot_first"] is True + + +def test_shared_priority_promotes_official_chart_and_keeps_local_as_supplemental() -> None: + from scripts.vedastro_priority import apply_vedastro_source_priority + + local_chart = { + "source": "local_engine", + "planets": {"Sun": {"sign": "Cancer"}}, + "ascendant": {"sign": "Virgo"}, + "houses": {"House1": {"sign": "Virgo"}}, + "birth_info": {"date": "REDACTED_DATE"}, + } + report = {"chart": local_chart, "modules": {"chart": local_chart}} + official_snapshot = { + "status": "partial", + "available": True, + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "official_chart": { + "source": "vedastro_official", + "primary_source": "vedastro_official", + "planets": {"Sun": {"source": "vedastro_official", "sign": "Aries"}}, + "ascendant": {"source": "vedastro_official", "sign": "Leo"}, + "houses": {"House1": {"source": "vedastro_official", "sign": "Leo"}}, + "coverage": {"planet_count": 1, "house_count": 1}, + }, + } + + apply_vedastro_source_priority(report, official_snapshot=official_snapshot) + + assert report["chart"]["source"] == "vedastro_official_primary" + assert report["chart"]["primary_source"] == "vedastro_official" + assert report["chart"]["planets"]["Sun"]["sign"] == "Aries" + assert report["chart"]["source_priority"][0] == "vedastro_official_snapshot" + assert report["chart"]["local_engine_role"] == "supplemental_crosscheck_or_fallback" + assert report["modules"]["local_engine_chart_fallback"]["planets"]["Sun"]["sign"] == "Cancer" + assert report["modules"]["source_priority"]["mode"] == "vedastro_official_primary" + assert report["modules"]["source_priority"]["local_engine_role"] == "supplemental_crosscheck_or_fallback" + + +def test_shared_priority_marks_local_fallback_only_when_official_blocked() -> None: + from scripts.vedastro_priority import apply_vedastro_source_priority + + local_chart = { + "source": "local_engine", + "planets": {"Sun": {"sign": "Cancer"}}, + "ascendant": {"sign": "Virgo"}, + "houses": {"House1": {"sign": "Virgo"}}, + } + report = {"chart": local_chart, "modules": {"chart": local_chart}} + official_snapshot = { + "status": "network_execution_disabled", + "available": False, + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "reason": "network disabled", + } + + apply_vedastro_source_priority(report, official_snapshot=official_snapshot) + + assert report["chart"]["source"] == "local_engine_fallback" + assert report["chart"]["primary_source"] == "local_engine" + assert report["chart"]["fallback_reason"] == "VedAstro official snapshot blocked: network_execution_disabled" + assert report["modules"]["source_priority"]["mode"] == "local_fallback_official_blocked" + assert report["modules"]["source_priority"]["official_snapshot_status"] == "network_execution_disabled" + + +def test_full_reading_prompt_pack_exposes_vedastro_official_snapshot_boundary() -> None: + result = subprocess.run( + [ + sys.executable, + "scripts/jyotish_engine.py", + "full-reading", + "--year", + "REDACTED_YEAR", + "--month", + "4", + "--day", + "17", + "--hour", + "14", + "--minute", + "49", + "--lat", + "36.42", + "--lon", + "114.2", + "--tz", + "8", + "--today", + "2026-06-29", + "--transit-date", + "2026-06-29", + ], + cwd=ROOT, + text=True, + capture_output=True, + timeout=180, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + report = json.loads(result.stdout) + + snapshot = report["modules"]["vedastro_official_full_snapshot"] + assert snapshot["operation"] == "official_full_snapshot" + assert snapshot["primary_source"] == "vedastro_official" + prompt_snapshot = report["ai_prompt_pack"]["evidence_snapshot"]["vedastro_official_full_snapshot"] + assert prompt_snapshot["primary_source"] == "vedastro_official" + assert prompt_snapshot["status"] in {"ok", "partial", "service_endpoint_not_configured", "network_execution_disabled", "blocked"} + + +def test_full_reading_official_snapshot_uses_requested_reference_date(monkeypatch) -> None: + from scripts import jyotish_engine + + captured: dict[str, str] = {} + + def fake_snapshot(case, *, case_id="user_chart"): + captured["reference_date"] = case.get("reference_date") + return { + "status": "network_execution_disabled", + "available": False, + "operation": "official_full_snapshot", + "primary_source": "vedastro_official", + "reason": "stub", + "snapshot_sections": {}, + "source_metadata": {}, + } + + fake_adapter = types.SimpleNamespace(run_official_full_snapshot_for_case=fake_snapshot) + fake_priority = types.SimpleNamespace(apply_vedastro_source_priority=lambda report, official_snapshot: report) + monkeypatch.setitem(sys.modules, "vedastro_service_adapter", fake_adapter) + monkeypatch.setitem(sys.modules, "vedastro_priority", fake_priority) + + class Args: + year = REDACTED_YEAR + month = 4 + day = 17 + hour = 14 + minute = 49 + lat = 36.42 + lon = 114.2 + tz = 8 + ayanamsa = "lahiri" + node_mode = "mean" + today = "2026-06-09" + transit_date = "2026-06-09" + + report = {"modules": {}, "warnings": []} + jyotish_engine._attach_vedastro_official_full_snapshot(report, Args()) + + assert captured["reference_date"] == "2026-06-09" + assert report["modules"]["vedastro_official_full_snapshot"]["operation"] == "official_full_snapshot" + + +def test_official_full_snapshot_extracts_official_chart_and_varga_from_pass_payload(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + calls = [] + + def fake_post(endpoint, request_item): + calls.append((request_item["section"], request_item.get("fanout_value"))) + section = request_item["section"] + if section == "chart_core": + planet = request_item["fanout_value"] + return { + "Status": "Pass", + "Payload": { + "AllPlanetData": { + "PlanetRasiD1Sign": {"Name": "Aries", "DegreesIn": {"TotalDegrees": "3.5"}}, + "PlanetHoraD2Signs": {"Name": "Leo", "DegreesIn": {"TotalDegrees": "7.0"}}, + "PlanetNavamshaD9Sign": {"Name": "Taurus", "DegreesIn": {"TotalDegrees": "1.5"}}, + "PlanetDashamamshaD10Sign": {"Name": "Taurus", "DegreesIn": {"TotalDegrees": "5.0"}}, + "PlanetNirayanaLongitude": {"TotalDegrees": "3.5"}, + "HousePlanetOccupiesBasedOnSign": "House9", + } + }, + }, 1, [] + if section == "house_core": + return { + "Status": "Pass", + "Payload": { + "AllHouseData": { + "HouseRasiD1Sign": {"Name": "Leo", "DegreesIn": {"TotalDegrees": "13.0"}}, + "HouseHoraD2Sign": {"Name": "Leo", "DegreesIn": {"TotalDegrees": "26.0"}}, + "HouseNavamshaD9Sign": {"Name": "Cancer", "DegreesIn": {"TotalDegrees": "27.7"}}, + "HouseDashamamshaD10Sign": {"Name": "Sagittarius", "DegreesIn": {"TotalDegrees": "10.8"}}, + } + }, + }, 1, [] + return {"Status": "Fail", "Payload": "not mapped"}, 1, [] + + monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://example.invalid/api") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") + monkeypatch.setattr(adapter, "_post_official_snapshot_section", fake_post) + + result = adapter.run_official_full_snapshot_for_case( + { + "year": REDACTED_YEAR, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.42, + "lon": 114.2, + "tz": 8, + }, + case_id="unit", + ) + + assert ("chart_core", "Sun") in calls + assert ("house_core", "House1") in calls + assert result["status"] == "partial" + assert result["section_statuses"]["events_overview"] == "fail" + official_chart = result["official_chart"] + assert official_chart["source"] == "vedastro_official" + assert official_chart["planets"]["Sun"]["sign"] == "Aries" + assert official_chart["planets"]["Sun"]["source"] == "vedastro_official" + assert official_chart["planets"]["Sun"]["vargas"]["D9"]["sign"] == "Taurus" + assert official_chart["ascendant"]["sign"] == "Leo" + assert official_chart["ascendant"]["vargas"]["D10"]["sign"] == "Sagittarius"