diff --git a/jyotish-app/export.js b/jyotish-app/export.js index e033efbe..25539a27 100644 --- a/jyotish-app/export.js +++ b/jyotish-app/export.js @@ -175,6 +175,16 @@ export function exportJSON(chartData, extras = {}) { payload.modules.workflows = extras.workflows; } + // ── 17. Vimsopaka Semantic Summary ── + if (extras.vimsopaka_semantic_summary) { + payload.modules = payload.modules || {}; + payload.modules.vimsopaka_semantic_summary = extras.vimsopaka_semantic_summary; + } + if (extras.vedastro_overview) { + payload.modules = payload.modules || {}; + payload.modules.vedastro_overview = extras.vedastro_overview; + } + downloadFile( JSON.stringify(payload, null, 2), `jyotish-chart-${birth_info?.date || 'export'}.json`, @@ -395,6 +405,10 @@ function _buildHTMLReport(chartData, extras) { const audit = extras.audit || {}; const workflows = extras.workflows || chartData._client_workflows || {}; const relationshipNarrative = extras.relationship_narrative || chartData?.ai_prompt_pack?.evidence_snapshot?.relationship_narrative || chartData?.relationship_narrative || null; + const vimsopakaSemanticSummary = extras.vimsopaka_semantic_summary || chartData?.ai_prompt_pack?.evidence_snapshot?.vimsopaka_semantic_summary || chartData?.vimsopaka_semantic_summary || null; + const functionalBeneficMalefic = extras.functional_benefic_malefic || chartData?.ai_prompt_pack?.evidence_snapshot?.functional_benefic_malefic || chartData?.functional_benefic_malefic || null; + const vedastroOverview = extras.vedastro_overview || chartData?.ai_prompt_pack?.evidence_snapshot?.vedastro_overview || chartData?.vedastro_overview || null; + const techniqueAuditTable = extras.technique_audit_table || chartData?.ai_prompt_pack?.evidence_snapshot?.technique_audit_table || chartData?.technique_audit_table || null; const generatedAt = new Date().toLocaleString('zh-CN'); return ` @@ -589,6 +603,12 @@ function _buildHTMLReport(chartData, extras) { ` : '

当前导出未附带 Dasha 时间线。

'} + ${_functionalRoleSummarySection(functionalBeneficMalefic)} + + ${_vimsopakaSemanticSummarySection(vimsopakaSemanticSummary)} + + ${_techniqueAuditTableSection(techniqueAuditTable)} + ${_relationshipStrictNarrativeSection(relationshipNarrative)} ${_workflowReportSection(workflows)} @@ -618,6 +638,60 @@ function _buildHTMLReport(chartData, extras) { `; } +function _functionalRoleSummarySection(snapshot) { + if (!snapshot || typeof snapshot !== 'object') return ''; + if (snapshot.status === 'blocked' || snapshot.status === 'not_used') return ''; + const benefics = Array.isArray(snapshot.functional_benefics) ? snapshot.functional_benefics : []; + const malefics = Array.isArray(snapshot.functional_malefics) ? snapshot.functional_malefics : []; + const neutrals = Array.isArray(snapshot.functional_neutrals) ? snapshot.functional_neutrals : []; + const yogakarakas = Array.isArray(snapshot.yogakarakas) ? snapshot.yogakarakas : []; + return `
+

Functional Benefic/Malefic

+
+ ${_table([ + ['状态', snapshot.status || 'used'], + ['Ascendant', snapshot.ascendant || snapshot.asc_sign || '-'], + ['Functional Benefics', benefics.join('、') || '—'], + ['Functional Malefics', malefics.join('、') || '—'], + ['Functional Neutrals', neutrals.join('、') || '—'], + ['Yogakarakas', yogakarakas.join('、') || '—'], + ['Confidence Impact', snapshot.effect_on_confidence || '已进入高严谨裁决链。'], + ])} +
+
`; +} + +function _vimsopakaSemanticSummarySection(summary) { + if (!summary || typeof summary !== 'object') return ''; + const highlights = Array.isArray(summary.highlights) ? summary.highlights : []; + const warnings = Array.isArray(summary.warnings) ? summary.warnings : []; + const status = summary.status || 'blocked'; + return `
+

Vimsopaka 语义摘要

+
+

将 Vimsopaka dignity 结果转成用户可见语义,避免只剩分数没有解释边界。

+ ${_table([ + ['状态', status], + ['积极语义', highlights.length ? highlights.join(';') : '—'], + ['风险语义', warnings.length ? warnings.join(';') : '—'], + ])} +
+
`; +} + +function _techniqueAuditTableSection(rows) { + if (!Array.isArray(rows) || !rows.length) return ''; + const formattedRows = rows.map(row => ([ + row.technique || '-', + row.status || '-', + row.note || '-', + ])); + return `
+

Technique Audit Table

