feat: add Railway-ready Jyotish chat product

This commit is contained in:
Jesse_Chen
2026-07-15 22:23:17 +08:00
parent 4a268c75f2
commit 4aa0105fa4
77 changed files with 15439 additions and 80 deletions
+1 -1
View File
@@ -27,8 +27,8 @@ def delivery_matrix() -> list[dict]:
"label": "Local dev",
"user_url": "http://127.0.0.1:5173",
"commands": [
".venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
"cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173",
"python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
],
"api_required": True,
"scope": "Full web/app user experience with local API.",
+305 -5
View File
@@ -22,6 +22,17 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import urlparse
# This file is intentionally runnable as ``python scripts/jyotish_api_server.py``.
# In that mode Python adds ``scripts/`` (not the repository root) to sys.path,
# so later lazy imports such as ``from scripts.vedastro_gateway import ...``
# otherwise fail during a real consultation request.
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
REPO_ROOT = os.path.abspath(os.path.join(SCRIPTS_DIR, '..'))
if REPO_ROOT not in sys.path:
sys.path.insert(0, REPO_ROOT)
if SCRIPTS_DIR not in sys.path:
sys.path.insert(0, SCRIPTS_DIR)
try:
from scripts.local_env import load_local_env
except ModuleNotFoundError: # pragma: no cover - script execution path
@@ -35,9 +46,6 @@ try:
except ModuleNotFoundError: # pragma: no cover - script execution path
from western_oracle_adapter import build_packet_from_oracle_payload
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 = {}
_API_CHART_CACHE_SCOPE = 'api_chart_response'
@@ -66,6 +74,209 @@ def _western_evidence_packet_from_body(body: dict, route_packet: dict) -> dict |
}
def _consultation_reference_date(body: dict) -> datetime:
raw = (
body.get('reference_date')
or body.get('transit_date')
or body.get('today')
or body.get('current_date')
)
if isinstance(raw, str) and raw.strip():
try:
return datetime.fromisoformat(raw.strip().replace('Z', '+00:00'))
except ValueError:
pass
return datetime.now()
def _consultation_current_age(birth_payload: dict, body: dict) -> float:
try:
born = datetime(
int(birth_payload['year']),
int(birth_payload['month']),
int(birth_payload['day']),
int(float(birth_payload.get('hour', 0))),
int(float(birth_payload.get('minute', 0))),
)
except (KeyError, TypeError, ValueError):
return 0.0
reference = _consultation_reference_date(body)
return max(0.0, (reference.replace(tzinfo=None) - born).total_seconds() / (365.2425 * 86400))
def _attach_local_consultation_layers(handler, chart: dict, birth_payload: dict, body: dict) -> dict:
"""Attach locally-computable consultation layers before reports/evidence are assembled.
VedAstro is an optional external cross-check for the web chat. D9/D10, Arudha
padas (including A10/UL), and Narayana Dasha are available in this repository
and must not be reported as missing merely because the external provider is
not configured.
"""
if not isinstance(chart, dict) or not chart.get('success', True):
return chart
modules = chart.get('modules') if isinstance(chart.get('modules'), dict) else {}
chart['modules'] = modules
planets = chart.get('planets') if isinstance(chart.get('planets'), dict) else {}
ascendant = chart.get('ascendant') if isinstance(chart.get('ascendant'), dict) else {}
diagnostics = []
if planets and ascendant and not isinstance(modules.get('varga_full'), dict):
try:
varga_response = handler._compute_varga_full({
'planets': planets,
'ascendant': ascendant,
'divisions': ['D2', 'D4', 'D9', 'D10', 'D11'],
})
varga_result = varga_response.get('result') if isinstance(varga_response, dict) else None
if isinstance(varga_result, dict):
modules['varga_full'] = varga_result
except Exception as exc: # optional local layer; preserve core D1 result
diagnostics.append({'layer': 'varga_full', 'status': 'unavailable', 'reason': exc.__class__.__name__})
if planets and ascendant and not isinstance(modules.get('arudha_padas'), dict):
try:
jaimini = _load_local_module('jaimini')
planet_lons = {
name: float(data.get('lon'))
for name, data in planets.items()
if isinstance(data, dict) and data.get('lon') is not None
}
asc_sign_idx = int(ascendant.get('sign_idx', int(float(ascendant.get('lon', 0))) // 30)) % 12
arudha_padas = jaimini.calc_arudha_padas(asc_sign_idx, planet_lons)
if isinstance(arudha_padas, dict):
modules['arudha_padas'] = arudha_padas
modules.setdefault('jaimini', {})['arudha_padas'] = arudha_padas
chart['arudha_padas'] = arudha_padas
except Exception as exc:
diagnostics.append({'layer': 'arudha_padas', 'status': 'unavailable', 'reason': exc.__class__.__name__})
if planets and ascendant and not isinstance(modules.get('narayana_dasha'), dict):
try:
narayana = _load_local_module('narayana_dasha')
planet_lons = {
name: float(data.get('lon'))
for name, data in planets.items()
if isinstance(data, dict) and data.get('lon') is not None
}
asc_sign_idx = int(ascendant.get('sign_idx', int(float(ascendant.get('lon', 0))) // 30)) % 12
narayana_result = narayana.narayana_dasha_full_report(
lagna_sign_idx=asc_sign_idx,
planet_lons=planet_lons,
current_age=_consultation_current_age(birth_payload, body),
birth_year=int(birth_payload.get('year', 0) or 0),
)
if isinstance(narayana_result, dict):
modules['narayana_dasha'] = narayana_result
except Exception as exc:
diagnostics.append({'layer': 'narayana_dasha', 'status': 'unavailable', 'reason': exc.__class__.__name__})
chart['local_consultation_layers'] = {
'status': 'ready' if not diagnostics else 'partial',
'source': 'repository_local_engines',
'available': [
name
for name in ('varga_full', 'arudha_padas', 'narayana_dasha')
if isinstance(modules.get(name), dict) and modules.get(name)
],
'diagnostics': diagnostics,
}
return chart
def _build_consumer_context(
*,
question: str,
route_packet: dict,
chart: dict,
rectification: dict,
machine_evidence_packet: dict,
vedastro_official: dict,
) -> dict:
"""Build a chat-facing truth contract without exposing provider noise as a fatal error."""
sections = (
machine_evidence_packet.get('sections')
if isinstance(machine_evidence_packet.get('sections'), dict)
else {}
)
route = str(route_packet.get('question_type') or route_packet.get('primary_theme') or 'general')
route_requirements = {
'career': ['D1', 'D10', 'A10', 'dasha_boundaries', 'narayana_dasha'],
'relationship': ['D1', 'D9', 'UL', 'dasha_boundaries'],
'finance': ['D1', 'D2', 'dasha_boundaries'],
'timing': ['D1', 'dasha_boundaries', 'narayana_dasha'],
'general': ['D1', 'D9', 'dasha_boundaries'],
}
required = route_requirements.get(route, route_requirements['general'])
available_layers = sorted(
name for name, section in sections.items()
if isinstance(section, dict) and section.get('status') == 'used'
)
missing_route_layers = [
name for name in required
if not isinstance(sections.get(name), dict) or sections[name].get('status') != 'used'
]
d1_ready = 'D1' in available_layers and bool(chart.get('success', True))
hard_blockers = [] if d1_ready else ['core_chart_unavailable']
official_state = (
sections.get('external_oracle_status', {}).get('status')
if isinstance(sections.get('external_oracle_status'), dict)
else 'official_blocked'
)
optional_unavailable = []
if official_state != 'official_verified':
optional_unavailable.append({
'layer': 'vedastro_official_cross_check',
'status': official_state,
'impact': 'external_cross_validation_only',
})
rect_summary = rectification.get('summary') if isinstance(rectification.get('summary'), dict) else {}
warned_vargas = rect_summary.get('warned') if isinstance(rect_summary.get('warned'), list) else []
disabled_vargas = rect_summary.get('disabled') if isinstance(rect_summary.get('disabled'), list) else []
relevant_warned_vargas = [name for name in warned_vargas if name in required]
precise_timing_requested = route == 'timing' or bool(re.search(r'(具体|精确|哪一|几月|月份|日期|何时|什么时候|时间点|年份)', question or ''))
timing_layers_ready = all(name not in missing_route_layers for name in ('dasha_boundaries', 'narayana_dasha'))
precision_allows_timing = not any(name in disabled_vargas for name in ('D9', 'D10'))
can_answer_precise_timing = d1_ready and timing_layers_ready and precision_allows_timing and not missing_route_layers
limitation_parts = []
if missing_route_layers:
limitation_parts.append(f"当前问题仍缺少 {', '.join(missing_route_layers)}")
if relevant_warned_vargas:
limitation_parts.append(f"{', '.join(relevant_warned_vargas)} 对出生时间精度敏感")
return {
'core_status': 'blocked' if hard_blockers else ('degraded' if missing_route_layers else 'ready'),
'calculation_source': 'repository_local_engine',
'route': route,
'available_layers': available_layers,
'missing_route_layers': missing_route_layers,
'optional_unavailable_layers': optional_unavailable,
'hard_blockers': hard_blockers,
'precision': {
'warned_layers': warned_vargas,
'disabled_layers': disabled_vargas,
'summary': rect_summary.get('headline'),
},
'answer_policy': {
'can_answer_direction': d1_ready,
'can_answer_chart_interpretation': d1_ready,
'can_answer_precise_timing': can_answer_precise_timing,
'precise_timing_requested': precise_timing_requested,
'should_lead_with_limitations': bool(hard_blockers) or (precise_timing_requested and not can_answer_precise_timing),
'provider_unavailable_is_fatal': False,
},
'user_facing_limitation': ''.join(limitation_parts) if limitation_parts else None,
'provider_status': {
'vedastro': official_state,
'role': 'optional_external_cross_check',
'runtime_status': vedastro_official.get('status') if isinstance(vedastro_official, dict) else None,
},
}
def execute_consultation_workflow(
handler,
*,
@@ -175,6 +386,8 @@ def execute_consultation_workflow(
computed_chart = True
executed_steps.append('compute_chart')
chart = _attach_local_consultation_layers(handler, chart, birth_payload, body)
historical_backtest = {}
if 'run_historical_event_backtest' in runtime_planner.get('sync_steps', []):
historical_backtest = handler._run_high_rigor_historical_backtest(birth_payload, events)
@@ -222,6 +435,14 @@ def execute_consultation_workflow(
vedastro_official=vedastro_official,
vedastro_archive_manifest=vedastro_archive_manifest,
)
consumer_context = _build_consumer_context(
question=question,
route_packet=route_packet,
chart=chart,
rectification=rectification,
machine_evidence_packet=machine_evidence_packet,
vedastro_official=vedastro_official,
)
real_case_calibration = _UNIFIED_CONSULTATION_ORCHESTRATOR.real_case_calibration_catalog(
route_packet=route_packet,
machine_evidence_packet=machine_evidence_packet,
@@ -283,6 +504,7 @@ def execute_consultation_workflow(
'runtime_truth': runtime_truth,
'interpretation_source_runtime_coverage': interpretation_source_runtime_coverage,
'machine_evidence_packet': machine_evidence_packet,
'consumer_context': consumer_context,
'western_evidence_packet': western_evidence_packet or {},
'real_case_calibration': real_case_calibration,
'runtime_evidence_log': runtime_evidence_log,
@@ -2623,7 +2845,15 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
)
if skip_full_reading:
module_status['full_reading'] = 'skipped_reuse_chart_data'
full_modules = full_reading.get('modules', {}) if isinstance(full_reading, dict) else {}
# Consultation workflow already computed and attached the local modules to
# ``chart_data`` before asking for a thematic report. When the caller
# explicitly skips a second full-reading pass, reuse those modules rather
# than silently discarding D9/D10, Arudha padas and Narayana Dasha.
chart_modules = raw.get('modules') if isinstance(raw.get('modules'), dict) else {}
computed_modules = full_reading.get('modules', {}) if isinstance(full_reading, dict) else {}
full_modules = {**chart_modules, **computed_modules}
if chart_modules:
module_status['chart_modules'] = 'reused'
chart = None
if full_reading:
chart = self._chart_from_full_reading(full_reading)
@@ -2671,6 +2901,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'periods': (dasha or {}).get('periods') or (dasha or {}).get('timeline') or [],
'yogas': chart.get('yogas') or [],
'ashtakavarga': ashtakavarga or {},
'modules': full_modules,
}
if yogas and isinstance(yogas.get('result'), dict):
enriched['yogas'] = list(enriched['yogas']) + (yogas['result'].get('extended_yogas') or [])
@@ -2697,6 +2928,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'warnings': warnings,
'evidence_counts': {theme: len(items) for theme, items in evidence.items()},
'full_reading_used': bool(full_reading),
'chart_modules_reused': bool(chart_modules),
'full_reading_summary': full_reading.get('summary', {}) if isinstance(full_reading, dict) else {},
'full_reading_module_count': len(full_modules) if isinstance(full_modules, dict) else 0,
}
@@ -2704,11 +2936,19 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
def _thematic_evidence_source(self, mode, derived_context, has_custom_evidence):
if mode == 'derived_chart_evidence' and derived_context:
full_reading_used = bool(derived_context.get('full_reading_used'))
chart_modules_reused = bool(derived_context.get('chart_modules_reused'))
return {
'mode': mode,
'source': 'full_reading_modules' if full_reading_used else 'birth_or_chart_payload',
'source': (
'full_reading_modules'
if full_reading_used
else 'reused_chart_modules'
if chart_modules_reused
else 'birth_or_chart_payload'
),
'sample_fallback': False,
'full_reading_used': full_reading_used,
'chart_modules_reused': chart_modules_reused,
'full_reading_module_count': derived_context.get('full_reading_module_count', 0),
'full_reading_summary': derived_context.get('full_reading_summary', {}),
'module_status': derived_context.get('module_status', {}),
@@ -2856,6 +3096,66 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
source='shadbala',
details=top_strength,
))
varga = full_modules.get('varga_full') if isinstance(full_modules.get('varga_full'), dict) else {}
d10 = varga.get('D10_Dasamsa') or varga.get('D10')
if isinstance(d10, dict) and d10:
ascendant = d10.get('ascendant') or d10.get('Ascendant') or {}
d10_planets = d10.get('planets') if isinstance(d10.get('planets'), dict) else {}
house_chart = d10.get('house_chart') if isinstance(d10.get('house_chart'), list) else []
tenth_house = house_chart[9] if len(house_chart) >= 10 and isinstance(house_chart[9], list) else []
asc_sign = ascendant.get('sign_cn') or ascendant.get('sign') or '未知'
tenth_label = ''.join(str(name) for name in tenth_house) if tenth_house else '无行星直接落入'
items.append(self._theme_evidence(
'D10-Dashamsha-local',
'D10',
f'D10 事业分盘已完成:上升落 {asc_sign},第10宫为{tenth_label};用于校验职业角色、执行方式与社会位置。',
'neutral',
'strong',
source='chart.modules.varga_full.D10_Dasamsa',
details={
'ascendant': ascendant,
'tenth_house_planets': tenth_house,
'planet_count': len(d10_planets),
},
))
arudha = full_modules.get('arudha_padas') if isinstance(full_modules.get('arudha_padas'), dict) else {}
if not arudha and isinstance(full_modules.get('jaimini'), dict):
candidate = full_modules['jaimini'].get('arudha_padas')
arudha = candidate if isinstance(candidate, dict) else {}
padas = arudha.get('padas') if isinstance(arudha.get('padas'), dict) else arudha
a10 = padas.get('A10') if isinstance(padas, dict) else None
if isinstance(a10, dict) and a10:
sign = a10.get('sign_cn') or a10.get('sign') or '未知'
lord = a10.get('lord') or '未知'
items.append(self._theme_evidence(
'A10-Karma-Pada-local',
'A10',
f'A10(事业形象点)落 {sign},主星为 {lord};用于观察职业品牌、可见度与外界如何识别你的事业角色。',
'neutral',
'strong',
source='chart.modules.arudha_padas.A10',
details=a10,
))
narayana = full_modules.get('narayana_dasha') if isinstance(full_modules.get('narayana_dasha'), dict) else {}
current_narayana = narayana.get('current_dasha') if isinstance(narayana.get('current_dasha'), dict) else {}
current_md = current_narayana.get('md') if isinstance(current_narayana.get('md'), dict) else {}
current_ad = current_narayana.get('ad') if isinstance(current_narayana.get('ad'), dict) else {}
if current_md:
md_sign = current_md.get('sign_cn') or current_md.get('sign') or '未知'
ad_sign = current_ad.get('sign_cn') or current_ad.get('sign') or '未知'
items.append(self._theme_evidence(
'Narayana-Dasha-local',
'Narayana',
f'Narayana Dasha 已完成:当前主周期为 {md_sign},子周期为 {ad_sign};可与行星大运交叉判断事业阶段。',
'neutral',
'moderate',
source='chart.modules.narayana_dasha.current_dasha',
details={'current_dasha': current_narayana},
))
convergence = full_modules.get('dasa_convergence') if isinstance(full_modules, dict) else {}
if not isinstance(convergence, dict):
convergence = {}
+4
View File
@@ -1188,9 +1188,13 @@ def _base_strict_narrative_payload(route_label, strict, *, fallback_headline, st
monthly_frame = strict.get('monthly_adjudication_summary') if isinstance(strict, dict) else {}
monthly_frame = monthly_frame if isinstance(monthly_frame, dict) else {}
event_judgement = strict.get('event_judgement') if isinstance(strict, dict) else {}
event_judgement = event_judgement if isinstance(event_judgement, dict) else {}
adjudication = strict.get('adjudication_stages') if isinstance(strict, dict) else {}
adjudication = adjudication if isinstance(adjudication, dict) else {}
boundary_contract = strict.get('prediction_boundary_contract') if isinstance(strict, dict) else {}
boundary_contract = boundary_contract if isinstance(boundary_contract, dict) else {}
confidence_boundary = boundary_contract.get('confidence_boundary') if isinstance(boundary_contract, dict) else {}
confidence_boundary = confidence_boundary if isinstance(confidence_boundary, dict) else {}
confidence_cap = strict.get('confidence_cap') or event_judgement.get('confidence_cap') or 'unknown'
dominant_label = event_judgement.get('dominant_label') if isinstance(event_judgement, dict) else None
+2 -2
View File
@@ -299,8 +299,8 @@ def format_failure_summary(
lines.append(f"stderr tail:\n{tail_text(stderr)}")
lines.extend([
"普通用户启动路径:",
"1. 网页服务:cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173",
"2. 本地 API 服务:python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
"1. 本地 API 服务:.venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
"2. 网页服务:cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173",
"3. Open http://127.0.0.1:5173, then open Trust Center and run the health check.",
"4. PWA 安装壳只包装网页服务,本地 API 服务仍需单独启动。",
"Next action: Run the focused command above, add --keep-logs for browser click smoke, then compare the app state with the startup path above.",
+36 -5
View File
@@ -391,6 +391,15 @@ class UnifiedConsultationOrchestrator:
if isinstance(chart_data.get("special_lagnas"), dict)
else modules.get("special_lagnas") if isinstance(modules.get("special_lagnas"), dict) else {}
)
arudha_padas = (
chart_data.get("arudha_padas")
if isinstance(chart_data.get("arudha_padas"), dict)
else modules.get("arudha_padas") if isinstance(modules.get("arudha_padas"), dict) else {}
)
if not arudha_padas and isinstance(modules.get("jaimini"), dict):
jaimini_arudha = modules["jaimini"].get("arudha_padas")
arudha_padas = jaimini_arudha if isinstance(jaimini_arudha, dict) else {}
pada_map = arudha_padas.get("padas") if isinstance(arudha_padas.get("padas"), dict) else arudha_padas
ascendant = base_chart.get("ascendant") if isinstance(base_chart.get("ascendant"), dict) else {}
ascendant_sign = ascendant.get("sign") if isinstance(ascendant, dict) else None
functional_layer = derive_functional_benefic_malefic(ascendant_sign)
@@ -402,6 +411,15 @@ class UnifiedConsultationOrchestrator:
or official.get("raw_payload")
or official.get("raw")
)
official_state = self._vedastro_cloud_state(vedastro_official)
raw_response_section = (
self._section(raw_response, "vedastro_official.raw_response")
if official_state == "official_verified"
else {
"status": "received_unverified" if raw_response else "missing",
"source_path": "vedastro_official.raw_response",
}
)
sections = {
"D1": self._section(
base_chart.get("planets") and base_chart.get("ascendant"),
@@ -414,22 +432,35 @@ class UnifiedConsultationOrchestrator:
"planet_degrees": self._section(base_chart.get("planets"), "chart.planets"),
"house_degrees": self._section(base_chart.get("houses") or chart_data.get("houses"), "chart.houses"),
"dasha_boundaries": self._section(modules.get("dasha") or chart_data.get("dasha"), "modules.dasha"),
"narayana_dasha": self._section(modules.get("narayana_dasha"), "modules.narayana_dasha"),
"shadbala": self._section(modules.get("shadbala") or chart_data.get("shadbala"), "modules.shadbala"),
"ashtakavarga": self._section(modules.get("ashtakavarga") or chart_data.get("ashtakavarga"), "modules.ashtakavarga"),
"yogas": self._section(modules.get("yogas") or chart_data.get("yogas"), "modules.yogas"),
"UL": self._section(special_lagnas.get("UL") or special_lagnas.get("Upapada_Lagna"), "special_lagnas.UL"),
"A7": self._section(special_lagnas.get("A7") or special_lagnas.get("Darapada"), "special_lagnas.A7"),
"A10": self._section(special_lagnas.get("A10") or special_lagnas.get("A10_Karma_Pada"), "special_lagnas.A10"),
"UL": self._section(
pada_map.get("UL")
or arudha_padas.get("upapada")
or special_lagnas.get("UL")
or special_lagnas.get("Upapada_Lagna"),
"modules.arudha_padas.UL",
),
"A7": self._section(
pada_map.get("A7") or special_lagnas.get("A7") or special_lagnas.get("Darapada"),
"modules.arudha_padas.A7",
),
"A10": self._section(
pada_map.get("A10") or special_lagnas.get("A10") or special_lagnas.get("A10_Karma_Pada"),
"modules.arudha_padas.A10",
),
"KP_cusp": self._section(modules.get("kp") or modules.get("kp_cusps") or chart_data.get("kp_cusps"), "modules.kp_cusps"),
"functional_benefic_malefic": self._section(
functional_layer if functional_layer.get("status") == "used" else None,
"chart.ascendant.sign -> scripts.functional_benefics",
),
"external_oracle_status": {
"status": self._vedastro_cloud_state(vedastro_official),
"status": official_state,
"source_path": "vedastro_official.runtime_truth",
},
"vedastro_official_raw_response": self._section(raw_response, "vedastro_official.raw_response"),
"vedastro_official_raw_response": raw_response_section,
"vedastro_official_raw_archive_manifest": self._section(
archive_manifest if archive_manifest.get("archive_count") else None,
"vedastro_gateway.archives",
+16 -46
View File
@@ -13,6 +13,13 @@ from datetime import datetime
from pathlib import Path
from typing import Any
try:
from scripts.vedastro_python_bridge import call_method as call_bridge_method
from scripts.vedastro_python_bridge import list_capabilities as list_bridge_capabilities
except ModuleNotFoundError: # pragma: no cover - direct script execution
from vedastro_python_bridge import call_method as call_bridge_method
from vedastro_python_bridge import list_capabilities as list_bridge_capabilities
ROOT = Path(__file__).resolve().parents[1]
PYTHON = sys.executable
@@ -22,6 +29,7 @@ CATALOG_STUB_ENV = "VEDASTRO_OFFICIAL_CAPABILITY_CATALOG_STUB"
PLANETS = ["Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu", "Ascendant"]
HOUSES = [f"House{i}" for i in range(1, 13)]
DEFAULT_SIGIL_SAMPLE_LIMIT = int(os.environ.get("VEDASTRO_FULL_CATALOG_SAMPLE_LIMIT", "0") or 0)
SNAPSHOT_FANOUT_ENABLED = os.environ.get("VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED", "1").strip().lower() in {"1", "true", "yes", "on"}
DOMAIN_ORDER = [
"career",
"marriage",
@@ -434,18 +442,7 @@ def _method_payload_for_instance(method: str, case: dict[str, Any], identity: st
def _call_bridge(method: str, payload: dict[str, Any]) -> dict[str, Any]:
completed = subprocess.run(
[PYTHON, str(BRIDGE), "--method", method, "--params-json", json.dumps(payload, ensure_ascii=False)],
cwd=ROOT,
text=True,
capture_output=True,
timeout=240,
check=False,
env=os.environ.copy(),
)
if completed.returncode != 0:
return {"available": False, "status": "bridge_runtime_error", "stderr": (completed.stderr or "").strip()}
return json.loads(completed.stdout)
return call_bridge_method(method, payload)
def _list_official_capabilities() -> dict[str, Any]:
@@ -454,36 +451,7 @@ def _list_official_capabilities() -> dict[str, Any]:
payload = json.loads(stub_raw)
payload.setdefault("source", "stubbed_official_capability_catalog")
return payload
completed = subprocess.run(
[PYTHON, str(BRIDGE), "--list-capabilities"],
cwd=ROOT,
text=True,
capture_output=True,
timeout=240,
check=False,
env=os.environ.copy(),
)
if completed.returncode != 0:
return {
"available": False,
"status": "bridge_runtime_error",
"capabilities": [],
"buckets": {},
"stderr": (completed.stderr or "").strip(),
"source": "vedastro_official_capability_runner",
}
try:
return json.loads(completed.stdout)
except json.JSONDecodeError:
return {
"available": False,
"status": "bridge_invalid_json",
"capabilities": [],
"buckets": {},
"stdout_excerpt": (completed.stdout or "").strip()[:500],
"source": "vedastro_official_capability_runner",
}
return list_bridge_capabilities()
def run_selected_methods(methods: list[str], birth_payload: dict[str, Any]) -> dict[str, Any]:
@@ -769,7 +737,7 @@ def run_snapshot_bundle(bundle: str, birth_payload: dict[str, Any]) -> dict[str,
chart_core: dict[str, Any] = {}
planet_statuses: dict[str, str] = {}
for planet in PLANETS:
for planet in PLANETS if SNAPSHOT_FANOUT_ENABLED else []:
stub_key = f"AllPlanetData:{planet}"
if stub_key in stub_map:
report = stub_map[stub_key]
@@ -797,7 +765,7 @@ def run_snapshot_bundle(bundle: str, birth_payload: dict[str, Any]) -> dict[str,
house_core: dict[str, Any] = {}
house_statuses: dict[str, str] = {}
for house in HOUSES:
for house in HOUSES if SNAPSHOT_FANOUT_ENABLED else []:
stub_key = f"AllHouseData:{house}"
if stub_key in stub_map:
report = stub_map[stub_key]
@@ -861,10 +829,11 @@ def run_snapshot_bundle(bundle: str, birth_payload: dict[str, Any]) -> dict[str,
"available": bool(coverage_sections),
"status": overall_status,
"summary": {
"requested_method_count": len(PLANETS) + len(HOUSES) + len(scalar_methods),
"executed_method_count": len(PLANETS) + len(HOUSES) + len(scalar_methods),
"requested_method_count": (len(PLANETS) + len(HOUSES) if SNAPSHOT_FANOUT_ENABLED else 0) + len(scalar_methods),
"executed_method_count": (len(PLANETS) + len(HOUSES) if SNAPSHOT_FANOUT_ENABLED else 0) + len(scalar_methods),
"ok_count": ok_count,
"skipped_count": skipped_count,
"fanout_enabled": SNAPSHOT_FANOUT_ENABLED,
},
"result": {
"snapshot_sections": snapshot_sections,
@@ -874,6 +843,7 @@ def run_snapshot_bundle(bundle: str, birth_payload: dict[str, Any]) -> dict[str,
"filled_sections": coverage_sections,
"planet_count": len(chart_core),
"house_count": len(house_core),
"fanout_enabled": SNAPSHOT_FANOUT_ENABLED,
},
},
}
+149
View File
@@ -14,8 +14,12 @@ separate. It stays deliberately thin:
from __future__ import annotations
import argparse
import contextlib
import enum
import inspect
import importlib
import importlib.util
import io
import json
import os
import subprocess
@@ -339,6 +343,148 @@ if __name__ == "__main__":
"""
def _offset_to_string(offset: Any) -> str:
if isinstance(offset, str):
raw = offset.strip()
if not raw:
return "+00:00"
if raw[0] in "+-" and ":" in raw:
return raw
try:
offset = float(raw)
except ValueError:
return raw
if isinstance(offset, (int, float)):
sign = "+" if offset >= 0 else "-"
absolute = abs(float(offset))
hours = int(absolute)
minutes = int(round((absolute - hours) * 60))
if minutes == 60:
hours += 1
minutes = 0
return f"{sign}{hours:02d}:{minutes:02d}"
return "+00:00"
def _import_runtime_module() -> tuple[str | None, Any | None]:
for name in MODULE_CANDIDATES:
with contextlib.redirect_stdout(io.StringIO()):
try:
return name, importlib.import_module(name)
except ModuleNotFoundError:
continue
return None, None
def _coerce_runtime_value(module: Any, value: Any) -> Any:
if isinstance(value, list):
return [_coerce_runtime_value(module, item) for item in value]
if isinstance(value, dict):
enum_name = value.get("__vedastro_enum__")
if enum_name:
return getattr(getattr(module, enum_name), value["value"])
type_name = value.get("__vedastro_type__")
if type_name == "GeoLocation":
return module.GeoLocation(value["location_name"], value["longitude"], value["latitude"])
if type_name == "Time":
geolocation = _coerce_runtime_value(module, value["geolocation"]) if value.get("geolocation") else None
if value.get("time_string") is not None:
return module.Time(value["time_string"], geolocation)
time_string = (
f"{int(value['hour']):02d}:{int(value['minute']):02d} "
f"{int(value['day']):02d}/{int(value['month']):02d}/{int(value['year'])} "
f"{_offset_to_string(value.get('offset', '+00:00'))}"
)
return module.Time(time_string, geolocation)
if type_name:
target = getattr(module, type_name)
args = [_coerce_runtime_value(module, item) for item in value.get("args", [])]
kwargs = {key: _coerce_runtime_value(module, item) for key, item in value.get("kwargs", {}).items()}
if args or kwargs:
return target(*args, **kwargs)
payload = {key: _coerce_runtime_value(module, item) for key, item in value.items() if not key.startswith("__")}
return target(**payload)
return {key: _coerce_runtime_value(module, item) for key, item in value.items()}
return value
def _serialize_runtime_value(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, enum.Enum):
return {"type": type(value).__name__, "name": value.name, "value": value.value}
if isinstance(value, (list, tuple, set)):
return [_serialize_runtime_value(item) for item in value]
if isinstance(value, dict):
return {str(key): _serialize_runtime_value(item) for key, item in value.items()}
if hasattr(value, "to_json"):
try:
payload = value.to_json()
if isinstance(payload, str):
try:
return json.loads(payload)
except json.JSONDecodeError:
return payload
return _serialize_runtime_value(payload)
except Exception:
pass
if hasattr(value, "__dict__"):
return {key: _serialize_runtime_value(item) for key, item in vars(value).items() if not key.startswith("_")}
return str(value)
def _resolve_runtime_callable(module: Any, method_name: str) -> Any:
parts = [part for part in method_name.split(".") if part]
if not parts:
raise AttributeError("empty_method_name")
if len(parts) == 1:
return getattr(module.Calculate, parts[0])
current = module
for part in parts:
current = getattr(current, part)
return current
def _call_in_current_python(method: str, params: dict[str, Any]) -> dict[str, Any]:
module_name, module = _import_runtime_module()
if module is None:
return _missing_package_result(method)
try:
target = _resolve_runtime_callable(module, method)
args: list[Any] = []
kwargs: dict[str, Any] = {}
if isinstance(params, dict) and ("args" in params or "kwargs" in params):
args = [_coerce_runtime_value(module, item) for item in params.get("args", [])]
kwargs = {key: _coerce_runtime_value(module, item) for key, item in params.get("kwargs", {}).items()}
elif isinstance(params, dict):
kwargs = {key: _coerce_runtime_value(module, item) for key, item in params.items()}
elif isinstance(params, list):
args = [_coerce_runtime_value(module, item) for item in params]
elif params is not None:
args = [_coerce_runtime_value(module, params)]
with contextlib.redirect_stdout(io.StringIO()):
result = target(*args, **kwargs)
return {
"available": True,
"status": "ok",
"method": method,
"module_name": module_name,
"result": _serialize_runtime_value(result),
"python_bin": sys.executable,
"source": "vedastro_python_bridge",
}
except Exception as exc:
return {
"available": False,
"status": "bridge_runtime_error",
"method": method,
"module_name": module_name,
"python_bin": sys.executable,
"error": {"type": type(exc).__name__, "message": str(exc)},
"source": "vedastro_python_bridge",
}
def _package_available() -> bool:
if os.environ.get(FORCE_UNAVAILABLE_ENV, "").strip().lower() in {"1", "true", "yes"}:
return False
@@ -537,6 +683,9 @@ def call_method(method: str, params: dict[str, Any]) -> dict[str, Any]:
if os.environ.get(FORCE_UNAVAILABLE_ENV, "").strip().lower() in {"1", "true", "yes"}:
return _missing_package_result(method)
if _package_available():
return _call_in_current_python(method, params)
python_bin = _select_python_bin()
if not python_bin:
return _missing_package_result(method)
+36 -3
View File
@@ -15,6 +15,7 @@ import json
import os
import socket
import subprocess
import sys
import threading
import time
from datetime import datetime
@@ -36,6 +37,15 @@ VEDASTRO_PYTHON_BRIDGE = ROOT / "scripts" / "vedastro_python_bridge.py"
VEDASTRO_OFFICIAL_CAPABILITY_RUNNER = ROOT / "scripts" / "vedastro_official_capability_runner.py"
def _vedastro_python_bin() -> str:
"""Run VedAstro child processes in the active backend interpreter by default."""
return (
os.environ.get("VEDASTRO_PYTHON_BIN", "").strip()
or os.environ.get("PYTHON_BIN", "").strip()
or sys.executable
)
PARITY_CASES = {
"steve_jobs_public_aa": {
"year": 1955,
@@ -927,7 +937,7 @@ def _call_vedastro_python_bridge_high_value(method_key: str, payload: dict[str,
try:
completed = subprocess.run(
[
os.environ.get("PYTHON_BIN", "") or "python3",
_vedastro_python_bin(),
str(VEDASTRO_PYTHON_BRIDGE),
"--high-value",
method_key,
@@ -1025,7 +1035,7 @@ def _try_official_capability_runner_snapshot_bundle(case: dict[str, Any]) -> dic
try:
completed = subprocess.run(
[
os.environ.get("PYTHON_BIN", "") or "python3",
_vedastro_python_bin(),
str(VEDASTRO_OFFICIAL_CAPABILITY_RUNNER),
"--bundle",
"official_full_snapshot",
@@ -1102,7 +1112,7 @@ def _try_official_full_capability_catalog_bundle(case: dict[str, Any]) -> dict[s
try:
completed = subprocess.run(
[
os.environ.get("PYTHON_BIN", "") or "python3",
_vedastro_python_bin(),
str(VEDASTRO_OFFICIAL_CAPABILITY_RUNNER),
"--bundle",
"official_full_capability_catalog",
@@ -2479,6 +2489,18 @@ def _run_official_full_snapshot_case(case: dict[str, Any], case_id: str = "user_
)
endpoint = os.environ.get("VEDASTRO_API_ENDPOINT", "").strip()
network_enabled = os.environ.get(ALLOW_NETWORK_ENV, "").strip().lower() in {"1", "true", "yes"}
fanout_enabled = os.environ.get("VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED", "1").strip().lower() in {"1", "true", "yes", "on"}
if bridge_sections and not fanout_enabled:
return _normalize_official_full_snapshot_success(
endpoint,
manifest,
bridge_sections,
bridge_section_statuses,
1,
[],
official_python_bundle=official_python_bundle,
official_full_capability_catalog=official_full_capability_catalog,
)
if budget_exhausted and endpoint and network_enabled and _is_official_public_endpoint(endpoint):
result = {
"backend": "vedastro_service_adapter_candidate",
@@ -2726,6 +2748,17 @@ def _run_range_scan_case(case: dict[str, Any], domain: str, start_date: str, end
"source_metadata": _source_metadata(endpoint),
}
range_scan_enabled = os.environ.get("VEDASTRO_RANGE_SCAN_NETWORK_ENABLED", "1").strip().lower() in {"1", "true", "yes", "on"}
if not range_scan_enabled:
return {
"backend": "vedastro_service_adapter_candidate",
"available": False,
"status": "network_execution_disabled",
"reason": "VEDASTRO_RANGE_SCAN_NETWORK_ENABLED is disabled for the interactive chat path.",
"request_preview": request_preview,
"source_metadata": _source_metadata(endpoint),
}
sample_dates = _iter_sample_dates(start_date, end_date)
reports: list[dict[str, Any]] = []
for sample_date in sample_dates: