Files
Jyotisha/scripts/jyotish_api_server.py
T
2026-06-24 13:37:33 +08:00

4455 lines
209 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
印度占星 API 服务器 v1.0
为 jyotish-app 前端提供 v6.9.14 引擎的精算能力
启动: python3 scripts/jyotish_api_server.py --port 5200
"""
import argparse
import base64
import io
import json, sys, os, math
import importlib.util
import re
from datetime import datetime, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse
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)
_LOCAL_MODULE_CACHE = {}
def _load_local_module(module_name):
cached = _LOCAL_MODULE_CACHE.get(module_name)
if cached:
return cached
module_path = os.path.join(SCRIPTS_DIR, f'{module_name}.py')
spec = importlib.util.spec_from_file_location(f'_jyotish_local_{module_name}', module_path)
if not spec or not spec.loader:
raise ImportError(f'Cannot load local module: {module_name}')
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
_LOCAL_MODULE_CACHE[module_name] = module
return module
SIGNS = ['Aries','Taurus','Gemini','Cancer','Leo','Virgo',
'Libra','Scorpio','Sagittarius','Capricorn','Aquarius','Pisces']
DEFAULT_ALLOWED_ORIGINS = {
'http://localhost:3456',
'http://127.0.0.1:3456',
'http://localhost:3457',
'http://127.0.0.1:3457',
'http://localhost:5173',
'http://127.0.0.1:5173',
}
MAX_REQUEST_BYTES = 2 * 1024 * 1024
MAX_IMPORT_FILE_BYTES = 1536 * 1024
MAX_IMPORT_TEXT_CHARS = 500_000
MAX_REPORT_HTML_CHARS = 1_200_000
MAX_REPORT_BASE64_BYTES = 8 * 1024 * 1024
REPORT_ARTIFACT_DIR = os.path.join('/private/tmp', 'jyotish-reports')
API_COMMAND_MAP = {
'chart': '/api/chart',
'kp': '/api/kp',
'prashna': '/api/prashna',
'synastry': '/api/synastry',
'ashtakoot': '/api/synastry',
'dasha': '/api/dasha',
'remedies': '/api/remedies',
'sade_sati': '/api/sade_sati',
'pancha_mahapurusha': '/api/pancha_mahapurusha',
'career': '/api/career',
'relationship': '/api/relationship',
'full-reading': '/api/chart',
'tajika': '/api/annual',
'solar-return': '/api/annual',
'muhurta': '/api/muhurta',
'panchanga-range': '/api/panchanga_range',
'bhava-chalit': '/api/bhava_chalit',
'sudarshana': '/api/sudarshana',
'nakshatra-full': '/api/nakshatra_full',
'varga-full': '/api/varga_full',
'jaimini': '/api/jaimini',
'ashtakavarga': '/api/ashtakavarga',
'shadbala': '/api/shadbala',
'yoga': '/api/yogas',
'aspects': '/api/aspects',
'rectification': '/api/rectification_gate',
'case-validation': '/api/case_validation',
'divisional-yoga': '/api/divisional_yoga',
'deep-varga-avastha': '/api/deep_varga_avastha',
'kakshya': '/api/kakshya',
'bhava-bala': '/api/bhava_bala',
'transit-trigger': '/api/transit',
'audit-capabilities': '/api/capability_audit',
'thematic-report': '/api/thematic_report',
'report-artifact': '/api/report_artifact',
}
TECHNIQUE_EXAMPLE_ENDPOINTS = {
'/api/ashtakavarga',
'/api/bhava_bala',
'/api/bhava_chalit',
'/api/career',
'/api/case_validation',
'/api/dasha',
'/api/divisional_yoga',
'/api/deep_varga_avastha',
'/api/jaimini',
'/api/kakshya',
'/api/kp',
'/api/muhurta',
'/api/nakshatra_full',
'/api/pancha_mahapurusha',
'/api/prashna',
'/api/rectification_gate',
'/api/relationship',
'/api/remedies',
'/api/sade_sati',
'/api/shadbala',
'/api/sudarshana',
'/api/synastry',
'/api/thematic_report',
'/api/transit',
'/api/varga_full',
'/api/yogas',
}
SAMPLE_PLANETS = {
'Sun': {'lon': 280.0, 'degree': 10.0, 'degree_in_sign': 10.0, 'sign_idx': 9, 'sign': 'Capricorn', 'house': 10},
'Moon': {'lon': 123.0, 'degree': 3.0, 'degree_in_sign': 3.0, 'sign_idx': 4, 'sign': 'Leo', 'house': 5},
'Mars': {'lon': 210.0, 'degree': 0.0, 'degree_in_sign': 0.0, 'sign_idx': 7, 'sign': 'Scorpio', 'house': 8},
'Mercury': {'lon': 275.0, 'degree': 5.0, 'degree_in_sign': 5.0, 'sign_idx': 9, 'sign': 'Capricorn', 'house': 10},
'Jupiter': {'lon': 15.0, 'degree': 15.0, 'degree_in_sign': 15.0, 'sign_idx': 0, 'sign': 'Aries', 'house': 1},
'Venus': {'lon': 330.0, 'degree': 0.0, 'degree_in_sign': 0.0, 'sign_idx': 11, 'sign': 'Pisces', 'house': 12},
'Saturn': {'lon': 300.0, 'degree': 0.0, 'degree_in_sign': 0.0, 'sign_idx': 10, 'sign': 'Aquarius', 'house': 11},
'Rahu': {'lon': 45.0, 'degree': 15.0, 'degree_in_sign': 15.0, 'sign_idx': 1, 'sign': 'Taurus', 'house': 2},
'Ketu': {'lon': 225.0, 'degree': 15.0, 'degree_in_sign': 15.0, 'sign_idx': 7, 'sign': 'Scorpio', 'house': 8},
}
SAMPLE_ASCENDANT = {'sign': 'Aries', 'sign_idx': 0, 'degree': 12.0, 'degree_in_sign': 12.0, 'lon': 12.0}
# 城市数据库(简化版)
CITY_DB = {
'北京': (39.9, 116.4, 8), '上海': (31.2, 121.5, 8), '广州': (23.1, 113.3, 8),
'深圳': (22.5, 114.1, 8), '成都': (30.6, 104.1, 8), '重庆': (29.6, 106.5, 8),
'杭州': (30.3, 120.2, 8), '南京': (32.1, 118.8, 8), '武汉': (30.6, 114.3, 8),
'西安': (34.3, 108.9, 8), '郑州': (34.8, 113.7, 8), '长沙': (28.2, 113.0, 8),
'天津': (39.1, 117.2, 8), '香港': (22.3, 114.2, 8), '台北': (25.0, 121.5, 8),
'New York': (40.7, -74.0, -5), 'London': (51.5, -0.1, 0),
'Tokyo': (35.7, 139.7, 9), 'Sydney': (-33.9, 151.2, 10),
'Delhi': (28.6, 77.2, 5.5), 'Mumbai': (19.1, 72.9, 5.5),
'Paris': (48.9, 2.3, 1), 'Berlin': (52.5, 13.4, 1),
'Los Angeles': (34.1, -118.2, -8), 'Chicago': (41.9, -87.6, -6),
'San Francisco': (37.8, -122.4, -8), 'Seattle': (47.6, -122.3, -8),
'Boston': (42.4, -71.1, -5), 'Toronto': (43.7, -79.4, -5),
'Singapore': (1.3, 103.8, 8), 'Dubai': (25.2, 55.3, 4),
}
class BadRequest(ValueError):
"""Client-side request validation failed."""
class JyotishAPIHandler(BaseHTTPRequestHandler):
server_version = 'JyotishAPI/6.9.14'
def _json(self, data, status=200):
self.send_response(status)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self._send_cors_headers()
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
self.send_header('Vary', 'Origin')
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False, default=str).encode())
def _send_cors_headers(self):
origin = self.headers.get('Origin')
allowed = getattr(self.server, 'allowed_origins', DEFAULT_ALLOWED_ORIGINS)
if origin in allowed:
self.send_header('Access-Control-Allow-Origin', origin)
def do_OPTIONS(self):
self._json({})
def do_GET(self):
path = urlparse(self.path).path
if path == '/api/health':
self._json({
'status': 'ok',
'version': '6.9.14',
'modules': 'Chart/KP/Synastry/Prashna/Remedies/Dasha/Varga/Jaimini/Ashtakavarga/Shadbala/Yoga/Aspects/Tajika/Muhurta/BhavaChalit/BhavaBala/Sudarshana/Nakshatra/Transit/RectificationGate/CaseValidation/DivisionalYoga/Kakshya',
})
elif path == '/api/cities':
self._json(list(CITY_DB.keys()))
elif path == '/api/capability_audit':
self._json(self._capability_audit())
elif path == '/api/technique_catalog':
self._json(self._technique_catalog())
else:
self._json({'error': 'Not found'}, 404)
def do_POST(self):
path = urlparse(self.path).path
try:
body = self._read_json_body()
if path == '/api/chart':
result = self._compute_chart(body)
self._json(result)
elif path == '/api/remedies':
result = self._compute_remedies(body)
self._json(result)
elif path == '/api/kp':
result = self._compute_kp(body)
self._json(result)
elif path == '/api/prashna':
result = self._compute_prashna(body)
self._json(result)
elif path == '/api/synastry':
result = self._compute_synastry(body)
self._json(result)
elif path == '/api/dasha':
result = self._compute_dasha_system(body)
self._json(result)
elif path == '/api/sade_sati':
result = self._compute_sade_sati(body)
self._json(result)
elif path == '/api/pancha_mahapurusha':
result = self._compute_pmc(body)
self._json(result)
elif path == '/api/career':
result = self._compute_career(body)
self._json(result)
elif path == '/api/relationship':
result = self._compute_relationship(body)
self._json(result)
elif path == '/api/import_chart':
result = self._import_chart_text(body)
self._json(result)
elif path == '/api/report_artifact':
result = self._compute_report_artifact(body)
self._json(result)
elif path == '/api/annual':
result = self._compute_annual(body)
self._json(result)
elif path == '/api/muhurta':
result = self._compute_muhurta(body)
self._json(result)
elif path == '/api/panchanga_range':
result = self._compute_panchanga_range(body)
self._json(result)
elif path == '/api/bhava_chalit':
result = self._compute_bhava_chalit(body)
self._json(result)
elif path == '/api/sudarshana':
result = self._compute_sudarshana(body)
self._json(result)
elif path == '/api/nakshatra_full':
result = self._compute_nakshatra_full(body)
self._json(result)
elif path == '/api/varga_full':
result = self._compute_varga_full(body)
self._json(result)
elif path == '/api/jaimini':
result = self._compute_jaimini(body)
self._json(result)
elif path == '/api/ashtakavarga':
result = self._compute_ashtakavarga(body)
self._json(result)
elif path == '/api/shadbala':
result = self._compute_shadbala(body)
self._json(result)
elif path == '/api/yogas':
result = self._compute_yogas_api(body)
self._json(result)
elif path == '/api/aspects':
result = self._compute_aspects(body)
self._json(result)
elif path == '/api/rectification_gate':
result = self._compute_rectification_gate(body)
self._json(result)
elif path == '/api/case_validation':
result = self._compute_case_validation(body)
self._json(result)
elif path == '/api/divisional_yoga':
result = self._compute_divisional_yoga(body)
self._json(result)
elif path == '/api/deep_varga_avastha':
result = self._compute_deep_varga_avastha(body)
self._json(result)
elif path == '/api/kakshya':
result = self._compute_kakshya(body)
self._json(result)
elif path == '/api/bhava_bala':
result = self._compute_bhava_bala_api(body)
self._json(result)
elif path == '/api/transit':
result = self._compute_transit_triggers(body)
self._json(result)
elif path == '/api/thematic_report':
result = self._compute_thematic_report(body)
self._json(result)
elif path == '/api/technique_example':
result = self._compute_technique_example(body)
self._json(result)
else:
self._json({'error': f'Unknown endpoint: {path}'}, 404)
except BadRequest as e:
self._json({'error': str(e)}, 400)
except Exception:
import logging
logging.exception("[api_server] request failed for %s", path)
self._json({'error': 'Internal server error'}, 500)
def _read_json_body(self):
raw_length = self.headers.get('Content-Length', '0')
try:
length = int(raw_length)
except ValueError as e:
raise BadRequest('Invalid Content-Length') from e
if length < 0:
raise BadRequest('Invalid Content-Length')
if length > MAX_REQUEST_BYTES:
raise BadRequest(f'Request body too large; max {MAX_REQUEST_BYTES} bytes')
if length == 0:
return {}
try:
body = json.loads(self.rfile.read(length))
except json.JSONDecodeError as e:
raise BadRequest('Invalid JSON body') from e
if not isinstance(body, dict):
raise BadRequest('JSON body must be an object')
return body
def _get_int(self, body, key, default, min_value=None, max_value=None):
value = body.get(key, default)
try:
number = int(value)
except (TypeError, ValueError) as e:
raise BadRequest(f'{key} must be an integer') from e
self._check_range(key, number, min_value, max_value)
return number
def _get_float(self, body, key, default, min_value=None, max_value=None):
value = body.get(key, default)
try:
number = float(value)
except (TypeError, ValueError) as e:
raise BadRequest(f'{key} must be a number') from e
if not math.isfinite(number):
raise BadRequest(f'{key} must be finite')
self._check_range(key, number, min_value, max_value)
return number
def _check_range(self, key, number, min_value, max_value):
if min_value is not None and number < min_value:
raise BadRequest(f'{key} must be >= {min_value}')
if max_value is not None and number > max_value:
raise BadRequest(f'{key} must be <= {max_value}')
def _validate_planets(self, planets):
if not isinstance(planets, dict):
raise BadRequest('planets must be an object')
return planets
def _normalize_degree(self, body, key, default):
return self._get_float(body, key, default, 0, 360) % 360
def _safe_report_slug(self, value):
slug = re.sub(r'[^a-zA-Z0-9._-]+', '-', str(value or 'jyotish-report')).strip('-._')
return (slug[:80] or 'jyotish-report')
def _validate_report_html(self, html):
if not isinstance(html, str) or not html.strip():
raise BadRequest('html must be a non-empty string')
if len(html) > MAX_REPORT_HTML_CHARS:
raise BadRequest(f'html too large; max {MAX_REPORT_HTML_CHARS} characters')
active_patterns = [
r'<\s*script\b',
r'<\s*iframe\b',
r'<\s*object\b',
r'<\s*embed\b',
r'\son[a-z]+\s*=',
r'javascript\s*:',
]
if any(re.search(pattern, html, re.IGNORECASE) for pattern in active_patterns):
raise BadRequest('report html cannot include active content')
return html
def _artifact_base64(self, path):
size = os.path.getsize(path)
if size > MAX_REPORT_BASE64_BYTES:
return None
with open(path, 'rb') as fh:
return base64.b64encode(fh.read()).decode('ascii')
def _compute_report_artifact(self, body):
html = self._validate_report_html(body.get('html'))
fmt = body.get('format', 'html')
if fmt not in {'html', 'pdf'}:
raise BadRequest('format must be html or pdf')
slug = self._safe_report_slug(body.get('name') or body.get('filename'))
stamp = datetime.utcnow().strftime('%Y%m%d-%H%M%S-%f')
os.makedirs(REPORT_ARTIFACT_DIR, exist_ok=True)
html_filename = f'{slug}-{stamp}.html'
html_path = os.path.join(REPORT_ARTIFACT_DIR, html_filename)
with open(html_path, 'w', encoding='utf-8') as fh:
fh.write(html)
result = {
'success': True,
'endpoint': 'report_artifact',
'format': fmt,
'html_filename': html_filename,
'html_path': html_path,
'html_size_kb': round(os.path.getsize(html_path) / 1024, 1),
'html_base64': self._artifact_base64(html_path),
'mime': 'text/html;charset=utf-8',
'source': 'scripts/report_builder.py',
'artifact_status': 'html_ready',
'primary_artifact': 'html',
'download_filename': html_filename,
'download_mime': 'text/html;charset=utf-8',
'fallback_reason': None,
'user_message': 'HTML report artifact generated.',
'next_action': 'Open the downloaded HTML file directly, or print it to PDF from the browser.',
'delivery': {
'artifact_status': 'html_ready',
'format': 'html',
'filename': html_filename,
'mime': 'text/html;charset=utf-8',
'fallback': False,
'user_message': 'HTML report artifact generated.',
'next_action': 'Open the downloaded HTML file directly, or print it to PDF from the browser.',
},
}
if fmt == 'pdf':
pdf_filename = f'{slug}-{stamp}.pdf'
pdf_path = os.path.join(REPORT_ARTIFACT_DIR, pdf_filename)
try:
ok = _load_local_module('report_builder')._html_to_pdf(html_path, pdf_path)
except Exception as exc:
ok = False
result['pdf_error'] = str(exc)
result.update({
'pdf_available': bool(ok and os.path.exists(pdf_path)),
'pdf_filename': pdf_filename,
'pdf_path': pdf_path if os.path.exists(pdf_path) else None,
})
if result['pdf_available']:
result.update({
'format': 'pdf',
'mime': 'application/pdf',
'pdf_size_kb': round(os.path.getsize(pdf_path) / 1024, 1),
'pdf_base64': self._artifact_base64(pdf_path),
'artifact_status': 'pdf_ready',
'primary_artifact': 'pdf',
'download_filename': pdf_filename,
'download_mime': 'application/pdf',
'fallback_reason': None,
'user_message': 'PDF report artifact generated.',
'next_action': 'Open the downloaded PDF file for reading, printing, or archiving.',
'delivery': {
'artifact_status': 'pdf_ready',
'format': 'pdf',
'filename': pdf_filename,
'mime': 'application/pdf',
'fallback': False,
'user_message': 'PDF report artifact generated.',
'next_action': 'Open the downloaded PDF file for reading, printing, or archiving.',
},
})
else:
result['fallback'] = 'html'
result['message'] = 'PDF renderer unavailable; HTML artifact generated as fallback.'
result['fallback_reason'] = result.get('pdf_error') or 'PDF renderer unavailable'
result['artifact_status'] = 'pdf_fallback_html_ready'
result['primary_artifact'] = 'html'
result['download_filename'] = html_filename
result['download_mime'] = 'text/html;charset=utf-8'
result['user_message'] = 'PDF renderer unavailable; HTML report artifact generated as fallback.'
result['next_action'] = 'Open the downloaded HTML file directly, or print it to PDF from the browser.'
result['delivery'] = {
'artifact_status': 'pdf_fallback_html_ready',
'format': 'html',
'filename': html_filename,
'mime': 'text/html;charset=utf-8',
'fallback': True,
'fallback_reason': result['fallback_reason'],
'user_message': result['user_message'],
'next_action': result['next_action'],
}
return result
def _compute_thematic_report(self, body):
report_orchestrator = _load_local_module('report_orchestrator')
reading_orchestrator = _load_local_module('reading_orchestrator')
orchestrator_bridge = _load_local_module('orchestrator_bridge')
chart_data = body.get('chart_data') if isinstance(body.get('chart_data'), dict) else body
custom_evidence = body.get('evidence')
has_custom_evidence = isinstance(custom_evidence, dict) and bool(custom_evidence)
derived_context = None
if not has_custom_evidence and self._can_derive_thematic_evidence(chart_data):
derived_context = self._derive_thematic_evidence(chart_data, report_orchestrator)
if derived_context:
chart = self._build_thematic_chart_data(derived_context.get('chart_data', chart_data), report_orchestrator)
else:
chart = self._build_thematic_chart_data(chart_data, report_orchestrator)
orchestrator = report_orchestrator.ThematicReportOrchestrator(chart)
if has_custom_evidence:
self._inject_thematic_evidence(orchestrator, custom_evidence, report_orchestrator)
mode = 'custom_evidence'
elif derived_context and derived_context.get('evidence'):
self._inject_thematic_evidence(orchestrator, derived_context['evidence'], report_orchestrator)
mode = 'derived_chart_evidence'
else:
self._inject_sample_thematic_evidence(orchestrator, report_orchestrator)
mode = 'sample_evidence'
theme_values = self._requested_thematic_report_themes(body, report_orchestrator)
reports = {}
for theme in theme_values:
report = orchestrator.generate_report(theme)
reports[theme.value] = report.to_dict()
return {
'success': True,
'endpoint': 'thematic_report',
'source': 'scripts/report_orchestrator.py',
'fragment_sources': [
'report_orchestrator.py',
'reading_orchestrator.py',
'orchestrator_bridge.py',
],
'workflow_orchestration': self._thematic_workflow_status(
reading_orchestrator,
orchestrator_bridge,
report_orchestrator,
theme_values,
),
'mode': mode,
'evidence_source': self._thematic_evidence_source(mode, derived_context, has_custom_evidence),
'themes': reports,
'theme_count': len(reports),
'available_themes': [theme.value for theme in report_orchestrator.ThemeName],
'boundary': '主题化报告用于组织证据、裁决冲突和生成叙事;具体预测仍需本命承诺、Dasha、Transit 与案例验证共同收敛。',
}
def _thematic_workflow_status(self, reading_orchestrator, orchestrator_bridge, report_orchestrator, theme_values):
reading_themes = []
for theme in getattr(reading_orchestrator, 'ReadingTheme'):
reading_themes.append({
'key': theme.value,
'label': theme.name,
})
report_themes = [theme.value for theme in getattr(report_orchestrator, 'ThemeName')]
selected = [theme.value for theme in theme_values]
return {
'stage': 'report_pipeline_bridge',
'reading_theme_count': len(reading_themes),
'reading_themes': reading_themes,
'report_themes': report_themes,
'selected_report_themes': selected,
'bridge': {
'class': getattr(orchestrator_bridge, 'OrchestratorBridge').__name__,
'capabilities': [
'chapter_to_technique_results',
'inject_dasha_results',
'inject_full_reading_modules',
],
},
'boundary': 'reading_orchestrator 的 registry 执行层尚未绑定到全部实时 API;当前产品路径使用 report_orchestrator 生成报告,并显式声明桥接能力。',
}
def _requested_thematic_report_themes(self, body, report_orchestrator):
raw = body.get('themes', body.get('theme', 'all'))
if raw in (None, '', 'all'):
values = [theme.value for theme in report_orchestrator.ThemeName]
elif isinstance(raw, str):
values = [raw]
elif isinstance(raw, list):
values = raw
else:
raise BadRequest('theme/themes must be a string, list, or all')
mapping = {theme.value: theme for theme in report_orchestrator.ThemeName}
themes = []
for value in values:
key = str(value).strip().lower()
if key not in mapping:
raise BadRequest(f'Unknown thematic report theme: {value}')
themes.append(mapping[key])
return themes or list(report_orchestrator.ThemeName)
def _build_thematic_chart_data(self, raw, report_orchestrator):
if not isinstance(raw, dict) or not raw:
return report_orchestrator.MockDataFactory.create_sample_chart()
birth_chart_data = report_orchestrator.BirthChartData
dasha_timeline = self._normalize_thematic_dasha_timeline(raw)
return birth_chart_data(
d1_houses=self._safe_dict(raw.get('d1_houses') or raw.get('houses')),
d9_houses=self._safe_dict(raw.get('d9_houses') or raw.get('navamsa_houses')),
d10_houses=self._safe_dict(raw.get('d10_houses') or raw.get('dashamsa_houses')),
d2_houses=self._safe_dict(raw.get('d2_houses')),
d20_houses=self._safe_dict(raw.get('d20_houses')),
d30_houses=self._safe_dict(raw.get('d30_houses')),
d60_houses=self._safe_dict(raw.get('d60_houses')),
planets=self._safe_dict(raw.get('planets')),
current_dasha=self._current_thematic_dasha_label(raw),
dasha_timeline=dasha_timeline,
yogas=raw.get('yogas') if isinstance(raw.get('yogas'), list) else [],
ashtakavarga=self._safe_dict(raw.get('ashtakavarga')),
)
def _safe_dict(self, value):
return value if isinstance(value, dict) else {}
def _current_thematic_dasha_label(self, raw):
dasha = raw.get('dasha') if isinstance(raw.get('dasha'), dict) else {}
current = dasha.get('current_dasha') if isinstance(dasha.get('current_dasha'), dict) else {}
md = dasha.get('current_md') or current.get('mahadasha') or current.get('maha') or current.get('lord') or raw.get('current_md')
ad = dasha.get('current_ad') or current.get('antardasha') or current.get('antar') or raw.get('current_ad')
if md and ad:
return f'{md}-{ad}'
return str(md or raw.get('current_dasha') or '')
def _normalize_thematic_dasha_timeline(self, raw):
dasha = raw.get('dasha') if isinstance(raw.get('dasha'), dict) else {}
source = raw.get('dasha_timeline') or dasha.get('timeline') or raw.get('periods') or []
if not isinstance(source, list):
return []
timeline = []
for period in source[:24]:
if not isinstance(period, dict):
continue
start_year = self._year_from_any(period.get('start') or period.get('start_date'), datetime.utcnow().year)
end_year = self._year_from_any(period.get('end') or period.get('end_date'), start_year + 1)
maha = self._thematic_period_lord(period.get('mahadasha') or period.get('maha') or period.get('lord') or period.get('name'))
antar = self._thematic_period_lord(period.get('antardasha') or period.get('antar') or period.get('sub_lord'))
timeline.append({
'mahadasha': maha or 'Unknown',
'antardasha': antar or 'Unknown',
'start': start_year,
'end': end_year,
})
return timeline
def _thematic_period_lord(self, value):
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(value, dict):
for key in ('lord', 'planet', 'name', 'mahadasha', 'maha', 'antardasha', 'antar'):
item = value.get(key)
if isinstance(item, str) and item.strip():
return item.strip()
return ''
def _year_from_any(self, value, default):
if isinstance(value, (int, float)):
return int(value)
match = re.search(r'\d{4}', str(value or ''))
return int(match.group(0)) if match else default
def _inject_sample_thematic_evidence(self, orchestrator, report_orchestrator):
factory = report_orchestrator.MockDataFactory()
orchestrator.add_techniques(report_orchestrator.ThemeName.MARRIAGE, factory.create_marriage_techniques())
orchestrator.add_techniques(report_orchestrator.ThemeName.CAREER, factory.create_career_techniques())
orchestrator.add_techniques(report_orchestrator.ThemeName.WEALTH, factory.create_wealth_techniques())
orchestrator.add_techniques(report_orchestrator.ThemeName.HEALTH, factory.create_health_techniques())
orchestrator.add_techniques(report_orchestrator.ThemeName.SPIRITUALITY, factory.create_spirituality_techniques())
def _inject_thematic_evidence(self, orchestrator, evidence, report_orchestrator):
theme_map = {theme.value: theme for theme in report_orchestrator.ThemeName}
strength_map = {level.value: level for level in report_orchestrator.StrengthLevel}
for theme_name, items in evidence.items():
theme = theme_map.get(str(theme_name).strip().lower())
if not theme:
raise BadRequest(f'Unknown evidence theme: {theme_name}')
if not isinstance(items, list):
raise BadRequest('evidence theme values must be arrays')
results = []
for index, item in enumerate(items[:40]):
if not isinstance(item, dict):
raise BadRequest('evidence items must be objects')
strength = strength_map.get(str(item.get('strength', 'moderate')).strip().lower(), report_orchestrator.StrengthLevel.MODERATE)
results.append(report_orchestrator.TechniqueResult(
technique=str(item.get('technique') or f'{theme.value}_evidence_{index + 1}')[:80],
chart=str(item.get('chart') or 'D1')[:24],
conclusion=str(item.get('conclusion') or item.get('summary') or '未提供结论')[:800],
sentiment=str(item.get('sentiment') or 'neutral').strip().lower(),
strength=strength,
details=item.get('details') if isinstance(item.get('details'), dict) else {},
))
orchestrator.add_techniques(theme, results)
def _can_derive_thematic_evidence(self, raw):
if not isinstance(raw, dict) or not raw:
return False
has_birth = all(raw.get(key) is not None for key in ('year', 'month', 'day'))
has_planets = isinstance(raw.get('planets'), dict) and bool(raw.get('planets'))
return has_birth or has_planets
def _derive_thematic_evidence(self, raw, report_orchestrator):
warnings = []
module_status = {}
def collect(key, fn):
try:
value = fn()
module_status[key] = 'ok'
return value
except Exception as exc:
module_status[key] = 'warning'
warnings.append({'module': key, 'error': str(exc)[:240]})
return None
has_birth = all(raw.get(key) is not None for key in ('year', 'month', 'day'))
full_reading = collect('full_reading', lambda: self._compute_full_reading_for_thematic(raw)) if has_birth else None
full_modules = full_reading.get('modules', {}) if isinstance(full_reading, dict) else {}
chart = None
if full_reading:
chart = self._chart_from_full_reading(full_reading)
if not chart and has_birth:
chart = collect('chart', lambda: self._compute_chart(raw))
if not chart:
normalized, _, _ = self._normalized_planets_from_body(raw)
chart = {
'success': True,
'planets': normalized,
'ascendant': raw.get('ascendant', SAMPLE_ASCENDANT),
'houses': raw.get('houses', {}),
'dasha': raw.get('dasha', {}),
'yogas': raw.get('yogas', []),
'source': 'provided_planets',
}
module_status.setdefault('chart', 'provided_planets')
payload = dict(raw)
payload['planets'] = chart.get('planets') or payload.get('planets') or {}
payload['ascendant'] = chart.get('ascendant') or payload.get('ascendant') or SAMPLE_ASCENDANT
if chart.get('dasha') and not isinstance(payload.get('dasha'), str):
payload['dasha'] = chart.get('dasha')
asc_sign = payload.get('asc_sign') or payload['ascendant'].get('sign') or SIGNS[payload['ascendant'].get('sign_idx', 0)]
payload['asc_sign'] = asc_sign if asc_sign in SIGNS else SIGNS[0]
dasha_payload = {**payload, 'dasha': 'vimshottari'}
if chart.get('dasha', {}).get('current_md'):
dasha_payload['current_md'] = chart['dasha']['current_md']
dasha = full_modules.get('dasha') or collect('dasha', lambda: self._compute_dasha_system(dasha_payload))
yogas = full_modules.get('yoga') or collect('yogas', lambda: self._compute_yogas_api(payload))
shadbala = full_modules.get('shadbala') or collect('shadbala', lambda: self._compute_shadbala(payload))
ashtakavarga = full_modules.get('ashtakavarga') or collect('ashtakavarga', lambda: self._compute_ashtakavarga(payload))
relationship = collect('relationship', lambda: self._compute_relationship({
**payload,
'dasha_info': self._thematic_dasha_info(chart, dasha),
}))
career = collect('career', lambda: self._compute_career(payload))
jaimini = full_modules.get('jaimini') or collect('jaimini', lambda: self._compute_jaimini({**payload, 'mode': 'all'}))
enriched = {
**payload,
'houses': chart.get('houses') or raw.get('houses') or {},
'dasha': dasha or chart.get('dasha') or {},
'periods': (dasha or {}).get('periods') or (dasha or {}).get('timeline') or [],
'yogas': chart.get('yogas') or [],
'ashtakavarga': ashtakavarga or {},
}
if yogas and isinstance(yogas.get('result'), dict):
enriched['yogas'] = list(enriched['yogas']) + (yogas['result'].get('extended_yogas') or [])
elif isinstance(yogas, dict):
enriched['yogas'] = list(enriched['yogas']) + (yogas.get('yogas') or [])
evidence = self._build_derived_thematic_evidence(
enriched,
dasha=dasha,
yogas=yogas,
shadbala=shadbala,
ashtakavarga=ashtakavarga,
relationship=relationship,
career=career,
jaimini=jaimini,
full_reading=full_reading,
full_modules=full_modules,
report_orchestrator=report_orchestrator,
)
return {
'chart_data': enriched,
'evidence': evidence,
'module_status': module_status,
'warnings': warnings,
'evidence_counts': {theme: len(items) for theme, items in evidence.items()},
'full_reading_used': bool(full_reading),
'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,
}
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'))
return {
'mode': mode,
'source': 'full_reading_modules' if full_reading_used else 'birth_or_chart_payload',
'sample_fallback': False,
'full_reading_used': full_reading_used,
'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', {}),
'warning_count': len(derived_context.get('warnings', [])),
'warnings': derived_context.get('warnings', [])[:6],
'evidence_counts': derived_context.get('evidence_counts', {}),
}
return {
'mode': mode,
'source': 'caller_evidence' if has_custom_evidence else 'report_orchestrator.MockDataFactory',
'sample_fallback': mode == 'sample_evidence',
}
def _build_derived_thematic_evidence(self, chart_data, **context):
evidence = {
'marriage': [],
'career': [],
'wealth': [],
'health': [],
'spirituality': [],
}
evidence['marriage'].extend(self._derived_marriage_evidence(chart_data, context))
evidence['career'].extend(self._derived_career_evidence(chart_data, context))
evidence['wealth'].extend(self._derived_wealth_evidence(chart_data, context))
evidence['health'].extend(self._derived_health_evidence(chart_data, context))
evidence['spirituality'].extend(self._derived_spirituality_evidence(chart_data, context))
return evidence
def _derived_marriage_evidence(self, chart_data, context):
h7 = self._theme_house_snapshot(chart_data, 7)
relationship = context.get('relationship') or {}
timing = relationship.get('relationship_timing') if isinstance(relationship, dict) else {}
full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {}
items = [
self._theme_evidence(
'D1-7th-house',
'D1',
f"第7宫为{h7['sign']},宫内星体:{h7['planets_label']};用于判断婚姻承诺与伴侣互动基调。",
'neutral' if h7['planets_label'] == '' else 'positive',
'moderate',
source='chart',
details=h7,
),
]
spouse = relationship.get('spouse_status_yoga') if isinstance(relationship, dict) else {}
if spouse:
items.append(self._theme_evidence(
'Spouse-status-yoga',
'D1/D9',
spouse.get('summary') or spouse.get('headline') or '配偶状态 Yoga 已由关系引擎计算,可作为婚姻主题证据。',
'positive' if spouse.get('score', 0) and spouse.get('score', 0) >= 50 else 'neutral',
'moderate',
source='relationship',
details={'fragment': 'spouse_status_yoga.py', 'score': spouse.get('score')},
))
if timing:
items.append(self._theme_evidence(
'DK-UL-Dasha timing',
'Dasha',
timing.get('summary') or 'DK、UL 与 Dasha 触发已进入婚姻时机证据链。',
'positive' if timing.get('level') in {'strong', 'watch'} else 'neutral',
'strong' if timing.get('level') == 'strong' else 'moderate',
source='relationship_timing',
details={'fragment': timing.get('source'), 'clues': timing.get('timing_clues', [])},
))
marriage_counting = full_modules.get('marriage_counting') if isinstance(full_modules, dict) else {}
if isinstance(marriage_counting, dict) and marriage_counting.get('interpretation'):
items.append(self._theme_evidence(
'Marriage-counting',
'D1/D9',
marriage_counting.get('interpretation'),
'positive' if (marriage_counting.get('marriage_count') or 0) <= 1 else 'neutral',
'moderate',
source='full_reading.modules.marriage_counting',
details={
'marriage_count': marriage_counting.get('marriage_count'),
'd9_marriage_quality': marriage_counting.get('d9_marriage_quality'),
},
))
vivah = full_modules.get('vivah_saham') if isinstance(full_modules, dict) else {}
if isinstance(vivah, dict) and vivah.get('vivah_saham'):
items.append(self._theme_evidence(
'Vivah-saham',
'Tajika',
vivah.get('note') or 'Vivah Saham 已由 full-reading 计算,可作为婚姻事件敏感点。',
'neutral',
'moderate',
source='full_reading.modules.vivah_saham',
details=vivah.get('vivah_saham') if isinstance(vivah.get('vivah_saham'), dict) else vivah,
))
return items
def _derived_career_evidence(self, chart_data, context):
h10 = self._theme_house_snapshot(chart_data, 10)
career = context.get('career') or {}
shadbala = context.get('shadbala') or {}
full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {}
top_strength = self._top_shadbala_planet(shadbala)
items = [
self._theme_evidence(
'D1-10th-house',
'D1',
f"第10宫为{h10['sign']},宫内星体:{h10['planets_label']};这是事业角色、名望与外在职责的主轴。",
'positive' if h10['planets'] else 'neutral',
'moderate',
source='chart',
details=h10,
),
]
if career:
items.append(self._theme_evidence(
'Career-engine',
'D1',
self._first_text_from_dict(career, ['summary', 'career_summary', 'dominant_theme', 'recommendation']) or '事业引擎已根据行星与上升星座生成职业倾向。',
'positive',
'moderate',
source='career_analysis.py',
details={'keys': sorted(career.keys())[:10]},
))
if top_strength:
items.append(self._theme_evidence(
'Shadbala-career-support',
'Strength',
f"Shadbala 排名中 {top_strength['planet']} 支持度最高({top_strength['rupas']} rupas),可作为事业执行力/资源侧证。",
'positive',
'moderate',
source='shadbala',
details=top_strength,
))
convergence = full_modules.get('dasa_convergence') if isinstance(full_modules, dict) else {}
top_domains = convergence.get('top_convergent_domains') if isinstance(convergence, dict) else []
career_domain = next(
(
row for row in top_domains
if isinstance(row, dict) and any(token in str(row.get('domain', '')).lower() for token in ('career', 'profession', 'status', 'work'))
),
None,
)
if not career_domain and isinstance(convergence.get('domain_activations'), dict):
for domain, row in convergence['domain_activations'].items():
if any(token in str(domain).lower() for token in ('career', 'profession', 'status', 'work')):
career_domain = {'domain': domain, **(row if isinstance(row, dict) else {})}
break
if career_domain:
items.append(self._theme_evidence(
'Dasa-convergence-career',
'Multi-Dasha',
career_domain.get('interpretation') or '多 Dasha 收敛模块已命中事业相关领域,需结合 Transit 确认兑现窗口。',
'positive',
'strong' if career_domain.get('convergence_level') in {'L2', 'L3'} else 'moderate',
source='full_reading.modules.dasa_convergence',
details=career_domain,
))
return items
def _derived_wealth_evidence(self, chart_data, context):
h2 = self._theme_house_snapshot(chart_data, 2)
h11 = self._theme_house_snapshot(chart_data, 11)
ashtakavarga = context.get('ashtakavarga') or {}
av_summary = self._ashtakavarga_summary_for_theme(ashtakavarga)
full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {}
items = [
self._theme_evidence(
'2nd-and-11th-house',
'D1',
f"第2宫({h2['sign']})与第11宫({h11['sign']})共同描述收入、积累与收益网络。",
'neutral',
'moderate',
source='chart',
details={'second': h2, 'eleventh': h11},
),
]
if av_summary:
strongest = av_summary.get('strongest_houses') or []
wealth_hit = any(row.get('house') in (2, 11) for row in strongest)
items.append(self._theme_evidence(
'Ashtakavarga-wealth',
'AV',
av_summary.get('headline') or 'Ashtakavarga 已生成财富宫位支持度。',
'positive' if wealth_hit else 'neutral',
'strong' if wealth_hit else 'moderate',
source='ashtakavarga',
details={'strongest_houses': strongest, 'sav_total': av_summary.get('sav_total')},
))
yogas_doshas = full_modules.get('yogas_doshas') if isinstance(full_modules, dict) else {}
dhana = yogas_doshas.get('dhana_yogas') if isinstance(yogas_doshas, dict) else {}
dhana_yogas = dhana.get('yogas') if isinstance(dhana, dict) else []
if dhana_yogas:
items.append(self._theme_evidence(
'Dhana-yoga-full-reading',
'D1',
dhana.get('summary') or f"full-reading 检出 {len(dhana_yogas)} 条 Dhana Yoga,可作为财富主题的直接证据。",
'positive',
'strong' if any(row.get('strength') == 'strong' for row in dhana_yogas if isinstance(row, dict)) else 'moderate',
source='full_reading.modules.yogas_doshas',
details={'yogas': dhana_yogas[:4]},
))
return items
def _derived_health_evidence(self, chart_data, context):
h6 = self._theme_house_snapshot(chart_data, 6)
h8 = self._theme_house_snapshot(chart_data, 8)
h12 = self._theme_house_snapshot(chart_data, 12)
yogas = context.get('yogas') or {}
curse = ((yogas.get('result') or {}).get('curse_yogas') or {}) if isinstance(yogas, dict) else {}
risk = curse.get('overall_risk')
full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {}
items = [
self._theme_evidence(
'6-8-12-health-axis',
'D1',
f"健康轴线:6宫{h6['sign']}、8宫{h8['sign']}、12宫{h12['sign']},用于观察疾病、突发与消耗主题。",
'negative' if any(row['planets'] for row in (h6, h8, h12)) else 'neutral',
'moderate',
source='chart',
details={'sixth': h6, 'eighth': h8, 'twelfth': h12},
),
]
if risk:
items.append(self._theme_evidence(
'Curse-yoga-risk',
'D1',
f"凶星合相风险层返回 {risk},作为健康/压力主题的高风险提示证据之一。",
'negative' if risk in {'high', 'medium'} else 'neutral',
'moderate',
source='curse_yoga_detector.py',
details={'risk': risk, 'detected': curse.get('curses_detected', [])[:4]},
))
validation = full_modules.get('validation') if isinstance(full_modules, dict) else {}
if isinstance(validation, dict) and validation.get('checked'):
items.append(self._theme_evidence(
'Calculation-validation-gate',
'Validation',
f"full-reading 完成 {validation.get('checked')} 项计算校验,通过 {validation.get('passed')} 项;健康判断可基于已校验星盘继续审慎解释。",
'positive' if validation.get('valid') else 'negative',
'strong' if validation.get('valid') else 'moderate',
source='full_reading.modules.validation',
details={
'valid': validation.get('valid'),
'passed': validation.get('passed'),
'failed': validation.get('failed'),
},
))
trimshamsa = full_modules.get('trimshamsa_d30') if isinstance(full_modules, dict) else {}
crisis = trimshamsa.get('marriage_crisis') if isinstance(trimshamsa, dict) else None
if crisis:
items.append(self._theme_evidence(
'Trimshamsa-D30-risk',
'D30',
'D30 Trimshamsa 已进入压力/危机侧证;健康主题需把风险提示与现实医疗信息分开处理。',
'negative' if isinstance(crisis, dict) and crisis.get('risk_level') in {'high', 'medium'} else 'neutral',
'moderate',
source='full_reading.modules.trimshamsa_d30',
details=crisis if isinstance(crisis, dict) else {'crisis': crisis},
))
return items
def _derived_spirituality_evidence(self, chart_data, context):
h9 = self._theme_house_snapshot(chart_data, 9)
h12 = self._theme_house_snapshot(chart_data, 12)
planets = chart_data.get('planets') if isinstance(chart_data.get('planets'), dict) else {}
jupiter = planets.get('Jupiter', {}) if isinstance(planets.get('Jupiter'), dict) else {}
ketu = planets.get('Ketu', {}) if isinstance(planets.get('Ketu'), dict) else {}
jaimini = context.get('jaimini') or {}
karakamsha = self._extract_jaimini_karakamsha(jaimini)
full_modules = context.get('full_modules') if isinstance(context.get('full_modules'), dict) else {}
items = [
self._theme_evidence(
'9th-and-12th-spirit',
'D1',
f"第9宫({h9['sign']})与第12宫({h12['sign']})显示信念、导师、修行与出离倾向。",
'positive' if h9['planets'] or h12['planets'] else 'neutral',
'moderate',
source='chart',
details={'ninth': h9, 'twelfth': h12},
),
self._theme_evidence(
'Jupiter-Ketu-spiritual-karaka',
'D1',
f"Jupiter 位于{jupiter.get('sign', '未知')}{jupiter.get('house', '-')}宫,Ketu 位于{ketu.get('sign', '未知')}{ketu.get('house', '-')}宫。",
'positive' if jupiter.get('house') in (1, 5, 9, 12) or ketu.get('house') in (9, 12) else 'neutral',
'moderate',
source='chart',
details={'Jupiter': jupiter, 'Ketu': ketu},
),
]
if karakamsha:
items.append(self._theme_evidence(
'Karakamsha',
'D9/D1',
karakamsha.get('interpretation') or karakamsha.get('description') or 'Karakamsha 已由 Jaimini 模块计算,用于灵性使命与内在驱动力。',
'positive',
'moderate',
source='jaimini.py',
details=karakamsha,
))
d20 = self._extract_vimsamsa_spiritual_context(full_modules)
if d20:
items.append(self._theme_evidence(
'Vimsamsa-D20-spiritual-context',
'D20',
d20.get('summary') or 'D20 Vimsamsa 分盘已进入灵性主题证据链,用于观察修行、信念与内在追求。',
'positive',
'moderate',
source=d20.get('source', 'full_reading.modules.varga_full'),
details=d20,
))
return items
def _theme_evidence(self, technique, chart, conclusion, sentiment, strength, *, source, details=None):
return {
'technique': technique,
'chart': chart,
'conclusion': str(conclusion)[:800],
'sentiment': sentiment,
'strength': strength,
'details': {
**(details if isinstance(details, dict) else {}),
'source': source,
'derived': True,
},
}
def _theme_house_snapshot(self, chart_data, house_num):
houses = chart_data.get('houses') if isinstance(chart_data.get('houses'), dict) else {}
house = houses.get(house_num) or houses.get(str(house_num)) or {}
asc = chart_data.get('ascendant') if isinstance(chart_data.get('ascendant'), dict) else {}
if house.get('sign'):
sign = house.get('sign')
else:
asc_idx = self._sign_idx_from_value(asc.get('sign_idx', asc.get('sign')), 0)
sign = SIGNS[(asc_idx + house_num - 1) % 12]
planets = []
for planet, pdata in (chart_data.get('planets') or {}).items():
if isinstance(pdata, dict) and pdata.get('house') == house_num:
planets.append(planet)
return {
'house': house_num,
'sign': sign,
'planets': planets,
'planets_label': ''.join(planets) if planets else '',
}
def _top_shadbala_planet(self, shadbala):
if not isinstance(shadbala, dict):
return None
result = shadbala.get('result') if isinstance(shadbala.get('result'), dict) else shadbala
planets = result.get('planets') if isinstance(result, dict) else {}
rows = []
for planet, data in (planets or {}).items():
if not isinstance(data, dict):
continue
rupas = data.get('total_rupas', data.get('rupas'))
if isinstance(rupas, (int, float)):
rows.append({'planet': planet, 'rupas': round(rupas, 2)})
rows.sort(key=lambda row: row['rupas'], reverse=True)
return rows[0] if rows else None
def _ashtakavarga_summary_for_theme(self, ashtakavarga):
if not isinstance(ashtakavarga, dict):
return {}
summary = ashtakavarga.get('summary')
if isinstance(summary, dict) and summary:
return summary
sav = ashtakavarga.get('sav') if isinstance(ashtakavarga.get('sav'), dict) else {}
scores = sav.get('scores') if isinstance(sav.get('scores'), dict) else {}
if not scores:
return {}
rows = []
for sign, score in scores.items():
if not isinstance(score, (int, float)):
continue
sign_idx = SIGNS.index(sign) if sign in SIGNS else None
house = sign_idx + 1 if sign_idx is not None else None
rows.append({'sign': sign, 'house': house, 'score': score})
rows.sort(key=lambda row: row['score'], reverse=True)
total = sum(row['score'] for row in rows)
return {
'headline': 'Ashtakavarga SAV 已由 full-reading 计算,财富宫位以第2/11宫支持度为重点。',
'strongest_houses': rows[:4],
'sav_total': total,
'source': 'full_reading.modules.ashtakavarga',
}
def _extract_jaimini_karakamsha(self, jaimini):
if not isinstance(jaimini, dict):
return {}
result = jaimini.get('result') if isinstance(jaimini.get('result'), dict) else jaimini
karakamsha = result.get('karakamsha') if isinstance(result, dict) else {}
return karakamsha if isinstance(karakamsha, dict) else {}
def _extract_vimsamsa_spiritual_context(self, full_modules):
if not isinstance(full_modules, dict):
return {}
varga = full_modules.get('varga_full')
if not isinstance(varga, dict):
return {}
d20 = varga.get('D20_Vimsamsa') or varga.get('D20') or varga.get('Vimsamsa')
if not isinstance(d20, dict):
return {}
placements = []
for planet in ('Jupiter', 'Ketu', 'Moon', 'Sun', 'Ascendant'):
pdata = d20.get(planet)
if isinstance(pdata, dict):
placements.append({
'planet': planet,
'sign': pdata.get('sign'),
'house': pdata.get('house') or pdata.get('house_in_d20'),
'dignity': pdata.get('dignity'),
})
if not placements:
return {}
return {
'summary': 'D20 Vimsamsa 已从 full-reading 分盘层读取,Jupiter/Ketu/Moon/Sun/Ascendant 作为灵性主题侧证。',
'placements': placements,
'source': 'full_reading.modules.varga_full',
}
def _first_text_from_dict(self, value, keys):
if not isinstance(value, dict):
return ''
for key in keys:
item = value.get(key)
if isinstance(item, str) and item.strip():
return item.strip()
if isinstance(item, dict):
nested = self._first_text_from_dict(item, keys)
if nested:
return nested
return ''
def _thematic_dasha_info(self, chart, dasha):
current = {}
if isinstance(chart.get('dasha'), dict):
current.update(chart['dasha'])
analysis = dasha.get('vimshottari_analysis') if isinstance(dasha, dict) else None
if isinstance(analysis, dict):
md = ((analysis.get('current') or {}).get('mahadasha') or {}).get('lord')
ad = ((analysis.get('current') or {}).get('antardasha') or {}).get('lord')
if md:
current['maha_dasha'] = md
if ad:
current['antar_dasha'] = ad
return current
def _parse_birth_datetime(self, body):
year = self._get_int(body, 'year', 1990, 1800, 2400)
month = self._get_int(body, 'month', 6, 1, 12)
day = self._get_int(body, 'day', 15, 1, 31)
hour = self._get_float(body, 'hour', 12, 0, 23)
minute = self._get_float(body, 'minute', 0, 0, 59)
try:
return datetime(year, month, day, int(hour), int(minute))
except ValueError as e:
raise BadRequest('Invalid birth date') from e
def _compute_full_reading_for_thematic(self, body):
engine = _load_local_module('jyotish_engine')
year = self._get_int(body, 'year', 1990, 1800, 2400)
month = self._get_int(body, 'month', 6, 1, 12)
day = self._get_int(body, 'day', 15, 1, 31)
hour = self._get_float(body, 'hour', 12, 0, 23)
minute = self._get_float(body, 'minute', 0, 0, 59)
lat = self._get_float(body, 'lat', 39.9, -90, 90)
lon = self._get_float(body, 'lon', 116.4, -180, 180)
tz = self._get_float(body, 'tz', 8, -14, 14)
try:
datetime(year, month, day, int(hour), int(minute))
except ValueError as e:
raise BadRequest('Invalid birth date') from e
node_mode = body.get('node_mode', body.get('nodeMode', 'mean'))
if not isinstance(node_mode, str) or node_mode not in {'mean', 'true'}:
node_mode = 'mean'
args = type('Args', (), {
'year': year,
'month': month,
'day': day,
'hour': int(hour),
'minute': int(minute),
'lat': lat,
'lon': lon,
'tz': tz,
'node_mode': node_mode,
'age': body.get('age'),
'today': body.get('today') or body.get('current_date'),
'transit_date': body.get('transit_date'),
'target_year': body.get('target_year'),
})()
result = engine.cmd_full_reading(args)
if not isinstance(result, dict) or not isinstance(result.get('modules'), dict):
raise BadRequest('full-reading did not return modules')
return result
def _chart_from_full_reading(self, full_reading):
if not isinstance(full_reading, dict):
return None
modules = full_reading.get('modules') if isinstance(full_reading.get('modules'), dict) else {}
chart = full_reading.get('chart') if isinstance(full_reading.get('chart'), dict) else modules.get('chart')
if not isinstance(chart, dict) or not isinstance(chart.get('planets'), dict):
return None
normalized = dict(chart)
normalized['success'] = True
normalized['planets'] = chart.get('planets') or {}
normalized['ascendant'] = chart.get('ascendant') or SAMPLE_ASCENDANT
normalized['houses'] = chart.get('houses') or modules.get('house_map') or {}
normalized['dasha'] = modules.get('dasha') or chart.get('dasha') or {}
yoga_module = modules.get('yoga') if isinstance(modules.get('yoga'), dict) else {}
normalized['yogas'] = (
chart.get('yogas')
or yoga_module.get('yogas')
or yoga_module.get('detected_yogas')
or []
)
normalized['ashtakavarga'] = modules.get('ashtakavarga') or chart.get('ashtakavarga') or {}
normalized['shadbala'] = modules.get('shadbala') or chart.get('shadbala') or {}
normalized['birth'] = chart.get('birth_info') or full_reading.get('birth_info') or {}
normalized['source'] = 'full_reading.modules.chart'
return normalized
def _sign_idx_from_value(self, value, default=0):
if isinstance(value, str):
if value not in SIGNS:
raise BadRequest('sign must be a valid sign')
return SIGNS.index(value)
try:
idx = int(value)
except (TypeError, ValueError):
return default
return max(0, min(11, idx))
def _planet_lon(self, planets, planet):
data = planets.get(planet, {}) if isinstance(planets, dict) else {}
if not isinstance(data, dict):
return None
lon = data.get('lon')
if isinstance(lon, (int, float)) and math.isfinite(lon):
return lon % 360
sign_idx = data.get('sign_idx')
if sign_idx is None and data.get('sign') in SIGNS:
sign_idx = SIGNS.index(data['sign'])
degree = data.get('degree', data.get('degree_in_sign', 0))
try:
return (int(sign_idx) * 30 + float(degree)) % 360
except (TypeError, ValueError):
return None
def _fallback_dasha_periods(self, birth_dt, dasha_key, info):
extended_dashas = _load_local_module('extended_dashas')
DASHA_ORDER = extended_dashas.DASHA_ORDER
cycle_years = float(info.get('years') or 36)
if cycle_years <= 0:
cycle_years = 36
period_years = round(cycle_years / len(DASHA_ORDER), 2)
periods = []
current = birth_dt
for lord in DASHA_ORDER:
end_date = current + timedelta(days=period_years * 365.25636)
periods.append({
'lord': lord,
'years': period_years,
'start': current.strftime('%Y-%m-%d'),
'end': end_date.strftime('%Y-%m-%d'),
})
current = end_date
return periods
def _import_chart_text(self, body):
filename = str(body.get('filename', ''))[:160]
text = body.get('text')
content_b64 = body.get('content_base64')
if text is not None:
if not isinstance(text, str):
raise BadRequest('text must be a string')
return self._import_text_response(text, filename, 'text')
if content_b64 is None:
raise BadRequest('text or content_base64 is required')
if not isinstance(content_b64, str):
raise BadRequest('content_base64 must be a string')
try:
data = base64.b64decode(content_b64, validate=True)
except Exception as e:
raise BadRequest('Invalid base64 content') from e
if len(data) > MAX_IMPORT_FILE_BYTES:
raise BadRequest(f'Import file too large; max {MAX_IMPORT_FILE_BYTES} bytes')
is_pdf = filename.lower().endswith('.pdf') or data.startswith(b'%PDF')
if is_pdf:
text, extractor = self._extract_pdf_text(data)
else:
text = data.decode('utf-8', errors='ignore')
extractor = 'text'
return self._import_text_response(text, filename, extractor)
def _import_text_response(self, text, filename, extractor):
normalized = str(text or '').replace('\x00', '').strip()
if not normalized:
raise BadRequest('No extractable text found')
truncated = len(normalized) > MAX_IMPORT_TEXT_CHARS
if truncated:
normalized = normalized[:MAX_IMPORT_TEXT_CHARS]
return {
'success': True,
'filename': filename,
'extractor': extractor,
'text': normalized,
'text_length': len(normalized),
'truncated': truncated,
}
def _extract_pdf_text(self, data):
errors = []
try:
import pdfplumber
with pdfplumber.open(io.BytesIO(data)) as pdf:
text = '\n'.join((page.extract_text() or '') for page in pdf.pages[:12])
if text.strip():
return text, 'pdfplumber'
except Exception as e:
errors.append(str(e))
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(data))
text = '\n'.join((page.extract_text() or '') for page in reader.pages[:12])
if text.strip():
return text, 'pypdf'
except Exception as e:
errors.append(str(e))
raise BadRequest('PDF has no extractable text; OCR is not supported yet')
def _calc_vimshottari_periods(self, birth_dt, moon_lon):
extended_dashas = _load_local_module('extended_dashas')
DASHA_ORDER = extended_dashas.DASHA_ORDER
YEAR_DAYS = extended_dashas.YEAR_DAYS
dasha_years = [7, 20, 6, 10, 7, 18, 16, 19, 17]
nak_size = 360 / 27
nak_idx = int(moon_lon / nak_size) % 27
start_idx = nak_idx % len(DASHA_ORDER)
current = birth_dt
periods = []
for i in range(len(DASHA_ORDER)):
idx = (start_idx + i) % len(DASHA_ORDER)
years = dasha_years[idx]
end_date = current + timedelta(days=years * YEAR_DAYS)
periods.append({
'lord': DASHA_ORDER[idx],
'years': years,
'start': current.strftime('%Y-%m-%d'),
'end': end_date.strftime('%Y-%m-%d'),
})
current = end_date
return periods
def _compute_chart(self, body):
"""完整星盘计算"""
year = self._get_int(body, 'year', 1990, 1800, 2400)
month = self._get_int(body, 'month', 6, 1, 12)
day = self._get_int(body, 'day', 15, 1, 31)
hour = self._get_float(body, 'hour', 12, 0, 23)
minute = self._get_float(body, 'minute', 0, 0, 59)
lat = self._get_float(body, 'lat', 39.9, -90, 90)
lon = self._get_float(body, 'lon', 116.4, -180, 180)
tz = self._get_float(body, 'tz', 8, -14, 14)
try:
datetime(year, month, day)
except ValueError as e:
raise BadRequest('Invalid birth date') from e
try:
import swisseph as swe
swe.set_ephe_path(os.path.join(SCRIPTS_DIR, '..', 'swiss_ephemeris'))
hour_ut = hour + minute / 60.0 - tz
jd = swe.julday(year, month, day, hour_ut)
swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0)
ayanamsa = swe.get_ayanamsa(jd)
planets_data = {}
planet_ids = {'Sun': 0, 'Moon': 1, 'Mars': 4, 'Mercury': 2, 'Jupiter': 5, 'Venus': 3, 'Saturn': 6, 'Rahu': 10, 'Ketu': 20}
planet_names_rev = {v: k for k, v in planet_ids.items()}
for pid, pname in planet_names_rev.items():
if pid == 20:
rahu_result, _ = swe.calc_ut(jd, 10)
planet_lon = (rahu_result[0] - ayanamsa + 180) % 360
else:
result, _ = swe.calc_ut(jd, pid)
planet_lon = (result[0] - ayanamsa) % 360
sign_idx = int(planet_lon / 30) % 12
planets_data[pname] = {'lon': planet_lon, 'sign_idx': sign_idx, 'sign': SIGNS[sign_idx], 'degree': planet_lon % 30}
# Ascendant
asc_tropical = swe.houses_ex(jd, lat, lon, b'E')[0][0] % 360
asc_lon = (asc_tropical - ayanamsa) % 360
asc_sign_idx = int(asc_lon / 30) % 12
asc_sign = SIGNS[asc_sign_idx]
# Houses
houses = {}
for h in range(1, 13):
s = (asc_sign_idx + h - 1) % 12
houses[h] = {'sign': SIGNS[s], 'sign_idx': s}
# Planet houses
for pn, pd in planets_data.items():
pd['house'] = ((pd['sign_idx'] - asc_sign_idx) % 12) + 1
# Dasha (simplified Vimshottari)
moon_lon = planets_data['Moon']['lon']
nak_size = 360/27
nak_idx = int(moon_lon / nak_size)
dasha_lords = ['Ketu','Venus','Sun','Moon','Mars','Rahu','Jupiter','Saturn','Mercury']
dasha_years = [7,20,6,10,7,18,16,19,17]
nak_lord_idx = nak_idx % 9
md_lord = dasha_lords[nak_lord_idx]
total_years = dasha_years[nak_lord_idx]
elapsed = (moon_lon % nak_size) / nak_size * total_years
remaining = total_years - elapsed
birth_dt = datetime(year, month, day, int(hour), int(minute))
elapsed_days = elapsed * 365.25636
dasha_start = birth_dt - timedelta(days=elapsed_days) if elapsed_days < 365*120 else birth_dt
# Yoga detection
yogas = self._detect_yogas(planets_data, asc_sign_idx)
# Sade Sati
from sade_sati import calc_sade_sati_complete
# Transit Saturn (approximate)
saturn_year_progress = (year - 2026) * 12 / 30 # ~12 signs in 30 years
transit_saturn_sign = (planets_data['Saturn']['sign_idx'] + int(saturn_year_progress)) % 12
transit_saturn_lon = transit_saturn_sign * 30 + 15
sade_sati = calc_sade_sati_complete(moon_lon, asc_lon, transit_saturn_lon)
# Dasha清单
extended_dashas = _load_local_module('extended_dashas')
dashas = extended_dashas.get_available_dashas()
dasha_list = [{'key': k, 'name': extended_dashas.DASHA_REGISTRY[k]['name'], 'years': extended_dashas.DASHA_REGISTRY[k]['years'], 'type': extended_dashas.DASHA_REGISTRY[k]['type']} for k in dashas]
try:
jaimini = _load_local_module('jaimini')
special_lagnas = jaimini.calc_special_lagnas_precise(
asc_sign_idx, year, month, day, int(hour), int(minute), lat, lon, tz
)
except Exception as e:
import logging
logging.warning(f"[api_server] special lagnas calculation failed: {e}")
special_lagnas = {}
# Shadbala (v6.9.14: covered with external absolute-calibration cap)
try:
from shadbala import calc_shadbala
sb = calc_shadbala(planets_data, asc_sign, hour+minute/60.0,
planets_data.get('Sun',{}).get('lon',0), moon_lon, minute)
shadbala_summary = {p: {'rupas': round(d['total_rupas'],2), 'level': d['strength_level']}
for p,d in sb.get('planets',{}).items()}
except Exception as e:
import logging
logging.warning(f"[api_server] shadbala calculation failed: {e}")
shadbala_summary = {}
try:
remedies_module = _load_local_module('remedies')
remedies = remedies_module.recommend_remedies(shadbala_summary, active_dasha_lord=md_lord)
except Exception as e:
import logging
logging.warning(f"[api_server] remedies calculation failed: {e}")
remedies = {}
try:
tithi_analyzer = _load_local_module('tithi_analyzer')
tithi_lord_analysis = tithi_analyzer.analyze_tithi({'planets': planets_data})
except Exception as e:
import logging
logging.warning(f"[api_server] tithi lord analysis failed: {e}")
tithi_lord_analysis = {}
# Yoga扩展 (dashaflow MIT规则)
try:
from yoga_expansion import detect_all_yogas as detect_ey
for ey in detect_ey(planets_data, asc_sign):
yogas.append({'name': ey.get('name',''), 'planets': ey.get('planets',[]),
'desc': ey.get('description','')[:80], 'cat': 'extended'})
except Exception as e:
import logging
logging.warning(f"[api_server] yoga expansion detection failed: {e}")
return {
'success': True, 'version': '6.9.14',
'birth': {
'date': f'{year}-{month:02d}-{day:02d}',
'time': f'{int(hour):02d}:{int(minute):02d}',
'tz': f"UTC{'+' if tz >= 0 else ''}{tz}",
'lat': lat,
'lon': lon,
'julian_day': round(jd, 6),
'ayanamsa': round(ayanamsa, 4),
},
'ascendant': {
'sign': asc_sign,
'sign_idx': asc_sign_idx,
'degree': round(asc_lon % 30, 2),
'degree_in_sign': round(asc_lon % 30, 2),
'lon': round(asc_lon, 4),
},
'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary,
'dasha': {
'current_md': md_lord,
'remaining_years': round(remaining, 2),
'total_years': total_years,
'start_date': dasha_start.isoformat() if hasattr(dasha_start, 'isoformat') else str(dasha_start),
},
'yogas': yogas,
'sade_sati': sade_sati,
'remedies': remedies,
'tithi_lord_analysis': tithi_lord_analysis,
'special_lagnas': special_lagnas,
'available_dashas': dasha_list,
'dasha_count': len(dasha_list),
}
except ImportError:
return self._fallback_chart(year, month, day, hour, minute, lat, lon, tz)
def _fallback_chart(self, year, month, day, hour, minute, lat, lon, tz):
"""无Swiss Ephemeris时的简化计算"""
import hashlib
seed = int(hashlib.md5(f"{year}{month}{day}{hour}{minute}{lat}{lon}".encode()).hexdigest()[:8], 16)
asc_sign_idx = seed % 12
asc_sign = SIGNS[asc_sign_idx]
planets = {}
planet_names = ['Sun','Moon','Mars','Mercury','Jupiter','Venus','Saturn','Rahu','Ketu']
import random
rng = random.Random(seed)
for pn in planet_names:
sign_idx = (asc_sign_idx + rng.randint(0, 11)) % 12
deg = rng.uniform(0, 30)
planets[pn] = {
'sign': SIGNS[sign_idx], 'sign_idx': sign_idx,
'degree': deg, 'lon': sign_idx * 30 + deg,
'house': ((sign_idx - asc_sign_idx) % 12) + 1,
}
houses = {}
for h in range(1, 13):
s = (asc_sign_idx + h - 1) % 12
houses[h] = {'sign': SIGNS[s], 'sign_idx': s}
try:
jaimini = _load_local_module('jaimini')
special_lagnas = jaimini.calc_special_lagnas_precise(
asc_sign_idx, year, month, day, int(hour), int(minute), lat, lon, tz
)
except Exception:
jaimini = _load_local_module('jaimini')
special_lagnas = jaimini.calc_special_lagnas(asc_sign_idx, int(hour), int(minute))
return {
'success': True, 'version': '6.9.14-fallback',
'warning': 'Swiss Ephemeris未安装,使用简化计算',
'birth': {
'date': f'{year}-{month:02d}-{day:02d}',
'time': f'{int(hour):02d}:{int(minute):02d}',
'tz': f"UTC{'+' if tz >= 0 else ''}{tz}",
'lat': lat,
'lon': lon,
},
'ascendant': {'sign': asc_sign, 'sign_idx': asc_sign_idx},
'planets': planets, 'houses': houses,
'dasha': {'current_md': 'Moon', 'remaining_years': 5},
'yogas': [], 'sade_sati': {'active': False},
'tithi_lord_analysis': _load_local_module('tithi_analyzer').analyze_tithi({'planets': planets}),
'special_lagnas': special_lagnas,
'available_dashas': [], 'dasha_count': 0,
}
def _detect_yogas(self, planets, asc_idx):
yogas = []
KENDRA = {1,4,7,10}
try:
from pancha_mahapurusha import detect_pancha_mahapurusha
pmc = detect_pancha_mahapurusha(planets)
for y in pmc:
if y['is_valid']:
yogas.append({'name': y['name'], 'planets': [y['planet']], 'category': 'PMC'})
except Exception as e:
import logging
logging.warning(f"[api_server] pancha_mahapurusha detection failed: {e}")
try:
from yoga_expansion import detect_all_yogas as detect_yogas_ext
for y in detect_yogas_ext(planets, SIGNS[asc_idx]):
yogas.append({'name': y.get('name',''), 'planets': y.get('planets',[]), 'category': 'extended'})
except Exception as e:
import logging
logging.warning(f"[api_server] yoga expansion in _detect_yogas failed: {e}")
return yogas[:10]
def _compute_remedies(self, body):
remedies_module = _load_local_module('remedies')
shadbala = body.get('shadbala', {})
doshas = body.get('doshas', [])
dasha_lord = body.get('dasha_lord', '')
if not isinstance(shadbala, dict):
raise BadRequest('shadbala must be an object')
if not isinstance(doshas, list):
raise BadRequest('doshas must be an array')
if not isinstance(dasha_lord, str):
raise BadRequest('dasha_lord must be a string')
return remedies_module.recommend_remedies(shadbala, doshas=doshas, active_dasha_lord=dasha_lord)
def _compute_kp(self, body):
planets = self._validate_planets(body.get('planets', {}))
asc_idx = self._get_int(body, 'asc_sign_idx', 0, 0, 11)
from kp_system import calc_kp_analysis
return calc_kp_analysis(planets, SIGNS[asc_idx])
def _compute_prashna(self, body):
question_type = body.get('question', 'general')
if not isinstance(question_type, str):
raise BadRequest('question must be a string')
question_text = body.get('question_text', '')
if not isinstance(question_text, str):
raise BadRequest('question_text must be a string')
from prashna import (
QUESTION_CATEGORIES,
analyze_lost_item,
build_kp_horary_evidence,
calc_prashna_chart,
calc_life_sphutas,
calc_sahams,
calc_sphutas,
detect_prashna_arudha,
get_kp_prashna_answer,
get_kp_prashna_answer_v2,
kunda_verify,
nadi_prashna_analysis,
prashna_timing_score,
)
from datetime import datetime
planets = self._validate_planets(body.get('planets', {}))
asc_degree = self._normalize_degree(body, 'asc_degree', 15.5)
horary_number = body.get('horary_number')
if horary_number in ('', None):
horary_number = None
else:
horary_number = self._get_int(body, 'horary_number', None, 1, 249)
question_key = question_type[:80]
chart = calc_prashna_chart(datetime.now(), planets, asc_degree)
answer = get_kp_prashna_answer(planets, question_key, asc_degree)
answer_v2 = get_kp_prashna_answer_v2(planets, question_key, asc_degree)
kp_horary = build_kp_horary_evidence(planets, question_key, asc_degree, horary_number)
question_house = QUESTION_CATEGORIES.get(question_key, QUESTION_CATEGORIES['general'])['primary']
arudha = detect_prashna_arudha(planets, asc_degree, question_house)
nadi = nadi_prashna_analysis(planets, asc_degree, question_key)
timing = prashna_timing_score(planets, asc_degree, question_key)
planet_lons = {}
for pname in ('Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu'):
p_lon = self._planet_lon(planets, pname)
if p_lon is not None:
planet_lons[pname] = p_lon
sphutas = calc_sphutas(planet_lons, asc_degree)
life_sphutas = calc_life_sphutas(
asc_degree,
planet_lons.get('Moon', 0),
planet_lons.get('Sun', 0),
sphutas.get('gulika', {}).get('longitude', 0),
)
sahams = calc_sahams(planet_lons, asc_degree)
lost_item = analyze_lost_item(planet_lons, asc_degree)
kunda = kunda_verify(asc_degree)
conclusion = answer_v2.get('kp_answer') or answer.get('kp_answer')
return {
'success': True,
'question_text': question_text[:160],
'question_category': question_key,
'prashna_chart': chart,
'kp_answer': answer,
'kp_answer_v2': answer_v2,
'kp_horary': kp_horary,
'arudha': arudha,
'nadi': nadi,
'timing': timing,
'sphutas': sphutas,
'life_sphutas': life_sphutas,
'sahams': sahams,
'lost_item': lost_item,
'kunda': kunda,
'summary': {
'conclusion': conclusion,
'confidence': answer_v2.get('confidence', answer.get('confidence')),
'primary_house': question_house,
'question_lord': answer_v2.get('question_lord', answer.get('question_lord')),
'next_action': timing.get('recommendation', '结合现实信息复核后再行动'),
},
}
def _compute_synastry(self, body):
from synastry import calc_ashtakoot
return calc_ashtakoot(
self._normalize_degree(body, 'male_moon', 0),
self._normalize_degree(body, 'female_moon', 0),
)
def _compute_dasha_system(self, body):
dasha_key = body.get('dasha', body.get('name', 'vimshottari'))
if not isinstance(dasha_key, str):
raise BadRequest('dasha must be a string')
dasha_key = dasha_key.strip().lower()
extended_dashas = _load_local_module('extended_dashas')
info = extended_dashas.DASHA_REGISTRY.get(dasha_key)
if not info:
raise BadRequest('Unknown dasha system')
birth_dt = self._parse_birth_datetime(body)
planets = self._validate_planets(body.get('planets', {}))
ascendant = body.get('ascendant', {})
if ascendant is not None and not isinstance(ascendant, dict):
raise BadRequest('ascendant must be an object')
moon_lon = self._planet_lon(planets, 'Moon')
if moon_lon is None:
moon_lon = self._normalize_degree(body, 'moon_lon', 0)
sun_lon = self._planet_lon(planets, 'Sun')
if sun_lon is None:
sun_lon = self._normalize_degree(body, 'sun_lon', 0)
nak_size = 360 / 27
moon_nak_idx = int(moon_lon / nak_size) % 27
moon_pada = int((moon_lon % nak_size) / (nak_size / 4)) + 1
moon_sign_idx = int(moon_lon / 30) % 12
sun_sign_idx = int(sun_lon / 30) % 12
asc_sign_idx = self._sign_idx_from_value(
body.get('asc_sign_idx', ascendant.get('sign_idx', ascendant.get('sign'))),
0,
)
d9_asc_sign_idx = self._sign_idx_from_value(body.get('d9_asc_sign_idx', asc_sign_idx), asc_sign_idx)
tithi_num = self._get_int(body, 'tithi_num', 1, 1, 30)
vimshottari_analysis = None
if dasha_key == 'vimshottari':
periods = self._calc_vimshottari_periods(birth_dt, moon_lon)
precision = 'calculator'
vimshottari_analysis = self._compute_vimshottari_analysis_layer(
birth_dt,
moon_lon,
body.get('today') or body.get('current_date'),
)
else:
periods = extended_dashas.calc_any_dasha(
dasha_key,
birth_dt,
moon_nak_idx=moon_nak_idx,
moon_pada=moon_pada,
asc_sign_idx=asc_sign_idx,
moon_sign_idx=moon_sign_idx,
sun_sign_idx=sun_sign_idx,
d9_asc_sign_idx=d9_asc_sign_idx,
tithi_num=tithi_num,
)
calc_fn = extended_dashas.DASHA_CALCULATORS.get(dasha_key)
precision = 'generic' if not calc_fn or calc_fn.__name__ == 'calc_generic_dasha' else 'calculator'
if not periods:
periods = self._fallback_dasha_periods(birth_dt, dasha_key, info)
precision = 'generic'
result = {
'success': True,
'key': dasha_key,
'name': info.get('name', dasha_key),
'type': info.get('type', 'other'),
'cycle_years': info.get('years'),
'precision': precision,
'periods': periods,
}
if vimshottari_analysis:
result['vimshottari_analysis'] = vimshottari_analysis
result['fragment_sources'] = ['dasha_analyzer.py', 'dasha_calculator_enhanced.py']
return result
def _compute_vimshottari_analysis_layer(self, birth_dt, moon_lon, current_date=None):
try:
analyzer = _load_local_module('dasha_analyzer')
enhanced = _load_local_module('dasha_calculator_enhanced')
nak_info, nak_progress, pada = analyzer.lon_to_nakshatra(moon_lon % 360)
timeline, elapsed, remaining, start_lord = analyzer.build_dasha_timeline(
birth_dt.strftime('%Y-%m-%d'),
nak_info,
nak_progress,
)
today = self._parse_optional_date(current_date) if current_date else datetime.now()
current_idx, current_md = analyzer.find_current(timeline, today)
antardashas = analyzer.build_antardasha(current_md)
current_ad = analyzer.find_current_sub(antardashas, today)
years_into_md = max(0.0, (today - current_md['start']).days / 365.25636)
five_levels = enhanced.calculate_five_level_dasha(current_md['lord'], years_into_md)
formatted_timeline = [self._format_datetime_period(period) for period in timeline]
formatted_antardashas = [self._format_datetime_period(period) for period in antardashas]
current_period = self._format_datetime_period(current_md)
current_sub = self._format_datetime_period(current_ad)
remaining_days = max(0, (current_ad['end'] - today).days)
theme = enhanced.PLANET_MODERN_MEANINGS.get(current_md['lord'], {})
return {
'source': 'dasha_analyzer.py + dasha_calculator_enhanced.py',
'nakshatra': {
'name': nak_info[0],
'lord': nak_info[1],
'years': nak_info[2],
'pada': pada,
'progress_pct': round(nak_progress * 100, 2),
'elapsed_years_at_birth': elapsed,
'remaining_years_at_birth': remaining,
},
'current': {
'mahadasha': current_period,
'antardasha': current_sub,
'remaining_days': remaining_days,
'remaining_months': round(remaining_days / 30.44, 1),
'theme': theme.get('theme', ''),
'keywords': theme.get('keywords', []),
},
'five_levels': five_levels,
'timeline_from_true_md_start': formatted_timeline,
'current_antardashas': formatted_antardashas,
'summary': {
'headline': f"当前处于 {current_md['lord']} Mahadasha / {current_ad['lord']} Antardasha",
'note': '该增强层复用 dasha_analyzer.py,主 periods 合同仍保持出生后周期列表。',
'next_action': '用当前 MD/AD 作为时间主轴,再用本命承诺、Transit 和案例验证收敛事件。',
},
'current_index': current_idx,
}
except Exception as exc:
import logging
logging.warning(f"[api_server] vimshottari analysis layer failed: {exc}")
return None
def _parse_optional_date(self, value):
if not isinstance(value, str) or not value.strip():
raise BadRequest('current_date must be YYYY-MM-DD')
try:
return datetime.strptime(value.strip()[:10], '%Y-%m-%d')
except ValueError as e:
raise BadRequest('current_date must be YYYY-MM-DD') from e
def _format_datetime_period(self, period):
return {
'lord': period.get('lord'),
'years': round(period.get('years', 0), 4) if isinstance(period.get('years'), (int, float)) else period.get('years'),
'start': period.get('start').strftime('%Y-%m-%d') if hasattr(period.get('start'), 'strftime') else period.get('start'),
'end': period.get('end').strftime('%Y-%m-%d') if hasattr(period.get('end'), 'strftime') else period.get('end'),
}
def _compute_sade_sati(self, body):
from sade_sati import calc_sade_sati_complete
return calc_sade_sati_complete(
self._normalize_degree(body, 'moon_degree', 0),
self._normalize_degree(body, 'asc_degree', 0),
self._normalize_degree(body, 'saturn_degree', 0),
)
def _compute_pmc(self, body):
from pancha_mahapurusha import assess_pmc_strength
sun_degree = None
if body.get('sun_degree') is not None:
sun_degree = self._normalize_degree(body, 'sun_degree', 0)
return assess_pmc_strength(self._validate_planets(body.get('planets', {})), sun_degree)
def _compute_career(self, body):
from career_analysis import analyze_career
asc_sign = body.get('asc_sign', 'Aries')
if asc_sign not in SIGNS:
raise BadRequest('asc_sign must be a valid sign')
return analyze_career(self._validate_planets(body.get('planets', {})), asc_sign)
def _compute_relationship(self, body):
from relationship_analysis import analyze_relationship
from spouse_status_yoga import analyze_spouse_status
asc_sign = body.get('asc_sign', 'Aries')
if asc_sign not in SIGNS:
raise BadRequest('asc_sign must be a valid sign')
planets, planet_lons = self._planet_lons_from_body(body)
normalized, _, asc_sign_idx = self._normalized_planets_from_body({'planets': planets, 'ascendant': {'sign': asc_sign}})
planets_for_analysis = normalized or planets
result = analyze_relationship(planets_for_analysis, asc_sign)
chart_data = {
'ascendant': {'sign': asc_sign},
'planets': planets_for_analysis,
}
d9_data = body.get('d9') if isinstance(body.get('d9'), dict) else None
result['spouse_status_yoga'] = analyze_spouse_status(chart_data, d9_data)
result['relationship_timing'] = self._compute_relationship_timing_evidence(
body,
planets_for_analysis,
planet_lons,
asc_sign_idx,
asc_sign,
d9_data,
)
result['fragment_sources'] = sorted(set(result.get('fragment_sources', []) + [
'relationship_analysis.py',
'spouse_status_yoga.py',
'darakaraka_reader.py',
'jaimini.py',
]))
return result
def _compute_relationship_timing_evidence(self, body, planets, planet_lons, asc_sign_idx, asc_sign, d9_data=None):
jaimini = _load_local_module('jaimini')
varga = _load_local_module('varga')
evidence = []
timing_clues = []
planet_degs = {planet: lon % 30 for planet, lon in planet_lons.items()}
ck7 = jaimini.calc_chara_karaka_7(planet_degs) if planet_degs else {}
dk = (ck7.get('karaka_table') or {}).get('Darakaraka') or {}
dk_planet = dk.get('planet')
darakaraka = None
if dk_planet:
d9_planets = {}
for planet, lon in planet_lons.items():
d9_pos = varga.calc_varga(lon, 9)
d9_planets[planet] = {
'sign': d9_pos.get('sign'),
'house': d9_pos.get('house'),
'degree': d9_pos.get('degree_in_sign', d9_pos.get('degree')),
'degree_in_sign': d9_pos.get('degree_in_sign', d9_pos.get('degree')),
}
chart_data = {
'ascendant': {'sign': asc_sign},
'planets': planets,
'd9': {'planets': d9_planets},
}
try:
darakaraka = _load_local_module('darakaraka_reader').analyze_darakaraka(chart_data, use_8_karaka=True)
timing_clues.extend(darakaraka.get('timing_clues') or [])
evidence.append({
'label': 'Darakaraka (DK)',
'value': f"{dk_planet} · H{darakaraka.get('dk_house', '-')}",
'note': f"{darakaraka.get('core_profile') or '配偶象征星'};婚姻质量 {darakaraka.get('marriage_quality_score', '-')}/100",
})
except Exception as exc:
darakaraka = {'error': str(exc), 'dk_planet': dk_planet}
upapada = jaimini.calc_upapada(asc_sign_idx, planet_lons) if planet_lons else None
if upapada:
evidence.append({
'label': 'Upapada Lagna (UL)',
'value': f"{upapada.get('sign')} · 2nd {upapada.get('second_from_ul', '-')}",
'note': upapada.get('description') or '婚姻外显与关系持续性证据',
})
h7_sign = SIGNS[(asc_sign_idx + 6) % 12]
h7_lord = {
'Aries':'Mars','Taurus':'Venus','Gemini':'Mercury','Cancer':'Moon',
'Leo':'Sun','Virgo':'Mercury','Libra':'Venus','Scorpio':'Mars',
'Sagittarius':'Jupiter','Capricorn':'Saturn','Aquarius':'Saturn','Pisces':'Jupiter',
}.get(h7_sign)
dasha_focus = self._relationship_dasha_focus(body, dk_planet, h7_lord)
if dasha_focus:
timing_clues.extend(dasha_focus.get('clues', []))
evidence.append({
'label': 'Dasha trigger',
'value': dasha_focus.get('label', '待补充'),
'note': dasha_focus.get('note', ''),
})
score = 0
if darakaraka and not darakaraka.get('error'):
quality = darakaraka.get('marriage_quality_score')
if isinstance(quality, (int, float)):
score += 2 if quality >= 65 else 1 if quality >= 45 else 0
if darakaraka.get('timing_clues'):
score += 1
if upapada:
score += 1
if dasha_focus and dasha_focus.get('level') != 'neutral':
score += 1
level = 'strong' if score >= 4 else 'watch' if score >= 2 else 'thin'
summary = {
'strong': 'DK、UL 与运限触发能形成较完整的关系时机证据链。',
'watch': '已有 DK/UL 或运限线索,适合继续用 D9、行运和现实事件复核。',
'thin': '关系时机证据仍偏薄,需要更完整出生数据或当前运限。',
}[level]
return {
'level': level,
'score': score,
'summary': summary,
'darakaraka': darakaraka,
'upapada': upapada,
'dasha_focus': dasha_focus,
'timing_clues': list(dict.fromkeys(timing_clues))[:6],
'evidence': evidence,
'source': 'darakaraka_reader.py + jaimini.py',
}
def _relationship_dasha_focus(self, body, dk_planet=None, h7_lord=None):
raw = body.get('dasha_info') or body.get('dasha') or {}
if not isinstance(raw, dict):
return None
candidates = [
raw.get('maha_dasha'), raw.get('maha'), raw.get('md'), raw.get('maha_lord'),
raw.get('antar_dasha'), raw.get('antar'), raw.get('ad'), raw.get('antar_lord'),
raw.get('pratyantar'), raw.get('pd'), raw.get('pratyantar_lord'),
]
active = [str(item) for item in candidates if item]
if not active:
return None
focus = {'Venus', 'Jupiter', 'Moon', 'Mars'}
if dk_planet:
focus.add(dk_planet)
if h7_lord:
focus.add(h7_lord)
hits = [planet for planet in active if planet in focus]
label = ' / '.join(active[:3])
if hits:
return {
'level': 'activated',
'label': label,
'hits': hits,
'note': f"当前运限触及 {''.join(hits)},关系/承诺主题更容易被事件激活。",
'clues': [f"Dasha 命中 {''.join(hits)},作为关系时机窗口观察。"],
}
return {
'level': 'neutral',
'label': label,
'hits': [],
'note': '当前运限未明显命中 Venus/Jupiter/Moon/Mars/DK/7主,关系时机需更多行运或事件证据。',
'clues': [],
}
def _planet_lons_from_body(self, body):
planets = self._validate_planets(body.get('planets', {}))
planet_lons = {}
for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn', 'Rahu', 'Ketu']:
lon = self._planet_lon(planets, planet)
if lon is not None:
planet_lons[planet] = lon
return planets, planet_lons
def _normalized_planets_from_body(self, body):
planets, planet_lons = self._planet_lons_from_body(body)
asc_sign_idx = self._asc_sign_idx_from_body(body)
normalized = {}
for planet, lon in planet_lons.items():
source = planets.get(planet, {}) if isinstance(planets, dict) else {}
sign_idx = int(lon / 30) % 12
sign = SIGNS[sign_idx]
house = source.get('house') if isinstance(source, dict) else None
if house is None:
house = ((sign_idx - asc_sign_idx) % 12) + 1
normalized[planet] = {
**(source if isinstance(source, dict) else {}),
'lon': lon,
'degree': lon,
'degree_in_sign': lon % 30,
'sign_idx': sign_idx,
'sign': sign,
'house': int(house),
}
return normalized, planet_lons, asc_sign_idx
def _asc_sign_idx_from_body(self, body):
ascendant = body.get('ascendant', {})
if isinstance(ascendant, dict):
if ascendant.get('sign_idx') is not None:
return self._sign_idx_from_value(ascendant.get('sign_idx'), 0)
if ascendant.get('sign') in SIGNS:
return SIGNS.index(ascendant.get('sign'))
if ascendant.get('lon') is not None:
try:
return int((float(ascendant.get('lon')) % 360) / 30) % 12
except (TypeError, ValueError) as e:
raise BadRequest('ascendant lon must be a number') from e
return int(self._asc_lon_from_body(body) / 30) % 12
def _chart_payload_from_body(self, body):
planets, planet_lons = self._planet_lons_from_body(body)
chart_data = {'planets': {}}
for planet, lon in planet_lons.items():
sign_idx = int(lon / 30) % 12
source = planets.get(planet, {}) if isinstance(planets, dict) else {}
chart_data['planets'][planet] = {
**(source if isinstance(source, dict) else {}),
'degree': lon,
'lon': lon,
'sign_idx': sign_idx,
'sign': SIGNS[sign_idx],
'degree_in_sign': lon % 30,
}
ascendant = body.get('ascendant', {})
if isinstance(ascendant, dict):
chart_data['ascendant'] = ascendant
return chart_data, planet_lons
def _asc_lon_from_body(self, body, default=0):
ascendant = body.get('ascendant', {})
if ascendant is None:
ascendant = {}
if not isinstance(ascendant, dict):
raise BadRequest('ascendant must be an object')
asc_lon = ascendant.get('lon', ascendant.get('degree_raw'))
if asc_lon is None and ascendant.get('sign') in SIGNS:
try:
return (
SIGNS.index(ascendant['sign']) * 30
+ float(ascendant.get('degree_in_sign', ascendant.get('degree', 0)))
) % 360
except (TypeError, ValueError) as e:
raise BadRequest('ascendant degree must be a number') from e
if asc_lon is None and ascendant.get('sign_idx') is not None:
try:
sign_idx = int(ascendant.get('sign_idx')) % 12
degree = float(ascendant.get('degree_in_sign', ascendant.get('degree', 0)))
return (sign_idx * 30 + degree) % 360
except (TypeError, ValueError) as e:
raise BadRequest('ascendant sign_idx/degree must be numeric') from e
if asc_lon is None:
return self._normalize_degree(body, 'asc_lon', default)
try:
number = float(asc_lon)
except (TypeError, ValueError) as e:
raise BadRequest('ascendant lon must be a number') from e
if not math.isfinite(number):
raise BadRequest('ascendant lon must be finite')
return number % 360
def _parse_varga_divisions(self, value):
if value is None or value == '':
return None
if isinstance(value, str):
raw_items = [item.strip() for item in value.split(',') if item.strip()]
elif isinstance(value, list):
raw_items = value
else:
raise BadRequest('divisions must be a comma string or list')
divisions = []
for item in raw_items:
token = str(item).strip().upper()
if token.startswith('D'):
token = token[1:]
if not re.fullmatch(r'\d+', token):
raise BadRequest('divisions must contain D-numbers such as D9 or 9')
division = int(token)
if division < 1 or division > 300:
raise BadRequest('division must be between 1 and 300')
divisions.append(division)
return divisions or None
def _parse_varga_composite(self, value):
if value is None or value == '':
return None
if isinstance(value, str):
raw_parts = [part.strip() for part in value.split(',') if part.strip()]
elif isinstance(value, list):
raw_parts = value
else:
raise BadRequest('composite must be "m,n" or a two-item list')
if len(raw_parts) != 2:
raise BadRequest('composite requires exactly two division factors')
parts = []
for part in raw_parts:
try:
number = int(part)
except (TypeError, ValueError) as e:
raise BadRequest('composite factors must be integers') from e
if number < 2 or number > 300:
raise BadRequest('composite factors must be between 2 and 300')
parts.append(number)
return parts[0], parts[1]
def _compute_varga_full(self, body):
_, planet_lons = self._planet_lons_from_body(body)
if not planet_lons:
raise BadRequest('planets must include longitude data')
asc_lon = self._asc_lon_from_body(body)
divisions = self._parse_varga_divisions(body.get('divisions'))
composite = self._parse_varga_composite(body.get('composite'))
custom_n = None
if body.get('custom') is not None:
custom_n = self._get_int(body, 'custom', 0, 2, 300)
variant = body.get('variant')
if variant is not None and not isinstance(variant, str):
raise BadRequest('variant must be a string')
mode_count = sum([
custom_n is not None,
composite is not None,
bool(variant),
])
if mode_count > 1:
raise BadRequest('custom, composite, and variant modes are mutually exclusive')
module = _load_local_module('divisional_charts_extended')
calc = module.DivisionalChartsCalculator()
try:
if custom_n is not None:
result = {'custom_div': custom_n}
result['Ascendant'] = calc.calc_custom_varga(asc_lon, custom_n)
for planet, lon in planet_lons.items():
result[planet] = calc.calc_custom_varga(lon, custom_n)
return {
'success': True,
'endpoint': 'varga_full',
'mode': 'custom',
'source': 'divisional_charts_extended',
'result': result,
}
if composite is not None:
outer, inner = composite
result = {
'composite_div': f'D{outer}×D{inner}=D{outer * inner}',
'outer': outer,
'inner': inner,
}
result['Ascendant'] = calc.calc_composite_varga(asc_lon, outer, inner)
for planet, lon in planet_lons.items():
result[planet] = calc.calc_composite_varga(lon, outer, inner)
return {
'success': True,
'endpoint': 'varga_full',
'mode': 'composite',
'source': 'divisional_charts_extended',
'result': result,
}
if variant:
if not divisions or len(divisions) != 1 or divisions[0] not in (2, 3):
raise BadRequest('variant mode requires exactly one division: D2 or D3')
division = divisions[0]
result = {'variant': variant, 'div': division}
result['Ascendant'] = calc.calc_varga_with_variant(asc_lon, division, variant)
for planet, lon in planet_lons.items():
result[planet] = calc.calc_varga_with_variant(lon, division, variant)
return {
'success': True,
'endpoint': 'varga_full',
'mode': 'variant',
'source': 'divisional_charts_extended',
'result': result,
}
available = {varga.division: varga for varga in module.VargaType}
selected = [available[division] for division in (divisions or sorted(available))]
result = {}
for varga in selected:
key = f'D{varga.division}_{varga.varga_name}'
result[key] = calc._calculate_single_varga(varga, planet_lons, asc_lon)
except KeyError as e:
raise BadRequest('unsupported standard division; use custom mode for arbitrary D-N') from e
except ValueError as e:
raise BadRequest(str(e)) from e
return {
'success': True,
'endpoint': 'varga_full',
'mode': 'standard',
'source': 'divisional_charts_extended',
'divisions': [varga.division for varga in selected],
'result': result,
}
def _compute_jaimini(self, body):
planets, planet_lons, asc_sign_idx = self._normalized_planets_from_body(body)
if not planet_lons:
raise BadRequest('planets must include longitude data')
mode = body.get('mode', 'all')
if not isinstance(mode, str):
raise BadRequest('mode must be a string')
mode = mode.strip().lower() or 'all'
allowed_modes = {'all', 'karaka', 'dasha', 'karakamsha', 'arudha', 'special'}
if mode not in allowed_modes:
raise BadRequest(f'mode must be one of: {", ".join(sorted(allowed_modes))}')
antardasha = bool(body.get('antardasha', False))
year = self._get_int(body, 'year', datetime.now().year, 1800, 2400)
month = self._get_int(body, 'month', 1, 1, 12)
hour = self._get_int(body, 'hour', 12, 0, 23)
minute = self._get_int(body, 'minute', 0, 0, 59)
jaimini = _load_local_module('jaimini')
varga = _load_local_module('varga')
planet_degs = {planet: lon % 30 for planet, lon in planet_lons.items()}
result = {}
if mode in ('all', 'karaka'):
result['chara_karaka_7'] = jaimini.calc_chara_karaka_7(planet_degs)
result['chara_karaka_8'] = jaimini.calc_chara_karaka_8(planet_degs)
if mode in ('all', 'dasha'):
if antardasha:
result['chara_dasha'] = jaimini.calc_chara_dasha_with_antardasha(asc_sign_idx, planet_lons, year, month)
else:
result['chara_dasha'] = jaimini.calc_chara_dasha(asc_sign_idx, planet_lons, year, month)
if mode in ('all', 'karakamsha'):
ck7 = result.get('chara_karaka_7') or jaimini.calc_chara_karaka_7(planet_degs)
ak_name = ck7['karaka_table']['Atmakaraka']['planet']
ak_d9 = varga.calc_varga(planet_lons.get(ak_name, 0), 9)
result['karakamsha'] = jaimini.calc_karakamsha(
ak_d9.get('sign', 'Aries'),
ak_d9.get('degree_in_sign', 0),
)
if mode in ('all', 'arudha'):
result['arudha_padas'] = jaimini.calc_arudha_padas(asc_sign_idx, planet_lons)
result['graha_padas'] = jaimini.calc_graha_padas(planet_lons)
if mode in ('all', 'special'):
result['special_lagnas'] = jaimini.calc_special_lagnas(asc_sign_idx, hour, minute)
return {
'success': True,
'endpoint': 'jaimini',
'mode': mode,
'ascendant': SIGNS[asc_sign_idx],
'result': result,
}
def _compute_ashtakavarga(self, body):
planets, _, asc_sign_idx = self._normalized_planets_from_body(body)
if not planets:
raise BadRequest('planets must include longitude data')
ashtakavarga = _load_local_module('ashtakavarga')
result = ashtakavarga.calc_ashtakavarga(planets, asc_sign_idx)
pav = ashtakavarga.calc_prastara_av(planets, asc_sign_idx)
sodhita = ashtakavarga.calc_sodhita_av(result.get('bav', {}), planets, asc_sign_idx)
yoga_pinda = result.get('yoga_pinda') or ashtakavarga.calc_yoga_pinda(result.get('bav', {}), planets, asc_sign_idx)
result['pav'] = pav
result['sodhita'] = sodhita
result['yoga_pinda'] = yoga_pinda
summary = self._summarize_ashtakavarga(result, pav, sodhita, yoga_pinda)
return {
'success': True,
'endpoint': 'ashtakavarga',
'rule_variants': self._ashtakavarga_rule_variants(),
'summary': summary,
'pav_summary': summary['pav_summary'],
'sodhita_summary': summary['sodhita_summary'],
'yoga_pinda_summary': summary['yoga_pinda_summary'],
'result': result,
}
def _summarize_ashtakavarga(self, result, pav, sodhita, yoga_pinda=None):
house_rows = []
for key, item in (result.get('house_scores') or {}).items():
try:
house_num = int(str(key).split('_')[-1])
except (TypeError, ValueError):
house_num = 0
house_rows.append({
'house': house_num,
'sign': item.get('sign'),
'score': item.get('sav_score', 0),
'level': item.get('level', ''),
})
house_rows.sort(key=lambda item: item.get('score', 0), reverse=True)
strongest = house_rows[:3]
weakest = sorted(house_rows, key=lambda item: item.get('score', 0))[:3]
pav_totals = []
for planet, source_scores in (pav.get('pav_summary') or {}).items():
total = sum(value for value in source_scores.values() if isinstance(value, (int, float)))
top_sources = sorted(source_scores.items(), key=lambda pair: pair[1], reverse=True)[:3]
pav_totals.append({
'planet': planet,
'total': total,
'top_sources': [{'source': source, 'bindus': bindus} for source, bindus in top_sources],
})
pav_totals.sort(key=lambda item: item['total'], reverse=True)
sodhita_scores = sodhita.get('sodhita_sav', {}).get('assessment', [])
sodhita_rank = sorted(sodhita_scores, key=lambda item: item.get('score', 0), reverse=True)
raw_total = result.get('sav', {}).get('total', 0)
sodhita_total = sodhita.get('sodhita_sav', {}).get('total', 0)
reduction_total = raw_total - sodhita_total if isinstance(raw_total, (int, float)) and isinstance(sodhita_total, (int, float)) else 0
yoga_pinda = yoga_pinda or result.get('yoga_pinda') or {}
yoga_rows = yoga_pinda.get('planets') or {}
yoga_rank = sorted(yoga_rows.items(), key=lambda item: item[1].get('yoga_pinda', 0), reverse=True)
leader = strongest[0] if strongest else {}
headline = (
f"Ashtakavarga总分{raw_total},重点支持H{leader.get('house')} {leader.get('sign')}"
if leader else f"Ashtakavarga总分{raw_total}"
)
return {
'headline': headline,
'sav_total': raw_total,
'sav_valid': result.get('sav', {}).get('valid'),
'strongest_houses': strongest,
'weakest_houses': weakest,
'pav_summary': {
'headline': f"PAV显示{pav_totals[0]['planet']}贡献结构最强" if pav_totals else 'PAV已生成贡献矩阵',
'top_planets': pav_totals[:3],
'validation_passed': pav.get('all_valid'),
},
'sodhita_summary': {
'headline': f"Sodhita净化后总分{sodhita_total},扣减{reduction_total}",
'top_signs': sodhita_rank[:3],
'weak_signs': sorted(sodhita_scores, key=lambda item: item.get('score', 0))[:3],
'reduction_total': reduction_total,
},
'yoga_pinda_summary': {
'headline': (
f"Yoga Pinda以{yoga_rank[0][0]}最高({yoga_rank[0][1].get('yoga_pinda', 0)}"
if yoga_rank else 'Yoga Pinda未返回'
),
'top_planets': [
{'planet': planet, 'yoga_pinda': row.get('yoga_pinda'), 'sign': row.get('sign')}
for planet, row in yoga_rank[:3]
],
'weak_planets': [
{'planet': planet, 'yoga_pinda': row.get('yoga_pinda'), 'sign': row.get('sign')}
for planet, row in sorted(yoga_rows.items(), key=lambda item: item[1].get('yoga_pinda', 0))[:3]
],
'total_yoga_pinda': yoga_pinda.get('summary', {}).get('total_yoga_pinda', 0),
'validation_passed': yoga_pinda.get('all_valid'),
},
'next_action': '先用SAV定领域强弱,再用PAV追溯贡献源,用Sodhita检验净支持度,并用Yoga Pinda比较行星承载力。',
}
def _compute_shadbala(self, body):
planets, planet_lons, asc_sign_idx = self._normalized_planets_from_body(body)
if not {'Sun', 'Moon'} <= set(planet_lons):
raise BadRequest('planets must include Sun and Moon longitude data')
birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23)
birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59)
result = _load_local_module('shadbala').calc_shadbala(
planets,
SIGNS[asc_sign_idx],
birth_hour,
planet_lons['Sun'],
planet_lons['Moon'],
birth_minute=birth_minute,
)
advanced = self._compute_shadbala_advanced_layer(body, planets, result)
return {
'success': True,
'endpoint': 'shadbala',
'rule_variants': self._shadbala_rule_variants(bool(advanced)),
'advanced_layer': advanced,
'result': result,
}
def _compute_yogas_api(self, body):
planets, _, asc_sign_idx = self._normalized_planets_from_body(body)
if not planets:
raise BadRequest('planets must include longitude data')
asc_sign = SIGNS[asc_sign_idx]
extended = _load_local_module('yoga_expansion').detect_all_yogas(planets, asc_sign)
try:
engine_yogas = _load_local_module('yoga_engine').detect_yogas(planets, asc_sign)
except Exception:
engine_yogas = []
curse_yogas = self._compute_curse_yoga_layer(body, planets, asc_sign)
result = {
'extended_yogas': extended,
'rule_engine_yogas': engine_yogas,
'curse_yogas': curse_yogas,
'summary': {
'extended_count': len(extended),
'rule_engine_count': len(engine_yogas),
'curse_count': len(curse_yogas.get('curses_detected', [])),
'risk': curse_yogas.get('overall_risk', 'low'),
},
'rule_variants': self._yoga_rule_variants(bool(curse_yogas.get('curses_detected'))),
}
return {
'success': True,
'endpoint': 'yogas',
'ascendant': asc_sign,
'rule_variants': result['rule_variants'],
'result': result,
}
def _ashtakavarga_rule_variants(self):
return {
'selected': ['sav_bav', 'prastara_av', 'sodhita_av', 'yoga_pinda'],
'available': [
{'key': 'sav_bav', 'label': 'SAV/BAV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'},
{'key': 'prastara_av', 'label': 'Prastara AV / PAV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'},
{'key': 'sodhita_av', 'label': 'Sodhita AV', 'status': 'active', 'source': 'scripts/ashtakavarga.py'},
{'key': 'yoga_pinda', 'label': 'Yoga Pinda', 'status': 'active', 'source': 'scripts/ashtakavarga.py'},
],
'boundary': '当前按本地 BAV 贡献规则生成 SAV、PAV、Sodhita 与 Yoga Pinda;后续可继续加入 Sarvashtakavarga 规则版本对比。',
}
def _shadbala_rule_variants(self, advanced_enabled):
return {
'selected': ['core_sixfold', 'advanced_evidence'] if advanced_enabled else ['core_sixfold'],
'available': [
{'key': 'core_sixfold', 'label': '六重力量主算法', 'status': 'active', 'source': 'scripts/shadbala.py'},
{'key': 'advanced_evidence', 'label': 'Kala/Yuddha/Sputa 增强证据', 'status': 'active' if advanced_enabled else 'unavailable', 'source': 'scripts/shadbala_advanced.py'},
],
'boundary': '增强层只作为证据补充,不覆盖主 Shadbala 的总分与排名。',
}
def _yoga_rule_variants(self, curse_enabled):
return {
'selected': ['extended_algorithm', 'json_rule_engine'] + (['curse_conjunctions'] if curse_enabled else []),
'available': [
{'key': 'extended_algorithm', 'label': '算法 Yoga 扩展', 'status': 'active', 'source': 'scripts/yoga_expansion.py'},
{'key': 'json_rule_engine', 'label': 'JSON 规则引擎', 'status': 'active', 'source': 'scripts/yoga_engine.py + references/yoga_rules.json'},
{'key': 'curse_conjunctions', 'label': '凶星合相命名', 'status': 'active', 'source': 'scripts/curse_yoga_detector.py'},
],
'boundary': '凶星合相属于高风险提示层,只能作为 Yoga 证据之一,不能替代健康、法律或安全建议。',
}
def _compute_curse_yoga_layer(self, body, planets, asc_sign):
current_dasha = body.get('current_dasha') or body.get('dasha_lord')
chart_data = {
'ascendant': {'sign': asc_sign},
'planets': self._planets_for_legacy_fragments(planets),
}
context = body.get('context')
if isinstance(context, dict):
chart_data['context'] = context
try:
return _load_local_module('curse_yoga_detector').detect_curse_yogas(chart_data, current_dasha=current_dasha)
except Exception as exc:
import logging
logging.warning(f"[api_server] curse yoga layer failed: {exc}")
return {'curses_detected': [], 'overall_risk': 'unknown', 'risk_score': 0, 'error': 'curse yoga layer unavailable'}
def _planets_for_legacy_fragments(self, planets):
legacy = {}
for planet, data in planets.items():
if not isinstance(data, dict):
continue
row = dict(data)
row['lon'] = data.get('lon', data.get('degree'))
row['degree'] = data.get('degree_in_sign', data.get('degree', 0))
row['degree_in_sign'] = row['degree']
legacy[planet] = row
return legacy
def _compute_shadbala_advanced_layer(self, body, planets, base_result):
try:
advanced_mod = _load_local_module('shadbala_advanced')
birth_hour = self._get_float(body, 'birth_hour', body.get('hour', 12), 0, 23)
birth_minute = self._get_float(body, 'birth_minute', body.get('minute', 0), 0, 59)
year = self._get_int(body, 'year', datetime.now().year, 1800, 2400)
month = self._get_int(body, 'month', 1, 1, 12)
day = self._get_int(body, 'day', 1, 1, 31)
lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90)
lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180)
tz = self._get_float(body, 'tz', body.get('birth_tz', 0), -14, 14)
hour_decimal = birth_hour + birth_minute / 60.0
solar_lon = planets.get('Sun', {}).get('lon', 0)
base_planets = base_result.get('planets', {}) if isinstance(base_result, dict) else {}
comparison_planets = {}
kala_additions = {}
drik_sputa = {}
for planet, pdata in planets.items():
if planet not in base_planets:
continue
comparison_planets[planet] = {
**pdata,
'degree': pdata.get('lon', pdata.get('degree', 0)),
'shadbala_total': base_planets.get(planet, {}).get('total_virupas', 0),
}
for planet in comparison_planets:
kala_additions[planet] = round(advanced_mod.calc_varsha_maasa_dina_hora_bala(
planet, year, month, day, hour_decimal, solar_lon, lat, lon, tz
), 2)
drik_sputa[planet] = round(advanced_mod.calc_drik_bala_sputa(planet, comparison_planets), 2)
yuddha = advanced_mod.calc_yuddha_bala(comparison_planets)
active_yuddha = {planet: value for planet, value in yuddha.items() if abs(value) > 0}
top_kala = sorted(kala_additions.items(), key=lambda item: item[1], reverse=True)[:3]
return {
'source': 'scripts/shadbala_advanced.py',
'method': 'Kala Bala完整子项 + Yuddha Bala + Sputa Drishti 证据层',
'kala_vmdh': kala_additions,
'top_kala_support': [{'planet': planet, 'virupas': value} for planet, value in top_kala],
'yuddha_bala': yuddha,
'active_yuddha': active_yuddha,
'sputa_drik_bala': drik_sputa,
'next_action': '若高级层与主排名冲突,先保留主 Shadbala 排名,再把冲突作为需要人工复核的证据。',
}
except Exception as exc:
import logging
logging.warning(f"[api_server] shadbala advanced layer failed: {exc}")
return {}
def _compute_aspects(self, body):
_, planet_lons = self._planet_lons_from_body(body)
if len(planet_lons) < 2:
raise BadRequest('planets must include at least two longitude values')
asc_lon = self._asc_lon_from_body(body)
result = _load_local_module('aspects').calc_all_aspects(planet_lons, asc_lon)
return {
'success': True,
'endpoint': 'aspects',
'result': result,
}
def _compute_annual(self, body):
target_year = self._get_int(body, 'target_year', datetime.now().year, 1800, 2400)
birth_dt = self._parse_birth_datetime(body)
lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90)
lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180)
tz = self._get_float(body, 'tz', body.get('birth_tz', 0), -14, 14)
solar_return = _load_local_module('solar_return')
report = solar_return.solar_return_full_report(
birth_dt.year,
birth_dt.month,
birth_dt.day,
birth_dt.hour,
birth_dt.minute,
lat,
lon,
tz,
target_year,
)
return {'success': True, 'endpoint': 'annual', 'report': report}
def _compute_muhurta(self, body):
has_range = body.get('start_date') or body.get('end_date') or body.get('start') or body.get('end')
query_date = body.get('date') or datetime.now().strftime('%Y-%m-%d')
if not isinstance(query_date, str):
raise BadRequest('date must be a string')
try:
query_dt = datetime.strptime(query_date[:10], '%Y-%m-%d')
except ValueError as e:
raise BadRequest('date must be YYYY-MM-DD') from e
activity = body.get('activity')
if activity is not None and not isinstance(activity, str):
raise BadRequest('activity must be a string')
hour_from_sunrise = self._get_float(body, 'hour_from_sunrise', 6.0, 0, 24)
sunrise = body.get('sunrise', '06:00')
sunset = body.get('sunset', '18:00')
if not isinstance(sunrise, str) or not isinstance(sunset, str):
raise BadRequest('sunrise/sunset must be HH:MM strings')
sun_lon = self._planet_lon(body.get('planets', {}), 'Sun')
moon_lon = self._planet_lon(body.get('planets', {}), 'Moon')
muhurta = _load_local_module('muhurta')
if sun_lon is None or moon_lon is None:
sun_lon, moon_lon = muhurta._approx_sun_moon_lon(query_dt.year, query_dt.month, query_dt.day)
weekday = (query_dt.weekday() + 1) % 7 # Python Mon=0; module Sun=0
activities = [activity] if activity else None
report = muhurta.muhurta_full_report(
sun_lon,
moon_lon,
weekday,
hour_from_sunrise=hour_from_sunrise,
query_date_str=query_date[:10],
activities=activities,
)
result = {'success': True, 'endpoint': 'muhurta', 'report': report}
if has_range:
start_raw = body.get('start_date') or body.get('start') or query_date
end_raw = body.get('end_date') or body.get('end') or start_raw
if not isinstance(start_raw, str) or not isinstance(end_raw, str):
raise BadRequest('start_date/end_date must be strings')
try:
start_dt = datetime.strptime(start_raw[:10], '%Y-%m-%d')
end_dt = datetime.strptime(end_raw[:10], '%Y-%m-%d')
except ValueError as e:
raise BadRequest('start_date/end_date must be YYYY-MM-DD') from e
if end_dt < start_dt:
raise BadRequest('end_date must be on or after start_date')
if (end_dt - start_dt).days > 62:
raise BadRequest('muhurta range must be <= 63 days')
lat = lon = tz = None
has_location = any(key in body for key in ('lat', 'lon', 'tz'))
if has_location:
lat = self._get_float(body, 'lat', 0, -90, 90)
lon = self._get_float(body, 'lon', 0, -180, 180)
tz = self._get_float(body, 'tz', 0, -14, 14)
limit = self._get_int(body, 'limit', 5, 1, 20)
try:
result['range_search'] = muhurta.muhurta_range_search(
start_dt.strftime('%Y-%m-%d'),
end_dt.strftime('%Y-%m-%d'),
activity=activity or 'business',
limit=limit,
hour_from_sunrise=hour_from_sunrise,
sunrise=sunrise,
sunset=sunset,
lat=lat,
lon=lon,
tz=tz,
)
except ValueError as e:
raise BadRequest(str(e)) from e
return result
def _compute_panchanga_range(self, body):
start_raw = body.get('start_date') or body.get('start') or datetime.now().strftime('%Y-%m-%d')
end_raw = body.get('end_date') or body.get('end') or start_raw
if not isinstance(start_raw, str) or not isinstance(end_raw, str):
raise BadRequest('start_date/end_date must be strings')
try:
start_dt = datetime.strptime(start_raw[:10], '%Y-%m-%d')
end_dt = datetime.strptime(end_raw[:10], '%Y-%m-%d')
except ValueError as e:
raise BadRequest('start_date/end_date must be YYYY-MM-DD') from e
if end_dt < start_dt:
raise BadRequest('end_date must be on or after start_date')
if (end_dt - start_dt).days > 62:
raise BadRequest('panchanga range must be <= 63 days')
activity = body.get('activity')
if activity is not None and not isinstance(activity, str):
raise BadRequest('activity must be a string')
sunrise = body.get('sunrise', '06:00')
sunset = body.get('sunset', '18:00')
if not isinstance(sunrise, str) or not isinstance(sunset, str):
raise BadRequest('sunrise/sunset must be HH:MM strings')
hour_from_sunrise = self._get_float(body, 'hour_from_sunrise', 6.0, 0, 24)
lat = lon = tz = None
has_location = any(key in body for key in ('lat', 'lon', 'tz'))
if has_location:
lat = self._get_float(body, 'lat', 0, -90, 90)
lon = self._get_float(body, 'lon', 0, -180, 180)
tz = self._get_float(body, 'tz', 0, -14, 14)
muhurta = _load_local_module('muhurta')
try:
report = muhurta.panchanga_range_report(
start_dt.strftime('%Y-%m-%d'),
end_dt.strftime('%Y-%m-%d'),
hour_from_sunrise=hour_from_sunrise,
sunrise=sunrise,
sunset=sunset,
activity=activity,
lat=lat,
lon=lon,
tz=tz,
)
except ValueError as e:
raise BadRequest(str(e)) from e
return {'success': True, 'endpoint': 'panchanga_range', 'report': report}
def _compute_rectification_gate(self, body):
asc_lon = self._asc_lon_from_body(body)
declared_accuracy = body.get('declared_accuracy', body.get('accuracy', 'minute'))
time_source = body.get('time_source', 'family_clear')
if not isinstance(declared_accuracy, str):
raise BadRequest('declared_accuracy must be a string')
if not isinstance(time_source, str):
raise BadRequest('time_source must be a string')
module = _load_local_module('birth_time_rectifier')
is_boundary, boundary_note = module.check_lagna_boundary(asc_lon)
effective_accuracy = module.get_effective_accuracy(declared_accuracy, time_source)
enabled_vargas = module.get_enabled_vargas(effective_accuracy)
normalized_planets, _, _ = self._normalized_planets_from_body(body)
recommended_events = module.recommend_event_types(normalized_planets)
confidence_seed = module.calculate_confidence(0, 0, effective_accuracy, is_boundary)
disabled = sorted([key for key, value in enabled_vargas.items() if value == 'disabled'])
warned = sorted([key for key, value in enabled_vargas.items() if value == 'enabled_with_warning'])
enabled = sorted([key for key, value in enabled_vargas.items() if value == 'enabled'])
if is_boundary:
headline = '出生时间高度敏感,建议先做事件反验'
next_action = '优先录入婚姻、迁移、事业转折、健康危机等日期明确事件,再比较相邻候选时间。'
elif warned or disabled:
headline = '可读主盘,但高敏分盘需要降级'
next_action = 'D1 可正常阅读;D9/D10 以上结论需标注时间精度限制。'
else:
headline = '出生时间风险较低,可进入完整解盘'
next_action = '保留原始出生记录来源;重要预测仍建议用 Dasha/Transit/案例验证交叉确认。'
return {
'success': True,
'endpoint': 'rectification_gate',
'ascendant': {
'lon': asc_lon,
'sign': SIGNS[int(asc_lon / 30) % 12],
'degree_in_sign': round(asc_lon % 30, 4),
},
'declared_accuracy': declared_accuracy,
'time_source': time_source,
'effective_accuracy': effective_accuracy,
'lagna_boundary': {
'is_sensitive': is_boundary,
'note': boundary_note,
},
'enabled_vargas': enabled_vargas,
'summary': {
'headline': headline,
'enabled': enabled,
'warned': warned,
'disabled': disabled,
'confidence_floor': confidence_seed.get('assessment'),
'recommended_events': recommended_events,
'next_action': next_action,
},
}
def _compute_case_validation(self, body):
planets, _, _ = self._normalized_planets_from_body(body)
current_md = body.get('current_md', body.get('dasha_lord', ''))
if current_md is not None and not isinstance(current_md, str):
raise BadRequest('current_md must be a string')
predicted_events = body.get('predicted_events', [])
if predicted_events is None:
predicted_events = []
if not isinstance(predicted_events, list):
raise BadRequest('predicted_events must be an array')
transit_desc = body.get('transit_desc', '')
if transit_desc is not None and not isinstance(transit_desc, str):
raise BadRequest('transit_desc must be a string')
analysis = {
'planets': {},
'dasha': {
'current_md': current_md,
'predicted_events': [str(item)[:120] for item in predicted_events[:12]],
},
'transit': transit_desc or {},
}
for planet, data in planets.items():
if planet not in {'Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn'}:
continue
dignity = self._case_dignity_label(planet, data.get('sign'))
if dignity:
analysis['planets'][planet] = {
'planet': planet,
'sign': data.get('sign'),
'house': data.get('house'),
'dignity': dignity,
}
validator = _load_local_module('case_validator')
raw = validator.validate_interpretation(analysis)
validations = raw.get('validations', [])
validated = [item for item in validations if item.get('validated')]
unvalidated = [item for item in validations if not item.get('validated')]
headline = '案例库支持度较强' if raw.get('overall_confidence', 0) >= 70 else '案例支持不足,需谨慎措辞'
if not validations:
headline = '当前星盘缺少可验证声明'
next_action = (
'优先使用已验证配置做结论;未验证项只作为假设,并补充真实案例或外部来源。'
if unvalidated else
'可以进入解释层,但仍需保留具体事件预测的置信度上限。'
)
mevg_gate = self._mevg_gate_status()
return {
'success': True,
'endpoint': 'case_validation',
'fragment_sources': ['case_validator.py', 'mevg_automation.py'],
'analysis': analysis,
'result': raw,
'mevg_gate': mevg_gate,
'summary': {
'headline': headline,
'overall_confidence': raw.get('overall_confidence', 0),
'validated_count': len(validated),
'unvalidated_count': len(unvalidated),
'case_base': raw.get('case_base', ''),
'gate_status': mevg_gate.get('gate_status'),
'top_cases': sorted({
case
for item in validated
for case in item.get('cases', [])
if isinstance(case, str)
})[:8],
'next_action': next_action,
},
}
def _mevg_gate_status(self):
try:
mevg = _load_local_module('mevg_automation')
state_path = getattr(mevg, 'MEVG_STATE_FILE', None)
checks = getattr(mevg, 'VALIDATION_CHECKS', [])
threshold = getattr(mevg, 'GATE_THRESHOLD', 0.30)
if not state_path or not os.path.exists(state_path):
return {
'source': 'mevg_automation.py',
'gate_status': 'NOT_INITIALIZED',
'threshold': threshold,
'case_count': 0,
'failed_count': 0,
'fail_rate': 0,
'checks': checks,
'next_action': '运行 MEVG 案例验证后再把外部门控作为预测置信度依据。',
}
with open(state_path, 'r', encoding='utf-8') as fh:
state = json.load(fh)
cases = state.get('cases', {}) if isinstance(state, dict) else {}
failed = sum(1 for item in cases.values() if isinstance(item, dict) and item.get('verdict') == 'FAIL')
fail_rate = failed / len(cases) if cases else 0
return {
'source': 'mevg_automation.py',
'gate_status': state.get('gate_status', 'UNKNOWN'),
'threshold': threshold,
'case_count': len(cases),
'failed_count': failed,
'fail_rate': round(fail_rate, 3),
'last_updated': state.get('last_updated'),
'checks': checks,
'next_action': '若门控 CLOSED,应先校准失败案例,再输出新的预测型解读。',
}
except Exception as exc:
import logging
logging.warning(f"[api_server] MEVG gate status unavailable: {exc}")
return {
'source': 'mevg_automation.py',
'gate_status': 'UNAVAILABLE',
'error': str(exc),
'next_action': 'MEVG 自动化状态不可用;预测项必须降级为待验证假设。',
}
def _case_dignity_label(self, planet, sign):
exalted = {
'Sun': 'Aries',
'Moon': 'Taurus',
'Mars': 'Capricorn',
'Mercury': 'Virgo',
'Jupiter': 'Cancer',
'Venus': 'Pisces',
'Saturn': 'Libra',
}
debilitated = {
'Sun': 'Libra',
'Moon': 'Scorpio',
'Mars': 'Cancer',
'Mercury': 'Pisces',
'Jupiter': 'Capricorn',
'Venus': 'Virgo',
'Saturn': 'Aries',
}
own_signs = {
'Sun': {'Leo'},
'Moon': {'Cancer'},
'Mars': {'Aries', 'Scorpio'},
'Mercury': {'Gemini', 'Virgo'},
'Jupiter': {'Sagittarius', 'Pisces'},
'Venus': {'Taurus', 'Libra'},
'Saturn': {'Capricorn', 'Aquarius'},
}
if not sign:
return ''
if exalted.get(planet) == sign:
return 'exalted'
if debilitated.get(planet) == sign:
return 'debilitated'
if sign in own_signs.get(planet, set()):
return 'own_sign'
if planet == 'Mars':
return 'strong'
if planet == 'Venus' and sign in {'Taurus', 'Libra', 'Pisces'}:
return 'strong'
return ''
def _compute_divisional_yoga(self, body):
normalized, _, asc_sign_idx = self._normalized_planets_from_body(body)
if not normalized:
raise BadRequest('planets must include longitude data')
divisions = body.get('divisions', ['D9', 'D10', 'D12'])
if isinstance(divisions, str):
divisions = [item.strip().upper() for item in divisions.split(',') if item.strip()]
if not isinstance(divisions, list):
raise BadRequest('divisions must be a list or comma string')
allowed = {'D9', 'D10', 'D12'}
selected = []
for item in divisions:
token = str(item).strip().upper()
if token not in allowed:
raise BadRequest('divisional_yoga supports D9, D10, and D12')
selected.append(token)
selected = selected or ['D9', 'D10', 'D12']
module = _load_local_module('divisional_yoga')
results = {}
total = 0
for division in selected:
yogas = module.detect_varga_yogas(normalized, division, SIGNS[asc_sign_idx])
results[division] = {
'yoga_count': len(yogas),
'yogas': yogas,
}
total += len(yogas)
headline = '分盘 Yoga 有可用证据' if total else '分盘 Yoga 暂无强命中'
return {
'success': True,
'endpoint': 'divisional_yoga',
'ascendant': SIGNS[asc_sign_idx],
'divisions': selected,
'result': results,
'summary': {
'headline': headline,
'total_yogas': total,
'next_action': '把 D9 用于关系/内在成熟,D10 用于事业兑现,D12 用于家族与父母主题;不要把分盘 Yoga 当作单点结论。',
},
}
def _compute_deep_varga_avastha(self, body):
_, planet_lons, _ = self._normalized_planets_from_body(body)
if not planet_lons:
raise BadRequest('planets must include longitude data')
asc_lon = self._asc_lon_from_body(body)
report = _load_local_module('deep_varga_avastha').build_deep_varga_avastha_report(
planet_lons,
asc_lon=asc_lon,
)
return {
'success': True,
'endpoint': 'deep_varga_avastha',
'report': report,
}
def _compute_kakshya(self, body):
normalized, _, asc_sign_idx = self._normalized_planets_from_body(body)
if not normalized:
raise BadRequest('planets must include longitude data')
result = _load_local_module('kakshya').calc_kakshya_scores(normalized, asc_sign_idx)
planets = result.get('planets', {})
strongest = sorted(
planets.items(),
key=lambda item: item[1].get('kakshya_strength', 0),
reverse=True,
)[:3]
weakest = sorted(
planets.items(),
key=lambda item: item[1].get('kakshya_strength', 0),
)[:3]
avg = sum(item.get('kakshya_strength', 0) for item in planets.values()) / max(len(planets), 1)
headline = 'Kakshya 度数层支持较强' if avg >= 6.5 else 'Kakshya 度数层需要谨慎使用'
return {
'success': True,
'endpoint': 'kakshya',
'ascendant': SIGNS[asc_sign_idx],
'result': result,
'summary': {
'headline': headline,
'average_strength': round(avg, 2),
'strongest': [{'planet': name, **data} for name, data in strongest],
'weakest': [{'planet': name, **data} for name, data in weakest],
'next_action': '把 Kakshya 用作 Ashtakavarga/Transit 的度数级触发层,只在已有 Dasha 或主题承诺时提高事件窗口权重。',
},
}
def _compute_bhava_bala_api(self, body):
normalized, _, asc_sign_idx = self._normalized_planets_from_body(body)
if not normalized:
raise BadRequest('planets must include longitude data')
asc_lon = self._asc_lon_from_body(body)
asc_sign = SIGNS[asc_sign_idx]
asc_degree = asc_lon % 30
house_signs = [SIGNS[(asc_sign_idx + i) % 12] for i in range(12)]
house_degrees = [asc_degree for _ in range(12)]
planet_shadbala = self._planet_shadbala_from_body(body, normalized)
result = _load_local_module('bhava_bala').calc_bhava_bala(
house_signs,
house_degrees,
asc_sign,
asc_degree,
normalized,
planet_shadbala,
)
houses = result.get('houses', [])
strongest = sorted(houses, key=lambda item: item.get('total', 0), reverse=True)[:3]
weakest = sorted(houses, key=lambda item: item.get('total', 0))[:3]
headline = '宫位力量结构清晰' if strongest and strongest[0].get('total', 0) >= 45 else '宫位力量偏分散'
return {
'success': True,
'endpoint': 'bhava_bala',
'ascendant': asc_sign,
'result': result,
'summary': {
'headline': headline,
'strongest': strongest,
'weakest': weakest,
'next_action': '优先阅读最强宫位对应的人生领域;最弱宫位只作为风险提示,需要结合宫主、Dasha 与 Transit 确认。',
},
}
def _planet_shadbala_from_body(self, body, planets):
raw = body.get('planet_shadbala') or body.get('shadbala') or {}
result = {}
if isinstance(raw, dict):
for planet, value in raw.items():
if isinstance(value, dict):
number = value.get('virupas', value.get('total_virupas', value.get('score', value.get('rupas'))))
else:
number = value
try:
result[planet] = float(number)
except (TypeError, ValueError):
continue
for planet in ['Sun', 'Moon', 'Mars', 'Mercury', 'Jupiter', 'Venus', 'Saturn']:
if planet in result:
continue
pdata = planets.get(planet, {})
dignity = self._case_dignity_label(planet, pdata.get('sign'))
score = 35.0
if dignity in {'exalted', 'own_sign'}:
score = 55.0
elif dignity == 'strong':
score = 45.0
elif dignity == 'debilitated':
score = 24.0
if pdata.get('house') in (1, 4, 7, 10):
score += 5.0
result[planet] = score
return result
def _compute_transit_triggers(self, body):
start_raw = body.get('start', body.get('start_date', datetime.now().strftime('%Y-%m-%d')))
end_raw = body.get('end', body.get('end_date'))
if not isinstance(start_raw, str):
raise BadRequest('start must be YYYY-MM-DD')
if end_raw is not None and not isinstance(end_raw, str):
raise BadRequest('end must be YYYY-MM-DD')
try:
start_date = datetime.strptime(start_raw[:10], '%Y-%m-%d')
end_date = datetime.strptime((end_raw or (start_date + timedelta(days=90)).strftime('%Y-%m-%d'))[:10], '%Y-%m-%d')
except ValueError as e:
raise BadRequest('start/end must be YYYY-MM-DD') from e
if end_date < start_date:
raise BadRequest('end must be after start')
if (end_date - start_date).days > 730:
raise BadRequest('transit search range must be <= 730 days')
planets_to_check = body.get('planets_to_check')
raw_planets = body.get('natal_planets', body.get('chart_planets'))
if raw_planets is None and isinstance(body.get('planets'), dict):
raw_planets = body.get('planets')
elif raw_planets is None and isinstance(body.get('planets'), list):
planets_to_check = body.get('planets')
raw_planets = {}
if planets_to_check is not None:
if not isinstance(planets_to_check, list):
raise BadRequest('planets_to_check must be an array')
planets_to_check = [str(item) for item in planets_to_check[:9]]
planets = self._validate_planets(raw_planets or {})
body_for_asc = {**body, 'planets': planets}
asc_lon = self._asc_lon_from_body(body_for_asc)
natal_data = {'asc': asc_lon, 'planets': planets}
result = _load_local_module('transit_trigger').search_all_transit_triggers(
natal_data,
start_date,
end_date,
planets_to_check=planets_to_check,
)
top_triggers = result.get('triggers', [])[:6]
headline = '发现可观察过境触发点' if result.get('total_triggers', 0) else '当前区间未发现精确触发'
return {
'success': True,
'endpoint': 'transit',
'result': result,
'triggers': result.get('triggers', []),
'summary': {
'headline': headline,
'period': result.get('search_period', {}),
'total_triggers': result.get('total_triggers', 0),
'top_triggers': top_triggers,
'next_action': '把过境触发作为时间窗口,不单独定事件;优先与 Dasha、Ashtakavarga、Kakshya 和本命承诺交叉确认。',
},
}
def _compute_bhava_chalit(self, body):
_, planet_lons = self._planet_lons_from_body(body)
if not planet_lons:
raise BadRequest('planets must include longitude data')
ascendant = body.get('ascendant', {})
if not isinstance(ascendant, dict):
raise BadRequest('ascendant must be an object')
asc_lon = ascendant.get('lon')
if asc_lon is None and ascendant.get('sign') in SIGNS:
asc_lon = SIGNS.index(ascendant['sign']) * 30 + float(ascendant.get('degree_in_sign', ascendant.get('degree', 0)))
if asc_lon is None:
asc_lon = self._normalize_degree(body, 'asc_lon', 0)
else:
asc_lon = float(asc_lon) % 360
mc_lon = self._normalize_degree(body, 'mc_lon', (asc_lon + 270) % 360)
house_system = body.get('house_system', 'sripati')
if not isinstance(house_system, str):
raise BadRequest('house_system must be a string')
requested_house_system = house_system.lower().strip()
mode = body.get('mode', 'compare')
if not isinstance(mode, str):
raise BadRequest('mode must be a string')
calculator = _load_local_module('bhava_chalit').BhavaChalitCalculator()
available_house_systems = sorted(calculator.HOUSE_SYSTEMS.keys())
if requested_house_system not in calculator.HOUSE_SYSTEMS:
raise BadRequest(f'unknown house_system: {house_system}')
selected_house_system = requested_house_system
jd = lat = lon = None
fallback_reason = ''
calculation_note = f'Bhava Chalit uses {calculator.HOUSE_SYSTEMS[selected_house_system]}.'
if selected_house_system in {'placidus', 'koch'}:
try:
year = self._get_int(body, 'year', 1990, 1800, 2400)
month = self._get_int(body, 'month', 6, 1, 12)
day = self._get_int(body, 'day', 15, 1, 31)
hour = self._get_float(body, 'hour', 12, 0, 23)
minute = self._get_float(body, 'minute', 0, 0, 59)
lat = self._get_float(body, 'lat', body.get('birth_lat', 0), -90, 90)
lon = self._get_float(body, 'lon', body.get('birth_lon', 0), -180, 180)
tz = self._get_float(body, 'tz', body.get('birth_tz', 0), -14, 14)
datetime(year, month, day)
import swisseph as swe
hour_ut = hour + minute / 60.0 - tz
jd = swe.julday(year, month, day, hour_ut)
calculation_note = f'{selected_house_system} cusps use swisseph houses with birth JD and location.'
except Exception as exc:
fallback_reason = f'{selected_house_system} requires swisseph plus valid birth date, lat, lon and tz; fell back to sripati: {exc}'
selected_house_system = 'sripati'
jd = lat = lon = None
calculation_note = 'Fallback to Sripati because time-based house cusps were unavailable.'
if mode == 'chart':
result = calculator.get_bhava_chalit_chart(planet_lons, asc_lon, mc_lon, selected_house_system, jd, lat, lon)
elif mode == 'boundaries':
result = calculator.calculate_bhava_boundaries(asc_lon, mc_lon, selected_house_system, jd, lat, lon)
else:
result = calculator.compare_rashi_vs_bhava(planet_lons, asc_lon, mc_lon, selected_house_system, jd, lat, lon)
result.setdefault('summary', {
'total_planets': len(planet_lons),
'shifted_count': result.get('shifted_count', 0),
'shifted_names': [shift.get('planet') for shift in result.get('shifts', []) if isinstance(shift, dict)],
})
result['requested_house_system'] = requested_house_system
result['selected_house_system'] = selected_house_system
result['available_house_systems'] = available_house_systems
result['fallback_reason'] = fallback_reason
result['calculation_note'] = calculation_note
return {
'success': True,
'endpoint': 'bhava_chalit',
'mode': mode,
'requested_house_system': requested_house_system,
'selected_house_system': selected_house_system,
'available_house_systems': available_house_systems,
'fallback_reason': fallback_reason,
'calculation_note': calculation_note,
'result': result,
}
def _compute_sudarshana(self, body):
_, planet_lons = self._planet_lons_from_body(body)
if not {'Sun', 'Moon'} <= set(planet_lons):
raise BadRequest('planets must include Sun and Moon longitude data')
ascendant = body.get('ascendant', {})
if not isinstance(ascendant, dict):
raise BadRequest('ascendant must be an object')
asc_lon = ascendant.get('lon')
if asc_lon is None and ascendant.get('sign') in SIGNS:
asc_lon = SIGNS.index(ascendant['sign']) * 30 + float(ascendant.get('degree_in_sign', ascendant.get('degree', 0)))
if asc_lon is None:
asc_lon = self._normalize_degree(body, 'asc_lon', 0)
else:
asc_lon = float(asc_lon) % 360
house = body.get('house')
if house is not None:
house = self._get_int(body, 'house', 1, 1, 12)
result = _load_local_module('sudarshana_chakra').calc_sudarshana_chakra(planet_lons, asc_lon, house)
return {'success': True, 'endpoint': 'sudarshana', 'result': result}
def _compute_nakshatra_full(self, body):
chart_data, _ = self._chart_payload_from_body(body)
age = body.get('age')
if age is not None:
age = self._get_float(body, 'age', 0, 0, 150)
transit_date = body.get('transit_date')
if transit_date is not None and not isinstance(transit_date, str):
raise BadRequest('transit_date must be a string')
result = _load_local_module('nakshatra_advanced').nakshatra_full_report(
chart_data,
age=age,
transit_date=transit_date,
)
return {'success': True, 'endpoint': 'nakshatra_full', 'result': result}
def _capability_audit(self):
registry = self._read_technique_registry()
techniques = registry.get('techniques', {})
engine_commands = self._scan_engine_commands()
api_endpoints = self._scan_api_endpoints()
app_tabs = self._scan_app_tabs()
local_sources = self._scan_local_open_source_sources()
command_set = set(engine_commands)
api_command_map = API_COMMAND_MAP
api_backed_commands = sorted(
command
for command, endpoint in api_command_map.items()
if endpoint in api_endpoints or command in command_set
)
app_visible_topics = self._app_visible_topics(app_tabs)
registry_commands = sorted({
command
for technique in techniques.values()
for command in technique.get('commands', [])
if isinstance(command, str)
})
registry_only_commands = sorted(set(registry_commands) - command_set)
engine_not_api = sorted(command_set - set(api_backed_commands))
status_counts = {}
domain_counts = {}
for technique in techniques.values():
status = technique.get('status', 'unknown')
status_counts[status] = status_counts.get(status, 0) + 1
for domain in technique.get('domains', []):
domain_counts[domain] = domain_counts.get(domain, 0) + 1
direct_sources = [s for s in local_sources if s.get('reuse') == 'direct']
caution_sources = [s for s in local_sources if s.get('reuse') == 'caution']
unknown_sources = [s for s in local_sources if s.get('reuse') == 'unknown']
priority_gaps = self._build_priority_gaps(techniques, engine_not_api, app_visible_topics)
productization = self._build_productization_matrix(
techniques,
command_set,
set(api_backed_commands),
app_visible_topics,
)
ux_productization = self._build_ux_productization_matrix(productization['rows'])
return {
'success': True,
'generated_at': datetime.utcnow().isoformat(timespec='seconds') + 'Z',
'registry': {
'version': registry.get('version'),
'technique_count': len(techniques),
'status_counts': status_counts,
'domain_counts': dict(sorted(domain_counts.items())),
'source_inspiration': registry.get('source_inspiration', []),
},
'techniques': [
{
'id': key,
'name': value.get('name', key),
'status': value.get('status', 'unknown'),
'domains': value.get('domains', []),
'commands': value.get('commands', []),
'output_paths': value.get('output_paths', []),
'audit_label': value.get('audit_label', value.get('name', key)),
'limitation': value.get('limitation', ''),
'missing_impact': value.get('missing_impact', ''),
}
for key, value in sorted(techniques.items())
],
'surfaces': {
'engine_command_count': len(engine_commands),
'engine_commands': engine_commands,
'api_endpoint_count': len(api_endpoints),
'api_endpoints': api_endpoints,
'api_backed_commands': api_backed_commands,
'engine_not_api': engine_not_api,
'registry_only_commands': registry_only_commands,
'app_tab_count': len(app_tabs),
'app_tabs': app_tabs,
'app_visible_topics': app_visible_topics,
},
'local_open_source': {
'source_count': len(local_sources),
'direct_reuse_count': len(direct_sources),
'caution_count': len(caution_sources),
'unknown_count': len(unknown_sources),
'sources': local_sources,
},
'external_research': self._external_research_matrix(),
'priority_gaps': priority_gaps,
'productization': productization,
'ux_productization': ux_productization,
}
def _technique_catalog(self):
audit = self._capability_audit()
rows = self._build_technique_catalog_rows(audit)
domains = sorted({
domain
for row in rows
for domain in row.get('domains', [])
})
endpoints = sorted({
endpoint
for row in rows
for endpoint in row.get('api_endpoints', [])
})
return {
'success': True,
'generated_at': audit.get('generated_at'),
'registry': audit.get('registry', {}),
'summary': {
'technique_count': len(rows),
'domain_count': len(domains),
'api_endpoint_count': len(endpoints),
'runnable_count': sum(1 for row in rows if row.get('runnable')),
},
'filters': {
'domains': domains,
'levels': ['productized', 'api_backed', 'engine_or_full_reading', 'registry_only'],
'statuses': sorted({row.get('status') for row in rows if row.get('status')}),
'api_endpoints': endpoints,
},
'api_command_map': API_COMMAND_MAP,
'example_payloads': self._technique_example_payloads(),
'api_docs': self._technique_api_docs(),
'techniques': rows,
}
def _build_technique_catalog_rows(self, audit):
product_rows = {
row.get('id'): row
for row in audit.get('productization', {}).get('rows', [])
if row.get('id')
}
ux_rows = {
row.get('id'): row
for row in audit.get('ux_productization', {}).get('rows', [])
if row.get('id')
}
api_endpoints = set(audit.get('surfaces', {}).get('api_endpoints', []))
rows = []
for technique in audit.get('techniques', []):
product = product_rows.get(technique.get('id'), {})
ux = ux_rows.get(technique.get('id'), {})
commands = sorted(set((technique.get('commands') or []) + (product.get('commands') or [])))
mapped = [
API_COMMAND_MAP.get(command)
for command in commands
if API_COMMAND_MAP.get(command) in api_endpoints
]
runnable = [endpoint for endpoint in mapped if endpoint in TECHNIQUE_EXAMPLE_ENDPOINTS]
primary_endpoint = self._primary_catalog_endpoint(technique, mapped, runnable)
rows.append({
'id': technique.get('id'),
'name': technique.get('name'),
'audit_label': technique.get('audit_label'),
'status': technique.get('status'),
'domains': sorted(set((technique.get('domains') or []) + (product.get('domains') or []))),
'commands': commands,
'api_commands': product.get('api_commands', []),
'api_endpoints': sorted(set(mapped)),
'runnable': bool(runnable),
'example_endpoint': runnable[0] if runnable else '',
'method_docs': self._technique_method_doc(technique, product, primary_endpoint),
'output_paths': sorted(set((technique.get('output_paths') or []) + (product.get('output_paths') or []))),
'level': product.get('level', 'registry_only'),
'reason': product.get('reason') or technique.get('limitation') or '',
'next_action': product.get('next_action') or ux.get('ux_next_action') or technique.get('missing_impact') or '',
'ux_level': ux.get('ux_level', 'not_user_ready'),
'ux_score': ux.get('ux_score', 0),
'visible_markers': product.get('visible_markers', []),
})
order = {'registry_only': 0, 'engine_or_full_reading': 1, 'api_backed': 2, 'productized': 3}
return sorted(rows, key=lambda row: (order.get(row.get('level'), 0), row.get('name') or ''))
def _primary_catalog_endpoint(self, technique, mapped, runnable):
candidates = runnable or mapped or []
if not candidates:
return ''
text = ' '.join([
str(technique.get('id', '')),
str(technique.get('name', '')),
str(technique.get('audit_label', '')),
' '.join(technique.get('domains') or []),
]).lower()
endpoint_keywords = [
('/api/thematic_report', ['thematic', '主题', 'report_orchestrator', 'reading', 'report']),
('/api/relationship', ['relationship', 'spouse', '婚姻', '感情']),
('/api/career', ['career', '事业']),
('/api/dasha', ['dasha', 'vimshottari']),
('/api/yogas', ['yoga', 'dosha']),
('/api/shadbala', ['shadbala']),
('/api/deep_varga_avastha', ['deep_varga_avastha', 'deep varga', 'avastha', 'sayanadi', 'shayanadi', 'd24', 'd30', 'd60']),
('/api/ashtakavarga', ['ashtakavarga']),
('/api/jaimini', ['jaimini', 'karaka', 'arudha']),
]
for endpoint, keywords in endpoint_keywords:
if endpoint in candidates and any(keyword in text for keyword in keywords):
return endpoint
return candidates[0]
def _compute_technique_example(self, body):
endpoint = body.get('endpoint')
if not isinstance(endpoint, str):
raise BadRequest('endpoint must be a string')
endpoint = endpoint.strip()
if endpoint not in TECHNIQUE_EXAMPLE_ENDPOINTS:
raise BadRequest('endpoint is not runnable from technique explorer')
payloads = self._technique_example_payloads()
payload = body.get('payload')
if payload is None:
payload = payloads.get(endpoint)
if not isinstance(payload, dict):
raise BadRequest('payload must be an object')
payload = self._sanitize_technique_example_payload(endpoint, payload)
result = self._dispatch_technique_endpoint(endpoint, payload)
return {
'success': True,
'endpoint': 'technique_example',
'target_endpoint': endpoint,
'sample_payload': payload,
'result': result,
}
def _dispatch_technique_endpoint(self, endpoint, payload):
dispatch = {
'/api/ashtakavarga': self._compute_ashtakavarga,
'/api/bhava_bala': self._compute_bhava_bala_api,
'/api/bhava_chalit': self._compute_bhava_chalit,
'/api/career': self._compute_career,
'/api/case_validation': self._compute_case_validation,
'/api/dasha': self._compute_dasha_system,
'/api/deep_varga_avastha': self._compute_deep_varga_avastha,
'/api/divisional_yoga': self._compute_divisional_yoga,
'/api/jaimini': self._compute_jaimini,
'/api/kakshya': self._compute_kakshya,
'/api/kp': self._compute_kp,
'/api/muhurta': self._compute_muhurta,
'/api/nakshatra_full': self._compute_nakshatra_full,
'/api/pancha_mahapurusha': self._compute_pmc,
'/api/prashna': self._compute_prashna,
'/api/rectification_gate': self._compute_rectification_gate,
'/api/relationship': self._compute_relationship,
'/api/remedies': self._compute_remedies,
'/api/sade_sati': self._compute_sade_sati,
'/api/shadbala': self._compute_shadbala,
'/api/sudarshana': self._compute_sudarshana,
'/api/synastry': self._compute_synastry,
'/api/thematic_report': self._compute_thematic_report,
'/api/transit': self._compute_transit_triggers,
'/api/varga_full': self._compute_varga_full,
'/api/yogas': self._compute_yogas_api,
}
return dispatch[endpoint](payload)
def _sanitize_technique_example_payload(self, endpoint, payload):
text = json.dumps(payload, ensure_ascii=False, default=str)
if len(text) > 120_000:
raise BadRequest('technique example payload too large')
if endpoint == '/api/transit':
start_raw = str(payload.get('start', payload.get('start_date', datetime.now().strftime('%Y-%m-%d'))))[:10]
try:
start = datetime.strptime(start_raw, '%Y-%m-%d')
except ValueError as e:
raise BadRequest('transit start must be YYYY-MM-DD') from e
end = payload.get('end') or payload.get('end_date')
if end:
try:
end_dt = datetime.strptime(str(end)[:10], '%Y-%m-%d')
except ValueError as e:
raise BadRequest('transit end must be YYYY-MM-DD') from e
if (end_dt - start).days > 180:
payload = {**payload, 'end': (start + timedelta(days=180)).strftime('%Y-%m-%d')}
else:
payload = {**payload, 'end': (start + timedelta(days=90)).strftime('%Y-%m-%d')}
return payload
def _technique_api_docs(self):
docs = {}
payloads = self._technique_example_payloads()
for endpoint in sorted(TECHNIQUE_EXAMPLE_ENDPOINTS):
payload = payloads.get(endpoint, {})
docs[endpoint] = {
'method': 'POST',
'endpoint': endpoint,
'curl': self._curl_example(endpoint, payload),
'openapi': self._openapi_operation(endpoint, payload),
'notes': self._endpoint_method_notes(endpoint),
}
return docs
def _technique_method_doc(self, technique, product, endpoint):
return {
'summary': product.get('reason') or technique.get('limitation') or technique.get('audit_label') or technique.get('name') or '',
'boundary': product.get('next_action') or technique.get('missing_impact') or '把该技法作为证据层使用,并结合 Dasha、Transit、案例验证共同收敛。',
'primary_endpoint': endpoint,
'source_paths': sorted(set((technique.get('output_paths') or []) + (product.get('output_paths') or [])))[:8],
'api_doc_key': endpoint if endpoint in TECHNIQUE_EXAMPLE_ENDPOINTS else '',
}
def _curl_example(self, endpoint, payload):
body = json.dumps(payload or {}, ensure_ascii=False, indent=2, sort_keys=True)
return (
"curl -sS -X POST http://127.0.0.1:5200"
f"{endpoint} \\\n"
" -H 'Content-Type: application/json' \\\n"
" --data-binary "
+ json.dumps(body, ensure_ascii=False)
)
def _openapi_operation(self, endpoint, payload):
return {
'path': endpoint,
'post': {
'summary': self._endpoint_summary(endpoint),
'operationId': self._endpoint_operation_id(endpoint),
'requestBody': {
'required': True,
'content': {
'application/json': {
'schema': {
'type': 'object',
'additionalProperties': True,
'example': payload or {},
},
},
},
},
'responses': {
'200': {
'description': 'Successful Jyotish calculation result',
'content': {
'application/json': {
'schema': {'type': 'object', 'additionalProperties': True},
},
},
},
'400': {'description': 'Invalid payload or unsupported option'},
},
},
}
def _endpoint_operation_id(self, endpoint):
return 'post' + ''.join(part.title() for part in endpoint.strip('/').replace('_', '-').split('-'))
def _endpoint_summary(self, endpoint):
labels = {
'/api/ashtakavarga': 'Compute SAV/BAV/PAV/Sodhita Ashtakavarga evidence',
'/api/bhava_bala': 'Compute Bhava Bala house strength evidence',
'/api/bhava_chalit': 'Compare Rashi and Bhava Chalit house placements',
'/api/career': 'Compute career analysis from planets and ascendant',
'/api/case_validation': 'Run case validation and MEVG gate status',
'/api/dasha': 'Compute Dasha periods and Vimshottari analysis layer',
'/api/deep_varga_avastha': 'Compute Sayanadi/Shayanadi avastha and D24/D30/D60 templates',
'/api/divisional_yoga': 'Detect D9/D10/D12 divisional yogas',
'/api/jaimini': 'Compute Jaimini karaka, arudha, dasha, and karakamsha data',
'/api/kakshya': 'Compute Kakshya degree-level trigger support',
'/api/kp': 'Compute KP significator and sublord analysis',
'/api/muhurta': 'Compute Muhurta day quality evidence',
'/api/nakshatra_full': 'Compute advanced Nakshatra report',
'/api/pancha_mahapurusha': 'Assess Pancha Mahapurusha yoga strength',
'/api/prashna': 'Compute Prashna chart and answer evidence',
'/api/rectification_gate': 'Evaluate birth-time precision gate',
'/api/relationship': 'Compute relationship and spouse-status evidence',
'/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha',
'/api/sade_sati': 'Compute Sade Sati status and phase',
'/api/shadbala': 'Compute Shadbala plus advanced evidence layer',
'/api/sudarshana': 'Compute Sudarshana Chakra evidence',
'/api/synastry': 'Compute 16-factor/Ashtakoot compatibility score',
'/api/thematic_report': 'Generate thematic report with sample/custom/derived evidence',
'/api/transit': 'Search transit trigger windows',
'/api/varga_full': 'Compute divisional chart positions',
'/api/yogas': 'Compute yoga rule-engine and curse-yoga evidence',
}
return labels.get(endpoint, f'Run {endpoint} calculation')
def _endpoint_method_notes(self, endpoint):
notes = {
'/api/thematic_report': '传 birth/chart payload 时会进入 derived_chart_evidence;只传 theme 时使用样例证据。',
'/api/shadbala': 'advanced_layer 是证据补充,不覆盖主 Shadbala 总分。',
'/api/yogas': 'curse_yogas 是高风险提示层,不能替代健康/法律/安全建议。',
'/api/case_validation': 'MEVG 只读门控不运行外部子进程。',
'/api/report_artifact': '报告 artifact 不在 Technique Explorer 白名单内,避免把任意 HTML 当作样例执行。',
}
return notes.get(endpoint, '样例 payload 可直接复制到本地 API;正式报告仍需结合上下文和边界说明。')
def _technique_example_payloads(self):
today = datetime.utcnow().strftime('%Y-%m-%d')
transit_end = (datetime.utcnow() + timedelta(days=60)).strftime('%Y-%m-%d')
base = {
'planets': SAMPLE_PLANETS,
'ascendant': SAMPLE_ASCENDANT,
}
birth = {
'year': 1990,
'month': 1,
'day': 1,
'hour': 12,
'minute': 0,
'lat': 28.6,
'lon': 77.2,
'tz': 5.5,
}
return {
'/api/ashtakavarga': base,
'/api/bhava_bala': base,
'/api/bhava_chalit': {**base, 'mode': 'compare', 'house_system': 'sripati'},
'/api/career': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries'},
'/api/case_validation': {**base, 'current_md': 'Jupiter', 'predicted_events': ['事业巅峰', '关系发展'], 'transit_desc': 'Double Jupiter Saturn activation'},
'/api/dasha': {**base, **birth, 'dasha': 'vimshottari'},
'/api/deep_varga_avastha': base,
'/api/divisional_yoga': {**base, 'divisions': ['D9', 'D10', 'D12']},
'/api/jaimini': {**base, **birth, 'mode': 'all'},
'/api/kakshya': base,
'/api/kp': {'planets': SAMPLE_PLANETS, 'asc_sign_idx': 0},
'/api/muhurta': {'date': today, 'activity': 'business', 'hour_from_sunrise': 6.0},
'/api/nakshatra_full': {**base, 'age': 36, 'transit_date': today},
'/api/pancha_mahapurusha': {'planets': SAMPLE_PLANETS, 'sun_degree': SAMPLE_PLANETS['Sun']['lon']},
'/api/prashna': {'planets': SAMPLE_PLANETS, 'question': 'general'},
'/api/rectification_gate': {**base, 'declared_accuracy': 'minute', 'time_source': 'family_clear'},
'/api/relationship': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries', 'dasha_info': {'maha_dasha': 'Venus', 'antar_dasha': 'Jupiter'}},
'/api/remedies': {'shadbala': {'Sun': {'rupas': 4.1}, 'Moon': {'rupas': 3.8}}, 'doshas': ['manglik'], 'dasha_lord': 'Venus'},
'/api/sade_sati': {'moon_degree': SAMPLE_PLANETS['Moon']['lon'], 'asc_degree': SAMPLE_ASCENDANT['lon'], 'saturn_degree': SAMPLE_PLANETS['Saturn']['lon']},
'/api/shadbala': {**base, **birth},
'/api/sudarshana': base,
'/api/synastry': {'male_moon': SAMPLE_PLANETS['Moon']['lon'], 'female_moon': 243.0},
'/api/thematic_report': {'theme': 'marriage'},
'/api/transit': {'natal_planets': SAMPLE_PLANETS, 'ascendant': SAMPLE_ASCENDANT, 'start': today, 'end': transit_end, 'planets_to_check': ['Saturn', 'Jupiter', 'Rahu', 'Ketu']},
'/api/varga_full': {**base, 'divisions': ['D9', 'D10']},
'/api/yogas': base,
}
def _read_technique_registry(self):
path = os.path.join(REPO_ROOT, 'references', 'technique_registry.json')
try:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
return data
except (OSError, json.JSONDecodeError):
pass
return {'version': None, 'techniques': {}}
def _scan_engine_commands(self):
path = os.path.join(SCRIPTS_DIR, 'jyotish_engine.py')
try:
with open(path, 'r', encoding='utf-8') as f:
text = f.read()
except OSError:
return []
return sorted(set(re.findall(r"add_parser\(['\"]([^'\"]+)['\"]", text)))
def _scan_api_endpoints(self):
path = os.path.abspath(__file__)
try:
with open(path, 'r', encoding='utf-8') as f:
text = f.read()
except OSError:
return []
return sorted(set(re.findall(r"path == ['\"](/api/[^'\"]+)['\"]", text)))
def _scan_app_tabs(self):
path = os.path.join(REPO_ROOT, 'jyotish-app', 'index.html')
try:
with open(path, 'r', encoding='utf-8') as f:
text = f.read()
except OSError:
return []
return sorted(set(re.findall(r'data-tab="([^"]+)"', text)))
def _scan_app_source_text(self):
root = os.path.join(REPO_ROOT, 'jyotish-app')
if not os.path.isdir(root):
return ''
chunks = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [
d for d in dirnames
if d not in {'node_modules', 'dist', '.vite', '.git'}
]
for filename in filenames:
if not filename.endswith(('.js', '.html', '.css')):
continue
try:
with open(os.path.join(dirpath, filename), 'r', encoding='utf-8', errors='ignore') as f:
chunks.append(f.read(40000))
except OSError:
continue
return '\n'.join(chunks).lower()
def _scan_local_open_source_sources(self):
root = os.path.join(REPO_ROOT, 'references', 'open_source_sources')
if not os.path.isdir(root):
return []
sources = []
for name in sorted(os.listdir(root)):
path = os.path.join(root, name)
if not os.path.isdir(path):
continue
files = []
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if d not in {'.git', '__pycache__', '.pytest_cache'}]
for filename in filenames:
rel = os.path.relpath(os.path.join(dirpath, filename), path)
files.append(rel)
if len(files) >= 500:
break
if len(files) >= 500:
break
license_name = self._detect_source_license(path, files)
if license_name == 'unknown':
license_name = self._license_from_research_index(name)
sources.append({
'name': name,
'path': os.path.relpath(path, REPO_ROOT),
'file_count': len(files),
'license': license_name,
'reuse': self._reuse_level(license_name),
'modules': self._infer_source_modules(files),
'has_readme': any(os.path.basename(f).lower() == 'readme.md' for f in files),
'has_skill': any(os.path.basename(f).lower() == 'skill.md' for f in files),
})
return sources
def _detect_source_license(self, source_path, files):
license_files = [
f for f in files
if os.path.basename(f).lower() in {'license', 'license.md', 'licence', 'licence.md', 'copying'}
]
text = ''
for rel in license_files[:2]:
try:
with open(os.path.join(source_path, rel), 'r', encoding='utf-8', errors='ignore') as f:
text += '\n' + f.read(6000)
except OSError:
continue
haystack = (text + '\n' + source_path).lower()
if 'agpl' in haystack:
return 'AGPL'
if 'gpl' in haystack and 'lesser' not in haystack:
return 'GPL'
if 'apache license' in haystack or 'apache-2.0' in haystack:
return 'Apache-2.0'
if 'mit license' in haystack or '/mit' in haystack:
return 'MIT'
return 'unknown'
def _license_from_research_index(self, source_name):
scan_path = os.path.join(REPO_ROOT, 'references', 'open-source-jyotish-scan-2026.md')
integration_path = os.path.join(REPO_ROOT, 'references', 'open_source_sources', 'INTEGRATION_REPORT.md')
text = ''
for path in (scan_path, integration_path):
try:
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
text += '\n' + f.read()
except OSError:
continue
if not text:
return 'unknown'
escaped = re.escape(source_name)
patterns = [
rf'\|\s*\*\*{escaped}\*\*\s*\|[^|\n]*\|\s*(MIT|Apache-2\.0|AGPL|GPL)',
rf'{escaped}[^\n]{{0,80}}\((MIT|Apache-2\.0|AGPL|GPL)',
rf'{escaped}[^\n]{{0,80}}\|\s*(MIT|Apache-2\.0|AGPL|GPL)',
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
license_name = match.group(1)
if license_name.lower() == 'mit':
return 'MIT (research)'
if license_name.lower().startswith('apache'):
return 'Apache-2.0 (research)'
return f'{license_name.upper()} (research)'
return 'unknown'
def _reuse_level(self, license_name):
base_license = (license_name or '').split(' ', 1)[0]
if base_license in {'MIT', 'Apache-2.0'}:
return 'direct'
if base_license in {'AGPL', 'GPL'}:
return 'caution'
return 'unknown'
def _infer_source_modules(self, files):
module_keywords = {
'ashtakavarga': ['ashtakavarga'],
'shadbala': ['shadbala', 'strength'],
'dasha': ['dasha', 'dasa', 'dashas'],
'jaimini': ['jaimini', 'karaka', 'arudha'],
'kp': ['kp', 'sublord', 'horary'],
'prashna': ['prashna', 'prasna', 'horary'],
'muhurta': ['muhurta', 'muhurtha'],
'tajika': ['tajika', 'varsha', 'solar_return'],
'synastry': ['synastry', 'matchmaking', 'compat'],
'remedies': ['remedies', 'upaya'],
'panchanga': ['panchang', 'panchanga', 'tithi'],
'varga': ['varga', 'divisional'],
'yoga': ['yoga'],
'transit': ['transit', 'gochar'],
}
lower_files = [f.lower() for f in files]
found = []
for module, keywords in module_keywords.items():
if any(any(keyword in f for keyword in keywords) for f in lower_files):
found.append(module)
return found
def _app_visible_topics(self, tabs):
mapping = {
'chart': 'D1/Rashi',
'complete': 'Full Reading',
'karaka': 'Jaimini Karaka',
'houses': 'Bhava',
'aspects': 'Aspects',
'yogas': 'Yoga',
'vargas': 'Varga',
'ashtakavarga': 'Ashtakavarga',
'shadbala': 'Shadbala',
'dasha': 'Dasha',
'transit': 'Transit',
'deep': 'PACDARES/Argala',
'extended': 'Bhava Bala/Vimsopaka',
'remedies': 'Remedies',
'synastry': 'Synastry',
'prashna': 'Prashna',
'kp': 'KP',
'verify': 'Verification',
'transit-compare': 'Transit Compare',
}
topics = [mapping[t] for t in tabs if t in mapping]
source_text = self._scan_app_source_text()
source_markers = {
'Muhurta': ['computemuhurta', 'muhurta'],
'Tajika': ['computeannual', 'varshaphala', 'tajika'],
'Solar Return': ['computeannual', 'solar return', 'varshaphala'],
'Bhava Chalit': ['computebhavachalit', 'bhava chalit', 'bhava_chalit'],
'Sudarshana': ['computesudarshana', 'sudarshana'],
'Nakshatra Full': ['computenakshatrafull', 'nakshatra_full'],
'Varga Full': ['computevargafull', 'varga_full'],
'Jaimini': ['computejaimini', '/api/jaimini'],
'Ashtakavarga': ['computeashtakavarga', '/api/ashtakavarga'],
'Shadbala': ['computeshadbala', '/api/shadbala'],
'Yoga': ['computeyogas', '/api/yogas'],
'Aspects': ['computeaspects', '/api/aspects'],
'Birth Rectification': ['computerectificationgate', '/api/rectification_gate', 'rectification', 'rect-prompt'],
'Case Validation': ['computecasevalidation', '/api/case_validation', 'case validation', 'mevg'],
'Deep Varga Avastha': ['deepvargaavastha', '/api/deep_varga_avastha', 'sayanadi/shayanadi', 'd24/d30/d60'],
'Divisional Yoga': ['computedivisionalyoga', '/api/divisional_yoga', 'divisional yoga'],
'Kakshya': ['computekakshya', '/api/kakshya', 'kakshya'],
'Career': ['computecareer', '/api/career', 'career analysis', '事业分析'],
'Relationship': ['computerelationship', '/api/relationship', 'relationship analysis', '感情分析'],
'KP System': ['computekp', '/api/kp', 'kp sublord', 'kp分析'],
'Prashna': ['computeprashna', '/api/prashna', 'prashna', '问事'],
'Synastry 16-factor': ['computesynastry', '/api/synastry', 'ashtakoot', '合盘'],
'Bhava Bala': ['computebhavabala', '/api/bhava_bala', 'bhava bala'],
'Transit Trigger': ['computetransittriggers', '/api/transit', 'transit trigger', '过境触发'],
'Thematic Report': ['computethematicreport', '/api/thematic_report', 'thematic report', '主题化报告'],
}
for topic, markers in source_markers.items():
if any(marker in source_text for marker in markers) and topic not in topics:
topics.append(topic)
return topics
def _build_priority_gaps(self, techniques, engine_not_api, app_visible_topics):
api_gap_commands = {'varga-full', 'bhava-chalit', 'sudarshana', 'tajika', 'solar-return', 'muhurta', 'nakshatra-full', 'audit-capabilities'}
gaps = []
for command in sorted(api_gap_commands & set(engine_not_api)):
related = [
value.get('name', key)
for key, value in techniques.items()
if command in value.get('commands', [])
][:4]
gaps.append({
'kind': 'engine_not_api',
'command': command,
'priority': 'high',
'reason': '引擎已有命令,但 Web API/用户端未直接承载完整工作流。',
'related_techniques': related,
})
visible_lower = ' '.join(app_visible_topics).lower()
for topic in ['muhurta', 'tajika', 'solar return', 'bhava chalit', 'sudarshana']:
if topic not in visible_lower:
gaps.append({
'kind': 'app_visibility',
'topic': topic,
'priority': 'medium',
'reason': '前端缺少独立入口或专题化展示,用户不容易发现已有能力。',
})
return gaps[:12]
def _build_productization_matrix(self, techniques, engine_commands, api_backed_commands, app_visible_topics):
app_text = ' '.join(app_visible_topics).lower()
productized_markers = {
'd1': ['d1', 'rashi', 'chart', '本命盘'],
'd9': ['d9', 'navamsa', 'varga', '分盘'],
'd10': ['d10', 'dasamsa', 'varga'],
'varga': ['varga', '分盘'],
'jaimini': ['jaimini', 'karaka', 'arudha'],
'karaka': ['karaka', 'jaimini'],
'arudha': ['arudha', 'jaimini'],
'ashtakavarga': ['ashtakavarga'],
'shadbala': ['shadbala'],
'yoga': ['yoga', '格局'],
'dosha': ['dosha', 'remedies'],
'dasha': ['dasha'],
'nakshatra': ['nakshatra'],
'muhurta': ['muhurta'],
'tajika': ['tajika', 'solar return', 'varshaphala'],
'annual': ['tajika', 'solar return', 'varshaphala'],
'bhava': ['bhava'],
'bhava-bala': ['bhava bala', 'bhava'],
'transit': ['transit'],
'transit-trigger': ['transit trigger', 'transit', '过境触发'],
'kp': ['kp'],
'prashna': ['prashna'],
'synastry': ['synastry'],
'relationship': ['relationship', 'synastry'],
'remedies': ['remedies'],
'aspects': ['aspects'],
'sudarshana': ['sudarshana'],
'career': ['career'],
'marriage': ['navamsa', 'synastry', 'relationship'],
'birth': ['birth rectification', 'rectification', '生时校正'],
'rectification': ['birth rectification', 'rectification', '生时校正'],
'case': ['case validation', 'mevg', '验证'],
'validation': ['case validation', 'mevg', '验证'],
'misconceptions': ['case validation', 'mevg', '验证'],
'muhurtha': ['muhurta'],
'varshaphala': ['tajika', 'solar return', 'varshaphala'],
'deep-varga-avastha': ['deep varga avastha', 'deep varga', 'd24/d30/d60'],
'avastha': ['deep varga avastha', 'sayanadi/shayanadi'],
'd24': ['d24/d30/d60'],
'd30': ['d24/d30/d60'],
'd60': ['d24/d30/d60'],
'divisional': ['divisional yoga', 'varga', '分盘'],
'kakshya': ['kakshya', 'ashtakavarga'],
}
api_only_commands = sorted(api_backed_commands - {'chart', 'full-reading'})
rows = []
summary = {
'productized': 0,
'api_backed': 0,
'engine_or_full_reading': 0,
'registry_only': 0,
}
for key, technique in sorted(techniques.items()):
commands = [c for c in technique.get('commands', []) if isinstance(c, str)]
domains = [d for d in technique.get('domains', []) if isinstance(d, str)]
output_paths = technique.get('output_paths', [])
inferred_commands = self._inferred_commands_for_technique(key, technique)
all_commands = sorted(set(commands + inferred_commands))
api_commands = [command for command in commands if command in api_backed_commands]
api_commands = sorted(set(api_commands + [command for command in inferred_commands if command in api_backed_commands]))
engine_hits = [command for command in all_commands if command in engine_commands]
marker_terms = []
for token in [key, *domains, *all_commands]:
token_lower = token.lower()
marker_terms.extend(productized_markers.get(token_lower, []))
marker_terms.append(token_lower.replace('-', ' '))
marker_terms.append(token_lower.replace('_', ' '))
visible_markers = sorted({
marker
for marker in marker_terms
if marker and marker in app_text
})
if visible_markers and (api_commands or 'full-reading' in commands or output_paths):
level = 'productized'
reason = '前端已有可见入口,并且有 API/full-reading/输出路径承载。'
elif api_commands:
level = 'api_backed'
reason = '已有 Web API,但用户端需要更明确的专题解释或引导。'
elif engine_hits or 'full-reading' in commands or output_paths:
level = 'engine_or_full_reading'
reason = '引擎、完整解盘或输出路径已覆盖,但缺少独立产品化入口。'
else:
level = 'registry_only'
reason = '注册表中存在,但未自动识别到命令/API/前端入口。'
summary[level] += 1
rows.append({
'id': key,
'name': technique.get('name', key),
'level': level,
'reason': reason,
'domains': domains,
'commands': all_commands,
'api_commands': api_commands,
'output_paths': output_paths,
'visible_markers': visible_markers[:6],
'status': technique.get('status', 'unknown'),
'next_action': self._productization_next_action(level, all_commands, api_commands, visible_markers),
})
next_queue = [
row for row in rows
if row['level'] in {'api_backed', 'engine_or_full_reading', 'registry_only'}
]
priority_order = {'api_backed': 0, 'engine_or_full_reading': 1, 'registry_only': 2}
next_queue.sort(key=lambda row: (priority_order[row['level']], row['name']))
return {
'summary': summary,
'rows': rows,
'next_queue': next_queue[:18],
'api_only_commands': api_only_commands,
}
def _inferred_commands_for_technique(self, key, technique):
domains = set(technique.get('domains') or [])
name = str(technique.get('name', '')).lower()
path_text = ' '.join(str(path).lower() for path in technique.get('output_paths', []))
inferred = []
if key == 'career_engine' or 'career_analysis.py' in path_text or 'career' in domains:
inferred.append('career')
if key == 'relationship_engine' or 'relationship_analysis.py' in path_text or 'relationship' in domains:
inferred.append('relationship')
if key == 'kp_system' or 'kp_system.py' in path_text or 'kp' in domains:
inferred.append('kp')
if key == 'prashna' or 'prashna.py' in path_text or 'prashna' in domains:
inferred.append('prashna')
if key == 'synastry_16factor' or 'synastry.py' in path_text or 'synastry' in domains:
inferred.append('synastry')
if key == 'remedies' or 'remedies.py' in path_text or 'remedies' in domains:
inferred.append('remedies')
if key == 'bhava_bala' or 'bhava_bala.py' in path_text:
inferred.append('bhava-bala')
if key == 'transit_trigger' or 'transit_trigger.py' in path_text:
inferred.append('transit-trigger')
if key == 'thematic_report_orchestrator' or 'report_orchestrator.py' in path_text or {'report', 'reading'} & domains:
inferred.append('thematic-report')
if key == 'divisional_yoga' or 'divisional_yoga.py' in path_text:
inferred.append('divisional-yoga')
if key == 'deep_varga_avastha' or 'deep_varga_avastha.py' in path_text:
inferred.append('deep-varga-avastha')
if key == 'birth_time_rectifier' or 'birth_time_rectifier.py' in path_text:
inferred.append('rectification')
if key == 'case_validator' or 'case_validator.py' in path_text:
inferred.append('case-validation')
if key == 'kakshya' or 'kakshya.py' in path_text:
inferred.append('kakshya')
if key == 'muhurtha' or 'muhurtha' in domains or 'muhurtha_election.py' in path_text:
inferred.append('muhurta')
if key == 'varshaphala' or 'varshaphala' in domains or 'varshaphala.py' in path_text:
inferred.append('solar-return')
if 'ashtakavarga' in domains and 'prastara' in name:
inferred.append('ashtakavarga')
if 'ashtakavarga' in domains and 'sodhita' in name:
inferred.append('ashtakavarga')
return sorted(set(inferred))
def _productization_next_action(self, level, commands, api_commands, visible_markers):
if level == 'api_backed':
return '把 API 结果转成结论卡、证据卡和交叉验证提示。'
if level == 'engine_or_full_reading':
if any(command in {'transit', 'narayana-dasha', 'nakshatra-dasha', 'vivah-saham', 'transit-ll7l'} for command in commands):
return '优先 API 化,并加入高级技法工作台或专题 Tab。'
return '梳理到完整解盘主流程,补用户可见入口。'
if level == 'registry_only':
return '确认是否仍是有效技法;若有效,补命令/API/输出路径。'
if visible_markers:
return '继续优化解释层和移动端可读性。'
return '保持监控。'
def _build_ux_productization_matrix(self, product_rows):
rows = []
summary = {
'excellent': 0,
'usable': 0,
'thin': 0,
'not_user_ready': 0,
}
criteria_keys = [
'clear_entry',
'human_readable_conclusion',
'evidence_chain',
'next_action',
'json_hidden',
'mobile_scannable',
]
for row in product_rows:
criteria = self._ux_criteria_for_row(row)
score = sum(1 for key in criteria_keys if criteria.get(key))
if score >= 5:
ux_level = 'excellent'
elif score >= 4:
ux_level = 'usable'
elif score >= 2:
ux_level = 'thin'
else:
ux_level = 'not_user_ready'
summary[ux_level] += 1
missing = [key for key in criteria_keys if not criteria.get(key)]
rows.append({
**row,
'ux_score': score,
'ux_level': ux_level,
'criteria': criteria,
'missing_ux': missing,
'ux_next_action': self._ux_next_action(ux_level, missing, row),
})
queue = [row for row in rows if row['ux_level'] != 'excellent']
level_order = {'not_user_ready': 0, 'thin': 1, 'usable': 2, 'excellent': 3}
queue.sort(key=lambda row: (level_order[row['ux_level']], -row['ux_score'], row['name']))
return {
'criteria': criteria_keys,
'summary': summary,
'rows': rows,
'next_queue': queue[:18],
}
def _ux_criteria_for_row(self, row):
level = row.get('level')
commands = set(row.get('commands') or [])
api_commands = set(row.get('api_commands') or [])
domains = set(row.get('domains') or [])
visible_markers = set(row.get('visible_markers') or [])
name = row.get('name', '').lower()
row_id = row.get('id', '').lower()
has_api = bool(api_commands - {'full-reading'}) or any(
command in api_commands
for command in {'dasha', 'jaimini', 'ashtakavarga', 'shadbala', 'yoga', 'muhurta', 'solar-return', 'sudarshana', 'nakshatra-full', 'varga-full', 'rectification', 'case-validation', 'deep-varga-avastha', 'divisional-yoga', 'kakshya', 'career', 'relationship', 'kp', 'prashna', 'synastry', 'remedies', 'bhava-bala', 'transit-trigger', 'thematic-report'}
)
source_backed = level == 'productized' and bool(row.get('output_paths'))
clear_entry = level == 'productized' and bool(visible_markers)
human_readable = bool(
source_backed
or commands & {'full-reading', 'chart', 'dasha', 'jaimini', 'ashtakavarga', 'muhurta', 'solar-return', 'sudarshana', 'rectification', 'case-validation', 'deep-varga-avastha', 'divisional-yoga', 'kakshya', 'career', 'relationship', 'kp', 'prashna', 'synastry', 'remedies', 'bhava-bala', 'transit-trigger', 'thematic-report'}
or domains & {'core', 'relationship', 'marriage', 'career', 'remedies', 'muhurta', 'muhurtha', 'tajika', 'varshaphala', 'birth', 'case', 'misconceptions', 'divisional', 'kakshya', 'kp', 'prashna', 'synastry', 'ashtakavarga', 'bhava', 'transit', 'avastha', 'd24', 'd30', 'd60'}
or any(token in name for token in ['reading', 'analysis', 'report'])
)
evidence_chain = bool(
has_api
or source_backed
or commands & {'full-reading', 'varga-full', 'ashtakavarga', 'shadbala', 'jaimini', 'yoga', 'dasha', 'case-validation', 'deep-varga-avastha', 'divisional-yoga', 'kakshya', 'career', 'relationship', 'kp', 'prashna', 'synastry', 'remedies', 'bhava-bala', 'transit-trigger', 'thematic-report'}
or domains & {'strength', 'timing', 'varga', 'dasha', 'jaimini', 'ashtakavarga', 'remedies', 'birth', 'case', 'misconceptions', 'divisional', 'kakshya', 'kp', 'prashna', 'synastry', 'bhava', 'transit', 'avastha', 'd24', 'd30', 'd60'}
)
next_action = bool(
domains & {'remedies', 'muhurta', 'muhurtha', 'career', 'relationship', 'marriage', 'timing', 'event', 'birth', 'case', 'varshaphala', 'divisional', 'kakshya', 'kp', 'prashna', 'synastry', 'bhava', 'transit', 'avastha', 'd24', 'd30', 'd60'}
or row.get('next_action')
)
hidden_json_rows = {
'bhava_bala',
'birth_time_rectifier',
'career_engine',
'case_validator',
'deep_varga_avastha',
'divisional_yoga',
'kakshya',
'misconceptions',
'relationship_engine',
'remedies',
'transit_trigger',
'full_reading_strict',
}
json_hidden = level == 'productized' and (
bool(api_commands)
or row_id in hidden_json_rows
or (not row_id.endswith('_engine') and '.py' not in name)
)
mobile_scannable = clear_entry and (human_readable or evidence_chain)
return {
'clear_entry': clear_entry,
'human_readable_conclusion': human_readable,
'evidence_chain': evidence_chain,
'next_action': next_action,
'json_hidden': json_hidden,
'mobile_scannable': mobile_scannable,
}
def _ux_next_action(self, ux_level, missing, row):
if ux_level == 'excellent':
return '保持现有入口,继续做文案和移动端微调。'
if 'human_readable_conclusion' in missing:
return '把计算结果转成用户可读结论卡,并说明它支持哪个判断。'
if 'clear_entry' in missing:
return '补清晰入口或把该技法合并进完整解盘主流程。'
if 'evidence_chain' in missing:
return '补证据链:引用具体行星、宫位、分盘、Dasha 或相位。'
if 'json_hidden' in missing:
return '默认隐藏 JSON,只保留展开查看;主视图展示摘要。'
if 'mobile_scannable' in missing:
return '重排移动端卡片密度,保证三分钟内能读到结论。'
return row.get('next_action') or '补用户体验解释层。'
def _external_research_matrix(self):
return [
{
'name': 'PyJHora',
'url': 'https://github.com/naturalstupid/PyJHora',
'license': 'AGPL-3.0',
'reuse': 'benchmark_only',
'notes': '最强传统算法对标;许可证不适合直接复制进 MIT 仓库。',
'best_for': ['Dasha族', '分盘变体', 'Ashtakavarga高级层', 'JHora口径校准'],
},
{
'name': 'dashaflow',
'url': 'https://github.com/adarshj322/dashaflow',
'license': 'MIT',
'reuse': 'direct',
'notes': '本地已有源码;适合复用 Ashtakavarga、Shadbala、Jaimini、合盘、Muhurta 的轻量实现。',
'best_for': ['Ashtakavarga', 'Shadbala', 'Jaimini', 'Synastry', 'Muhurta'],
},
{
'name': 'jyotishganit',
'url': 'https://github.com/northtara/jyotishganit',
'license': 'MIT',
'reuse': 'direct',
'notes': '高精度结构化计算与 JSON-LD 输出思路,适合用于数据层校准。',
'best_for': ['D1-D60', 'Panchanga', 'Shadbala', 'JSON-LD'],
},
{
'name': 'VedicAstro',
'url': 'https://github.com/diliprk/VedicAstro',
'license': 'unspecified/readme badge',
'reuse': 'review_before_copy',
'notes': 'KP 与 Horary 很有价值,但复制前必须确认许可证文件。',
'best_for': ['KP Sublord', 'ABCD Significators', 'Horary'],
},
{
'name': 'VedAstro',
'url': 'https://github.com/VedAstro/VedAstro',
'license': 'MIT',
'reuse': 'direct_or_port',
'notes': 'C# API 平台范式,可参考 Web/API 产品化结构。',
'best_for': ['API Platform', 'Muhurta', 'Panchanga', 'AI Astrologer'],
},
{
'name': 'xalen-ephemeris',
'url': 'https://github.com/vedika-io/xalen-ephemeris',
'license': 'Apache-2.0',
'reuse': 'direct_or_port',
'notes': 'Rust 高精度星历与多传统系统,适合未来替代/补强天文底座。',
'best_for': ['Ephemeris', 'Ayanamsa', 'House Systems', 'KP/Jaimini/Tajika'],
},
]
def _parse_allowed_origins(value):
if not value:
return DEFAULT_ALLOWED_ORIGINS
return {item.strip() for item in value.split(',') if item.strip()}
def start_server(port=5200, host='127.0.0.1', allowed_origins=None):
server = HTTPServer((host, port), JyotishAPIHandler)
server.allowed_origins = allowed_origins or DEFAULT_ALLOWED_ORIGINS
print(f'Jyotish API v6.9.14 running on http://{host}:{port}')
print(f' CORS origins: {", ".join(sorted(server.allowed_origins))}')
print(f' POST /api/chart — 完整星盘计算')
print(f' POST /api/remedies — 补救建议')
print(f' POST /api/kp — KP分析')
print(f' POST /api/prashna — 卜卦')
print(f' POST /api/synastry — 合盘')
print(f' POST /api/dasha — 单Dasha时间线')
print(f' POST /api/sade_sati — 土星周期')
print(f' POST /api/pancha_mahapurusha — 五王瑜伽')
print(f' POST /api/career — 事业分析')
print(f' POST /api/relationship — 感情分析')
print(f' POST /api/annual — 年运/Tajika')
print(f' POST /api/muhurta — 择日')
print(f' POST /api/panchanga_range — Panchanga日期范围')
print(f' POST /api/bhava_chalit — Bhava Chalit')
print(f' POST /api/sudarshana — Sudarshana Chakra')
print(f' POST /api/nakshatra_full — Nakshatra深层报告')
print(f' POST /api/varga_full — BPHS扩展分盘/变体/自定义/复合')
print(f' POST /api/jaimini — Jaimini Karaka/Arudha/Chara Dasha')
print(f' POST /api/ashtakavarga — Ashtakavarga SAV/BAV')
print(f' POST /api/shadbala — Shadbala六重力量')
print(f' POST /api/yogas — Yoga格局检测')
print(f' POST /api/aspects — 精确相位')
print(f' POST /api/report_artifact — HTML/PDF报告工件生成')
print(f' POST /api/thematic_report — 主题化报告/冲突裁决')
print(f' GET /api/health — 健康检查')
print(f' GET /api/cities — 城市列表')
print(f' GET /api/capability_audit — 能力审计')
server.serve_forever()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Jyotish API server')
parser.add_argument('--port', type=int, default=5200)
parser.add_argument('--host', default=os.environ.get('JYOTISH_API_HOST', '127.0.0.1'))
parser.add_argument(
'--allow-origin',
action='append',
default=[],
help='Allowed browser origin; may be repeated. Defaults to local Vite origins.',
)
args = parser.parse_args()
env_origins = _parse_allowed_origins(os.environ.get('JYOTISH_ALLOWED_ORIGINS'))
cli_origins = set(args.allow_origin)
start_server(args.port, host=args.host, allowed_origins=cli_origins or env_origins)