+ ${_table([['Technique', 'Status', 'Note'], ...formattedRows])} +
`; +} + function _relationshipStrictNarrativeSection(narrative) { if (!narrative || typeof narrative !== 'object') return ''; const strengths = Array.isArray(narrative.strengths) ? narrative.strengths : []; diff --git a/jyotish-app/main.js b/jyotish-app/main.js index 602f78cb..c9ef3b5e 100644 --- a/jyotish-app/main.js +++ b/jyotish-app/main.js @@ -7481,6 +7481,18 @@ function buildExportExtras(sourceChart) { || sourceChart?.relationship_narrative || sourceChart?.modules?.relationship_strict_evidence?.user_narrative || null; + const vimsopakaSemanticSummary = sourceChart?.ai_prompt_pack?.evidence_snapshot?.vimsopaka_semantic_summary + || sourceChart?.vimsopaka_semantic_summary + || null; + const functionalBeneficMalefic = sourceChart?.ai_prompt_pack?.evidence_snapshot?.functional_benefic_malefic + || sourceChart?.functional_benefic_malefic + || null; + const vedastroOverview = sourceChart?.ai_prompt_pack?.evidence_snapshot?.vedastro_overview + || sourceChart?.vedastro_overview + || null; + const techniqueAuditTable = sourceChart?.ai_prompt_pack?.evidence_snapshot?.technique_audit_table + || sourceChart?.technique_audit_table + || null; return { dasha, yogas, vargas, aspects, jaimini, @@ -7488,6 +7500,10 @@ function buildExportExtras(sourceChart) { shadbala: sb, ashtakavarga: _buildAVModule(av), panchanga, validation, audit, actionableContext, relationship_narrative: relationshipNarrative, + vimsopaka_semantic_summary: vimsopakaSemanticSummary, + functional_benefic_malefic: functionalBeneficMalefic, + vedastro_overview: vedastroOverview, + technique_audit_table: techniqueAuditTable, provenance: sourceChart._client_audit?.provenance, workflows: buildWorkflowExportExtras(sourceChart), }; diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 461003d6..088706e6 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -14,13 +14,20 @@ import io import json, sys, os, math import importlib.util import re +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta from http.server import HTTPServer, BaseHTTPRequestHandler from urllib.parse import urlparse +try: + from scripts.local_env import load_local_env +except ModuleNotFoundError: # pragma: no cover - script execution path + from local_env import load_local_env + SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.abspath(os.path.join(SCRIPTS_DIR, '..')) sys.path.insert(0, SCRIPTS_DIR) +load_local_env(REPO_ROOT) _LOCAL_MODULE_CACHE = {} @@ -38,6 +45,158 @@ def _load_local_module(module_name): _LOCAL_MODULE_CACHE[module_name] = module return module + +def _attach_vedastro_main_entry_overview(chart_result, birth_payload): + if not isinstance(chart_result, dict): + return chart_result + modules = chart_result.setdefault('modules', {}) + if not isinstance(modules, dict): + modules = {} + chart_result['modules'] = modules + if modules.get('vedastro_range_scan_result'): + return chart_result + + try: + adapter = _load_local_module('vedastro_service_adapter') + except Exception: + return chart_result + + reference_date = str( + birth_payload.get('transit_date') + or birth_payload.get('today') + or datetime.utcnow().strftime('%Y-%m-%d') + )[:10] + start_date = datetime.strptime(reference_date, '%Y-%m-%d').date() + end_date = start_date + case = { + 'year': birth_payload.get('year'), + 'month': birth_payload.get('month'), + 'day': birth_payload.get('day'), + 'hour': birth_payload.get('hour'), + 'minute': birth_payload.get('minute'), + 'second': birth_payload.get('second', 0), + 'lat': birth_payload.get('lat'), + 'lon': birth_payload.get('lon'), + 'tz': birth_payload.get('tz'), + 'ayanamsa_policy': birth_payload.get('ayanamsa') or 'lahiri', + 'node_policy': birth_payload.get('node_mode') or birth_payload.get('nodeMode') or 'mean', + } + + def _scan_domain(domain: str): + return domain, adapter.run_range_scan_for_case( + case, + domain=domain, + start_date=start_date.isoformat(), + end_date=end_date.isoformat(), + case_id=f"api_chart_{domain}", + ) + + domain_reports = {} + combined_events = [] + domain_statuses = {} + top_events = {} + failure_reason = None + availability = True + + with ThreadPoolExecutor(max_workers=3) as executor: + for domain, domain_report in executor.map(_scan_domain, ('career', 'marriage', 'wealth')): + domain_reports[domain] = domain_report + for domain in ('career', 'marriage', 'wealth'): + domain_report = domain_reports[domain] + domain_statuses[domain] = domain_report.get('status') + availability = availability and bool(domain_report.get('available', False)) + if domain_report.get('status') != 'ok' and failure_reason is None: + failure_reason = domain_report.get('reason') + for event in domain_report.get('evidence_ledger') or []: + if isinstance(event, dict): + combined_events.append(event) + top_event = domain_report.get('top_event') + if isinstance(top_event, dict): + top_events[domain] = top_event + + primary_status = next( + ( + domain_reports[domain].get('status') + for domain in ('career', 'marriage', 'wealth') + if domain_reports.get(domain, {}).get('status') == 'ok' + ), + domain_reports.get('marriage', {}).get('status') or 'blocked', + ) + source_metadata = { + 'ingestion_profile': 'main_entry_overview', + 'search_scope': 'single_day_overview', + 'reference_date': reference_date, + 'scan_window': {'start': start_date.isoformat(), 'end': end_date.isoformat()}, + 'domain_statuses': domain_statuses, + 'domain_event_counts': { + domain: int((domain_reports.get(domain, {}) or {}).get('event_count', 0) or 0) + for domain in ('career', 'marriage', 'wealth') + }, + } + for domain in ('career', 'marriage', 'wealth'): + metadata = domain_reports.get(domain, {}).get('source_metadata') + if isinstance(metadata, dict): + for key in ( + 'endpoint', + 'endpoint_host', + 'transport', + 'provenance_mode', + 'timeout_seconds', + 'retry_policy', + ): + if key in metadata and key not in source_metadata: + source_metadata[key] = metadata[key] + + modules['vedastro_range_scan_result'] = { + 'backend': 'vedastro_service_adapter_candidate', + 'available': availability, + 'status': primary_status, + 'operation': 'range_scan', + 'domain': 'overview', + 'event_count': len(combined_events), + 'top_event': top_events.get('marriage') or next(iter(top_events.values()), None), + 'top_events_by_domain': top_events, + 'evidence_ledger': combined_events, + 'source_metadata': source_metadata, + 'reason': failure_reason, + 'domain_reports': domain_reports, + } + return chart_result + + +def _build_vedastro_overview_payload_from_chart(chart): + modules = chart.get('modules') if isinstance(chart, dict) else {} + overview = modules.get('vedastro_range_scan_result') if isinstance(modules, dict) else {} + if not isinstance(overview, dict): + return { + 'status': 'blocked', + 'source': 'vedastro_service_adapter_candidate', + 'ingestion_profile': None, + 'search_scope': None, + 'reference_date': None, + 'event_count': 0, + 'domain_statuses': {}, + 'top_events_by_domain': {}, + 'boundary_note': 'VedAstro main-entry overview was not attached.', + 'visibility': 'user_visible_overview_only', + } + metadata = overview.get('source_metadata') if isinstance(overview.get('source_metadata'), dict) else {} + return { + 'status': overview.get('status') or 'blocked', + 'source': overview.get('backend') or 'vedastro_service_adapter_candidate', + 'ingestion_profile': metadata.get('ingestion_profile'), + 'search_scope': metadata.get('search_scope'), + 'reference_date': metadata.get('reference_date'), + 'event_count': int(overview.get('event_count', 0) or 0), + 'domain_statuses': metadata.get('domain_statuses') or {}, + 'top_events_by_domain': overview.get('top_events_by_domain') or {}, + 'boundary_note': ( + overview.get('reason') + or 'This is overview only and does not replace explicit long-range VedAstro scans.' + ), + 'visibility': 'user_visible_overview_only', + } + SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo', 'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces'] @@ -533,6 +692,39 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return html[:body_close.start()] + summary + html[body_close.start():] return html + summary + def _inject_vimsopaka_semantic_summary(self, html, snapshot): + if not isinstance(snapshot, dict): + return html + if snapshot.get('status') in {None, 'blocked'}: + return html + highlights = snapshot.get('highlights') if isinstance(snapshot.get('highlights'), list) else [] + warnings = snapshot.get('warnings') if isinstance(snapshot.get('warnings'), list) else [] + if not highlights and not warnings: + return html + + def _escape(value): + return html_lib.escape(str(value or '')) + + def _line_items(items): + return ''.join(f'
  • {_escape(item)}
  • ' for item in items) or '
  • None
  • ' + + summary = ( + '
    ' + '

    Vimsopaka Semantic Summary

    ' + f'

    Status: {_escape(snapshot.get("status"))}

    ' + '

    Highlights:

    ' + f'' + '

    Warnings:

    ' + f'' + '
    ' + ) + body_close = re.search(r'', html, re.IGNORECASE) + if body_close: + return html[:body_close.start()] + summary + html[body_close.start():] + return html + summary + def _inject_relationship_narrative_summary(self, html, narrative): if not isinstance(narrative, dict): return html @@ -592,6 +784,49 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return html[:body_close.start()] + summary + html[body_close.start():] return html + summary + def _inject_vedastro_overview_summary(self, html, overview): + if not isinstance(overview, dict): + return html + if not overview.get('status'): + return html + + def _escape(value): + return html_lib.escape(str(value or '')) + + domain_statuses = overview.get('domain_statuses') if isinstance(overview.get('domain_statuses'), dict) else {} + top_events = overview.get('top_events_by_domain') if isinstance(overview.get('top_events_by_domain'), dict) else {} + domain_lines = ''.join( + f'
  • {_escape(domain)}: {_escape(status)}
  • ' + for domain, status in domain_statuses.items() + ) or '
  • None
  • ' + top_event_lines = ''.join( + f'
  • {_escape(domain)}: {_escape((payload or {}).get("signal_label") or (payload or {}).get("event_id") or "-")} · {_escape((payload or {}).get("start") or "-")}
  • ' + for domain, payload in top_events.items() + if isinstance(payload, dict) + ) or '
  • None
  • ' + + summary = ( + '
    ' + '

    VedAstro External Overview

    ' + f'

    Status: {_escape(overview.get("status"))}

    ' + f'

    Ingestion Profile: {_escape(overview.get("ingestion_profile"))}

    ' + f'

    Search Scope: {_escape(overview.get("search_scope"))}

    ' + f'

    Reference Date: {_escape(overview.get("reference_date"))}

    ' + f'

    Event Count: {_escape(overview.get("event_count"))}

    ' + '

    Domain Statuses:

    ' + f'' + '

    Top Events By Domain:

    ' + f'' + f'

    Boundary: {_escape(overview.get("boundary_note"))}

    ' + '
    ' + ) + body_close = re.search(r'', html, re.IGNORECASE) + if body_close: + return html[:body_close.start()] + summary + html[body_close.start():] + return html + summary + def _artifact_base64(self, path): size = os.path.getsize(path) if size > MAX_REPORT_BASE64_BYTES: @@ -727,10 +962,18 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): html, body.get('functional_benefic_malefic'), ) + html = self._inject_vimsopaka_semantic_summary( + html, + body.get('vimsopaka_semantic_summary'), + ) html = self._inject_relationship_narrative_summary( html, body.get('relationship_narrative'), ) + html = self._inject_vedastro_overview_summary( + html, + body.get('vedastro_overview'), + ) fmt = body.get('format', 'html') if fmt not in {'html', 'pdf'}: raise BadRequest('format must be html or pdf') @@ -1999,6 +2242,31 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'available_dashas': dasha_list, 'dasha_count': len(dasha_list), } + result['modules'] = { + 'chart': { + 'planets': result['planets'], + 'ascendant': result['ascendant'], + 'houses': result['houses'], + 'birth_info': result['birth'], + }, + 'dasha': result['dasha'], + 'shadbala': {'planets': sb.get('planets', {})} if 'sb' in locals() and isinstance(sb, dict) else {}, + } + _attach_vedastro_main_entry_overview(result, { + 'year': year, + 'month': month, + 'day': day, + 'hour': int(hour), + 'minute': int(minute), + 'second': int(second), + 'lat': lat, + 'lon': lon, + 'tz': tz, + 'ayanamsa': ayanamsa_name, + 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), + 'today': body.get('today') or body.get('current_date'), + 'transit_date': body.get('transit_date'), + }) result['ai_prompt_pack'] = self._build_chart_prompt_pack(result) return result except ImportError: @@ -2059,6 +2327,29 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'special_lagnas': special_lagnas, 'available_dashas': [], 'dasha_count': 0, } + result['modules'] = { + 'chart': { + 'planets': result['planets'], + 'ascendant': result['ascendant'], + 'houses': result['houses'], + 'birth_info': result['birth'], + }, + 'dasha': result['dasha'], + 'shadbala': {}, + } + _attach_vedastro_main_entry_overview(result, { + 'year': year, + 'month': month, + 'day': day, + 'hour': int(hour), + 'minute': int(minute), + 'second': int(second), + 'lat': lat, + 'lon': lon, + 'tz': tz, + 'ayanamsa': 'lahiri', + 'node_mode': 'mean', + }) result['ai_prompt_pack'] = self._build_chart_prompt_pack(result) return result @@ -2069,6 +2360,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): dasha = chart.get('dasha') or {} shadbala = chart.get('shadbala') or {} functional_layer = self._functional_benefic_malefic_snapshot(planets, ascendant) + vedastro_overview = _build_vedastro_overview_payload_from_chart(chart) top_strength = sorted( [ { @@ -2138,6 +2430,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'shadbala_ranking': top_strength, }, 'functional_benefic_malefic': functional_layer, + 'vedastro_overview': vedastro_overview, 'quality_boundary': { 'external_oracle_status': 'D1/D9/VedAstro longitude boundary covered; Dasha/Shadbala external absolute calibration still requires multi-source oracle expansion.', }, diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index afe02343..f6dd2f4b 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -43,6 +43,7 @@ import csv import math import sqlite3 import importlib.util +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta from typing import Dict, List from tabulate import tabulate @@ -67,6 +68,13 @@ DB_PATH = os.path.join(CLAW_DIR, 'vedic_astrology_validation.db') PERSON_CSV = os.path.join(CLAW_DIR, 'vedastro_data', 'PersonList-15k.csv') TRANSIT_JSON = os.path.join(CLAW_DIR, '月运过境配置-2026-2028.json') +try: + from local_env import load_local_env +except ModuleNotFoundError: # pragma: no cover - compatibility when imported as package + from scripts.local_env import load_local_env + +load_local_env(ROOT_DIR) + try: import swisseph as swe apply_ayanamsa('lahiri', swe) # P0修复:必须设置Lahiri恒星黄道模式 @@ -140,6 +148,29 @@ DIGNITY_LABELS = { 'DEBILITATED': '落陷(Debilitated)', 'NEECHA_BHANGA': '落陷取消(Neecha Bhanga)', } + + +def _build_vimsopaka_semantic_summary(vimsopaka: dict | None) -> dict: + if not isinstance(vimsopaka, dict): + return {"status": "blocked", "highlights": [], "warnings": []} + + highlights = [] + warnings = [] + for planet, payload in vimsopaka.items(): + if not isinstance(payload, dict): + continue + dignity = payload.get('dignity') + label = DIGNITY_LABELS.get(dignity) + if dignity in {'GREAT_FRIEND', 'NEECHA_BHANGA'} and label: + highlights.append(f"{planet}: {label}") + elif dignity == 'GREAT_ENEMY' and label: + warnings.append(f"{planet}: {label}") + + return { + "status": "used", + "highlights": highlights, + "warnings": warnings, + } PUSHKARA_NAVAMSA_RANGES = { 'fire': [(6 + 40/60, 10), (23 + 20/60, 26 + 40/60)], 'earth': [(3 + 20/60, 6 + 40/60), (16 + 40/60, 20)], @@ -888,6 +919,7 @@ def _build_technique_audit_table(functional_layer, oracle_progress, modules): vimsopaka = modules.get('vimsopaka') if isinstance(modules, dict) else {} dasa_convergence = modules.get('dasa_convergence') if isinstance(modules, dict) else {} relationship = modules.get('relationship_strict_evidence') if isinstance(modules, dict) else {} + vedastro_overview = modules.get('vedastro_range_scan_result') if isinstance(modules, dict) else {} rows = [ { @@ -959,6 +991,17 @@ def _build_technique_audit_table(functional_layer, oracle_progress, modules): "不得把这些支持越权解释成 legal marriage 的高置信度落地。" ), }) + vedastro_meta = vedastro_overview.get('source_metadata') if isinstance(vedastro_overview, dict) else {} + rows.append({ + 'technique': 'VedAstro Main Entry Overview', + 'status': 'used' if isinstance(vedastro_overview, dict) and vedastro_overview.get('status') == 'ok' else 'blocked', + 'source': 'modules.vedastro_range_scan_result', + 'note': ( + f"overview only; status={vedastro_overview.get('status') if isinstance(vedastro_overview, dict) else None}; " + f"domain_statuses={vedastro_meta.get('domain_statuses') if isinstance(vedastro_meta, dict) else None}; " + f"reference_date={vedastro_meta.get('reference_date') if isinstance(vedastro_meta, dict) else None}." + ), + }) return rows @@ -1068,6 +1111,39 @@ def _build_relationship_narrative_payload(relationship_strict): } +def _build_vedastro_overview_payload(modules): + overview = modules.get('vedastro_range_scan_result') if isinstance(modules, dict) else {} + if not isinstance(overview, dict): + return { + 'status': 'blocked', + 'source': 'vedastro_service_adapter_candidate', + 'ingestion_profile': None, + 'search_scope': None, + 'reference_date': None, + 'event_count': 0, + 'domain_statuses': {}, + 'top_events_by_domain': {}, + 'boundary_note': 'VedAstro main-entry overview was not attached.', + 'visibility': 'user_visible_overview_only', + } + metadata = overview.get('source_metadata') if isinstance(overview.get('source_metadata'), dict) else {} + return { + 'status': overview.get('status') or 'blocked', + 'source': overview.get('backend') or 'vedastro_service_adapter_candidate', + 'ingestion_profile': metadata.get('ingestion_profile'), + 'search_scope': metadata.get('search_scope'), + 'reference_date': metadata.get('reference_date'), + 'event_count': int(overview.get('event_count', 0) or 0), + 'domain_statuses': metadata.get('domain_statuses') or {}, + 'top_events_by_domain': overview.get('top_events_by_domain') or {}, + 'boundary_note': ( + overview.get('reason') + or 'This is overview only and does not replace explicit long-range VedAstro scans.' + ), + 'visibility': 'user_visible_overview_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 {} @@ -1087,6 +1163,8 @@ def _build_ai_prompt_pack(report): oracle_progress = _oracle_progress_snapshot() technique_audit_table = _build_technique_audit_table(functional_layer, oracle_progress, modules) 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) shadbala_ranking = [] for planet_name, pdata in sorted( @@ -1157,8 +1235,10 @@ def _build_ai_prompt_pack(report): }, 'oracle_progress': oracle_progress, 'functional_benefic_malefic': functional_layer, + 'vedastro_overview': vedastro_overview, 'technique_audit_table': technique_audit_table, 'relationship_narrative': relationship_narrative, + 'vimsopaka_semantic_summary': vimsopaka_semantic_summary, } prompt_lines = [ @@ -1169,6 +1249,7 @@ def _build_ai_prompt_pack(report): "必须显式标注置信度和边界:Dasha/PDF 起点差异、Shadbala 外部绝对值 oracle 尚未完成时,不得声称已经完全校准。", "输出结构建议:参数声明、核心星盘、关系/事业/财富/健康分主题、当前时机、证据表、风险边界、可行动建议。", "若引用经典法则,请优先检索 retrieval_plan.local_reference_docs;需要外部断语时再做 web/source verification。", + "若 evidence_snapshot.vedastro_overview.status 为 ok,请把它作为用户可见外部概览证据明确写出,但不要把 overview-only 结果误当作长周期精扫结论。", ] return { @@ -1196,6 +1277,133 @@ def _build_ai_prompt_pack(report): } +def _overview_vedastro_reference_date(args) -> str: + candidate = ( + getattr(args, 'transit_date', None) + or getattr(args, 'today', None) + or datetime.now().strftime('%Y-%m-%d') + ) + return str(candidate)[:10] + + +def _overview_vedastro_scan_window(reference_date: str) -> tuple[str, str]: + start = datetime.strptime(reference_date, '%Y-%m-%d').date() + return start.isoformat(), start.isoformat() + + +def _attach_vedastro_main_entry_overview(report, args): + if not isinstance(report, dict): + return report + modules = report.setdefault('modules', {}) + if not isinstance(modules, dict): + return report + if modules.get('vedastro_range_scan_result'): + return report + + try: + from vedastro_service_adapter import run_range_scan_for_case + except Exception as exc: # pragma: no cover - import guard + report.setdefault('warnings', []).append(f"vedastro-main-entry-import: {exc}") + return report + + reference_date = _overview_vedastro_reference_date(args) + start_date, end_date = _overview_vedastro_scan_window(reference_date) + 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'), + } + + def _scan_domain(domain: str): + return domain, run_range_scan_for_case( + case, + domain=domain, + start_date=start_date, + end_date=end_date, + case_id=f"main_entry_{domain}", + ) + + domain_reports = {} + combined_events = [] + domain_statuses = {} + top_events = {} + failure_reason = None + availability = True + + with ThreadPoolExecutor(max_workers=3) as executor: + for domain, domain_report in executor.map(_scan_domain, ('career', 'marriage', 'wealth')): + domain_reports[domain] = domain_report + for domain in ('career', 'marriage', 'wealth'): + domain_report = domain_reports[domain] + domain_statuses[domain] = domain_report.get('status') + availability = availability and bool(domain_report.get('available', False)) + if domain_report.get('status') != 'ok' and failure_reason is None: + failure_reason = domain_report.get('reason') + for event in domain_report.get('evidence_ledger') or []: + if isinstance(event, dict): + combined_events.append(event) + top_event = domain_report.get('top_event') + if isinstance(top_event, dict): + top_events[domain] = top_event + + primary_status = next( + ( + domain_reports[domain].get('status') + for domain in ('career', 'marriage', 'wealth') + if domain_reports.get(domain, {}).get('status') == 'ok' + ), + domain_reports.get('marriage', {}).get('status') or 'blocked', + ) + source_metadata = { + 'ingestion_profile': 'main_entry_overview', + 'search_scope': 'single_day_overview', + 'reference_date': reference_date, + 'scan_window': {'start': start_date, 'end': end_date}, + 'domain_statuses': domain_statuses, + 'domain_event_counts': { + domain: int((domain_reports.get(domain, {}) or {}).get('event_count', 0) or 0) + for domain in ('career', 'marriage', 'wealth') + }, + } + for domain in ('career', 'marriage', 'wealth'): + metadata = domain_reports.get(domain, {}).get('source_metadata') + if isinstance(metadata, dict): + for key in ( + 'endpoint', + 'endpoint_host', + 'transport', + 'provenance_mode', + 'timeout_seconds', + 'retry_policy', + ): + if key in metadata and key not in source_metadata: + source_metadata[key] = metadata[key] + + modules['vedastro_range_scan_result'] = { + 'backend': 'vedastro_service_adapter_candidate', + 'available': availability, + 'status': primary_status, + 'operation': 'range_scan', + 'domain': 'overview', + 'event_count': len(combined_events), + 'top_event': top_events.get('marriage') or next(iter(top_events.values()), None), + 'top_events_by_domain': top_events, + 'evidence_ledger': combined_events, + 'source_metadata': source_metadata, + 'reason': failure_reason, + 'domain_reports': domain_reports, + } + return report + + def _load_relationship_strict_collector(): try: from mcp_server import _collect_strict_evidence as collector @@ -4917,6 +5125,11 @@ def cmd_full_reading(args): except Exception as e: report['errors'].append(f"relationship-strict-evidence: {e}") + try: + _attach_vedastro_main_entry_overview(report, args) + except Exception as e: + report['warnings'].append(f"vedastro-main-entry-overview: {e}") + report['ai_prompt_pack'] = _build_ai_prompt_pack(report) return report diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index fe4bb6f9..9c2c8a9a 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -190,6 +190,18 @@ def test_vedastro_status_endpoint_exposes_safe_adapter_state(monkeypatch) -> Non assert payload['live_profile'] == 'vedastro-live' +def test_vedastro_status_endpoint_honors_explicit_network_disable_even_when_local_env_exists(monkeypatch) -> None: + monkeypatch.setenv('VEDASTRO_API_ENDPOINT', 'https://vedastro.example.test/secret/path') + monkeypatch.setenv('VEDASTRO_ENABLE_NETWORK', '0') + handler = _VedAstroStatusCaptureHandler() + + handler.do_GET() + + payload = handler.payload() + assert payload['network_enabled'] is False + assert payload['status'] == 'network_execution_disabled' + + def test_vedastro_range_scan_endpoint_uses_user_birth_and_returns_controlled_blocked_state(monkeypatch) -> None: monkeypatch.delenv('VEDASTRO_API_ENDPOINT', raising=False) monkeypatch.delenv('VEDASTRO_ENABLE_NETWORK', raising=False) @@ -578,6 +590,59 @@ def test_report_artifact_can_render_functional_benefic_malefic_summary() -> None assert '高严谨模式下必须叠加功能性吉凶星。' in html +def test_report_artifact_can_render_vimsopaka_semantic_summary() -> None: + handler = _handler() + result = handler._compute_report_artifact({ + 'format': 'html', + 'name': 'vimsopaka-semantic-report', + 'html': '

    Jyotish

    ', + 'vimsopaka_semantic_summary': { + 'status': 'used', + 'highlights': ['Sun: 极友(Great Friend)', 'Moon: 落陷取消(Neecha Bhanga)'], + 'warnings': ['Mars: 极敌(Great Enemy)'], + }, + }) + + assert result['success'] is True + html = Path(result['html_path']).read_text(encoding='utf-8') + assert 'Vimsopaka Semantic Summary' in html + assert 'Great Friend' in html + assert 'Neecha Bhanga' in html + assert 'Great Enemy' in html + + +def test_report_artifact_can_render_vedastro_external_overview() -> None: + handler = _handler() + result = handler._compute_report_artifact({ + 'format': 'html', + 'name': 'vedastro-overview-report', + 'html': '

    Jyotish

    ', + 'vedastro_overview': { + 'status': 'ok', + 'source': 'vedastro_service_adapter_candidate', + 'ingestion_profile': 'main_entry_overview', + 'search_scope': 'single_day_overview', + 'reference_date': '2026-06-29', + 'event_count': 5, + 'domain_statuses': {'career': 'ok', 'marriage': 'ok', 'wealth': 'ok'}, + 'top_events_by_domain': { + 'marriage': {'signal_label': 'Jupiter in 7th marriage window', 'start': '2026-06-29'}, + 'career': {'signal_label': 'Jupiter in 10th career window', 'start': '2026-06-29'}, + }, + 'boundary_note': 'This is overview only and does not replace explicit long-range scans.', + }, + }) + + assert result['success'] is True + html = Path(result['html_path']).read_text(encoding='utf-8') + assert 'VedAstro External Overview' in html + assert 'main_entry_overview' in html + assert 'single_day_overview' in html + assert '2026-06-29' in html + assert 'Jupiter in 7th marriage window' in html + assert 'overview only' in html + + def test_report_artifact_can_render_relationship_strict_narrative_summary() -> None: handler = _handler() result = handler._compute_report_artifact({ @@ -1534,6 +1599,36 @@ def test_chart_ai_prompt_pack_exposes_functional_benefic_malefic_layer() -> None assert isinstance(functional['functional_benefics'], list) assert isinstance(functional['functional_malefics'], list) assert functional['effect_on_confidence'] + vedastro = prompt_pack['evidence_snapshot']['vedastro_overview'] + assert vedastro['source'] == 'vedastro_service_adapter_candidate' + assert vedastro['ingestion_profile'] == 'main_entry_overview' + assert vedastro['visibility'] == 'user_visible_overview_only' + + +def test_chart_auto_attaches_vedastro_main_entry_boundary(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://example.invalid/api") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + monkeypatch.setenv("JYOTISH_SKIP_LOCAL_ENV", "1") + + handler = _handler() + result = handler._compute_chart({ + 'year': 1990, + 'month': 6, + 'day': 15, + 'hour': 12, + 'minute': 0, + 'lat': 39.9, + 'lon': 116.4, + 'tz': 8, + }) + + assert "modules" in result + vedastro = result["modules"]["vedastro_range_scan_result"] + assert vedastro["backend"] == "vedastro_service_adapter_candidate" + assert vedastro["status"] == "network_execution_disabled" + assert vedastro["source_metadata"]["ingestion_profile"] == "main_entry_overview" + assert vedastro["source_metadata"]["reference_date"] + assert sorted(vedastro["source_metadata"]["domain_statuses"]) == ["career", "marriage", "wealth"] def test_yogas_endpoint_returns_summary_counts() -> None: diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 1aba2d05..43126b52 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -313,6 +313,10 @@ def test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack() -> None: assert relationship_narrative["markdown"] assert "D9" in "".join(relationship_narrative["boundaries"]) assert "dual dasha" in relationship_narrative["markdown"] + vimsopaka_summary = prompt_pack["evidence_snapshot"]["vimsopaka_semantic_summary"] + assert vimsopaka_summary["status"] == "used" + assert isinstance(vimsopaka_summary["highlights"], list) + assert isinstance(vimsopaka_summary["warnings"], list) oracle_progress = prompt_pack["evidence_snapshot"]["oracle_progress"] assert oracle_progress["scope"] == "external_oracle_evidence_validation" assert oracle_progress["valid_packets"] >= 4 @@ -320,6 +324,49 @@ def test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack() -> None: assert oracle_progress["production_tuning_allowed"] is False assert oracle_progress["artifact_policy"] == "references/oracle/artifacts/" assert "external_verified" in oracle_progress["promotion_rule"] + vedastro_overview = prompt_pack["evidence_snapshot"]["vedastro_overview"] + assert vedastro_overview["status"] in {"ok", "network_execution_disabled", "service_endpoint_not_configured"} + assert vedastro_overview["source"] == "vedastro_service_adapter_candidate" + assert vedastro_overview["ingestion_profile"] == "main_entry_overview" + assert vedastro_overview["visibility"] == "user_visible_overview_only" + vedastro_rows = [row for row in audit_table if row["technique"] == "VedAstro Main Entry Overview"] + assert vedastro_rows + assert vedastro_rows[0]["status"] in {"used", "blocked"} + assert "overview only" in vedastro_rows[0]["note"] + assert "domain_statuses" in vedastro_rows[0]["note"] + + +def test_full_reading_auto_attaches_vedastro_main_entry_boundary() -> None: + env = dict(**__import__("os").environ) + env["VEDASTRO_API_ENDPOINT"] = "https://example.invalid/api" + env["VEDASTRO_ENABLE_NETWORK"] = "0" + env["JYOTISH_SKIP_LOCAL_ENV"] = "1" + + completed = subprocess.run( + [ + sys.executable, + str(ENGINE), + "full-reading", + *BASE_BIRTH_ARGS, + "--today", + "2026-06-29", + "--transit-date", + "2026-06-29", + ], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + env=env, + ) + result = json.loads(completed.stdout) + + vedastro = result["modules"]["vedastro_range_scan_result"] + assert vedastro["backend"] == "vedastro_service_adapter_candidate" + assert vedastro["status"] == "network_execution_disabled" + assert vedastro["source_metadata"]["ingestion_profile"] == "main_entry_overview" + assert vedastro["source_metadata"]["reference_date"] == "2026-06-29" + assert sorted(vedastro["source_metadata"]["domain_statuses"]) == ["career", "marriage", "wealth"] def test_full_reading_exposes_sensitive_point_modules() -> None: diff --git a/tests/test_frontend_productization.py b/tests/test_frontend_productization.py index 39f1ba36..987e825b 100644 --- a/tests/test_frontend_productization.py +++ b/tests/test_frontend_productization.py @@ -1931,7 +1931,24 @@ def test_provenance_panchanga_workspace_panel_is_productized() -> None: assert "_relationshipStrictNarrativeSection" in export_js assert "relationship_report" in export_js assert "relationship_narrative" in export_js + assert "vimsopaka_semantic_summary" in export_js + assert "functional_benefic_malefic" in export_js + assert "vedastro_overview" in export_js + assert "technique_audit_table" in export_js assert "relationship_narrative" in main + assert "vimsopaka_semantic_summary" in main + assert "functional_benefic_malefic:" in main + assert "vedastro_overview:" in main + assert "technique_audit_table:" in main + assert "_functionalRoleSummarySection" in export_js + assert "_techniqueAuditTableSection" in export_js + assert "_vimsopakaSemanticSummarySection" in export_js + assert "Functional Benefic/Malefic" in export_js + assert "Technique Audit Table" in export_js + assert "Vimsopaka 语义摘要" in export_js + assert "highlights" in export_js + assert "warnings" in export_js + assert "vimsopaka_semantic_summary:" in main assert "strictNarrative" in main assert "relationship-deliverable" in export_js assert "relationship-evidence-grid" in export_js