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
+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 = {}