harden calculation contracts and local API boundaries

This commit is contained in:
732642856
2026-07-11 23:24:54 +08:00
parent 4a268c75f2
commit a8f6a57ccc
17 changed files with 1075 additions and 157 deletions
+14
View File
@@ -64,6 +64,20 @@ For large architecture or release work, also read:
| ERR-031 | Premium skill zip can ship without user install prompts or replay schemas, leaving users and future oracle imports without a contract. | mitigated 2026-07-09 | `skill_release_package.py` must inject `INSTALL.md` and `USER_PROMPTS.md`; replay contracts must live in `references/real_case_calibration/` and `references/oracle/`. |
| ERR-032 | Full smoke files can time out while focused slices pass; `test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack` currently exposes `external_oracle_gap_summary=null`. | observed 2026-07-10 | Do not claim full `tests/test_cli_smoke.py` or full `tests/test_vedastro_external_technique_evidence.py` passed unless run to completion; use focused slices for related changes and track the prompt-pack gap separately. |
| ERR-033 | Premium skill zip validation can accidentally depend on a parent Git repository, so a cloud-drive user may fail in a clean unzip directory. | mitigated 2026-07-10 | Release acceptance must include `tests/test_skill_release_clean_trial.py`; scripts such as `public_release_privacy_scan.py` must support non-Git unpacked zip directories. |
| ERR-034 | A single `historical_event_backtest.build_report()` strict replay can exceed 120 seconds before returning a case result. | observed 2026-07-11 | Use `scripts/public_real_case_benchmark.py` for bounded batch evidence replay; keep strict workflow as a separately timed probe and report timeout as blocked. |
| ERR-035 | `benchmarks/jyotish/scripts/run_pyjhora_compare.py --help` executes the benchmark and crashes when canonical fixtures are absent. | observed 2026-07-11 | Do not claim PyJHora parity from readiness. Generate canonical fixtures or harden the runner before the next same-chart batch. |
| ERR-036 | `public_real_case_benchmark.py --rule-version compare` originally replayed both rule versions and exceeded the 120-second command budget. | mitigated 2026-07-11 | Compare mode must read precomputed `--comparison-v1` and `--comparison-v2` reports; never duplicate engine replay inside comparison. |
| ERR-037 | `scripts/muntha.py` failed at import because `List` was used in an annotation but not imported. | resolved 2026-07-11 | Keep `tests/test_muntha_module.py`; a technique file does not count as available unless it imports and runs independently. |
| ERR-038 | Real-case scoring counted the same planet twice when MD and AD had the same lord, inflating strong-hit scores and duplicating signals. | mitigated 2026-07-11 | V2.1 must deduplicate active lords before `_planet_score`; keep the same-MD/AD regression test and preserve legacy V2 reports for audit only. |
| ERR-039 | `exact_label_rate` looked like classification accuracy even though the benchmark already knew the event domain and assigned the expected label at the strong threshold. | mitigated 2026-07-11 | Use `known_event_activation_rate` and `strong_activation_rate`; keep old names deprecated and never present them as predictive accuracy. |
| ERR-040 | `.gitignore` excluded only parts of `scratch/`, leaving local helper files and `.serena/` visible to `git add .`. | resolved 2026-07-11 | Ignore `/scratch/` and `/.serena/` at repo root; keep a regression test for both private workspace directories. |
| ERR-041 | Positive-event replay scores were interpreted as timing evidence even though nearby non-target dates could receive equal or higher scores. | mitigated 2026-07-11 | Keep the negative-control date-ranking pilot and `timing_precision_gate`; block exact-day/month claims while Top-3 ranking remains below the gate. |
| ERR-042 | REST duplicated natal chart, Vimshottari and Sade Sati calculations, so True Node was ignored, the first Dasha balance drifted, and Saturn transit was fabricated. | resolved 2026-07-11 | Keep `tests/test_calculation_p0_regressions.py`; domain/CLI/REST must share `domain_calculation_service.py`, effective parameters and `result_hash`. |
| ERR-043 | Localhost POST requests trusted CORS response headers as an execution guard; report Chromium could load external/local resources; async job IDs were predictable and persisted without capability authentication or TTL. | mitigated 2026-07-11 | Keep `tests/test_runtime_security_p0.py`; enforce Origin/Host/JSON, sandbox report resources, use random capability tokens, `0600` atomic records, TTL deletion and a bounded worker queue. Run an isolated Chromium network PoC before declaring the renderer fully hardened. |
| ERR-044 | Focused selections that include legacy full chart API tests can still exceed the 120-second desktop command budget even after pure calculation tests pass. | observed 2026-07-11 | Keep P0 calculation/security tests pure and fast; profile the legacy chart fixture separately before using the full API file as a blocking CI gate. |
| ERR-045 | Three-engine readiness was mistaken for completed same-chart parity. Public replay on 2026-07-11 captured PyJHora and jyotishganit raw, but VedAstro returned `official_snapshot_budget_exhausted` with no raw response. | active external blocker | Keep `three_engine_parity_runner.py`; status remains `blocked`/`partial` until all required raw artifacts are normalized into comparison rows. |
| ERR-046 | Report-renderer SSRF/file PoC could not run because the Playwright Chromium binary was absent and installation exceeded the desktop outer timeout. | blocked environment | Keep route/JS-denial tests; rerun isolated HTTP/file PoC only after a verified Chromium installation, then update this ledger with the measured request count. |
| ERR-047 | Initial `slow` marker partition for `test_api_server_security.py` still exceeded the 120-second desktop budget; heavy paths extend beyond VedAstro/high-rigor prefix groups. | active profiling blocker | Profile test node IDs in bounded subprocess batches, mark only measured heavy tests, and keep fast-security acceptance separate from long CI integration coverage. |
## Fragment Sweep Command Set
-1
View File
@@ -444,7 +444,6 @@ function buildAISetupGuidance() {
function getApiBase() {
if (window.JYOTISH_API_BASE) return window.JYOTISH_API_BASE;
if (import.meta.env?.VITE_JYOTISH_API_BASE) return import.meta.env.VITE_JYOTISH_API_BASE;
if (window.Capacitor?.isNativePlatform?.()) return localStorage.getItem('jyotish_api_base') || '';
return ''; // 同域部署
}
+18
View File
@@ -47,6 +47,7 @@ async function postJson(path, payload, { requireModernChart = false } = {}) {
continue;
}
activeApiBase = base;
if (data?.mode === 'async_submitted') return pollAsyncJob(data, { base });
return data;
} catch (error) {
lastAttempt = `${base}${path}`;
@@ -60,6 +61,22 @@ async function postJson(path, payload, { requireModernChart = false } = {}) {
throw lastError || new Error(buildAPIRecoveryMessage(path, '本地 API 未连接', lastAttempt));
}
async function pollAsyncJob(job, { base = activeApiBase, timeoutMs = 120000, intervalMs = 500 } = {}) {
if (!job?.poll_path || !job?.access_token) throw new Error('Async job response missing poll capability');
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resp = await fetch(`${base}${job.poll_path}`, {
headers: { Authorization: `Bearer ${job.access_token}` },
});
const data = await parseApiResponse(resp);
if (!resp.ok) throw new Error(buildAPIRecoveryMessage(job.poll_path, data?.error || `Job poll failed (${resp.status})`));
if (data.status === 'completed') return data.result || data;
if (data.status === 'failed') throw new Error(data.error || 'Async job failed');
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
throw new Error(buildAPIRecoveryMessage(job.poll_path, 'Async job timed out'));
}
async function fetchJson(path) {
let lastError = null;
let lastAttempt = null;
@@ -501,6 +518,7 @@ window.JyotishAPI = {
computeKakshya,
computeBhavaBala,
computeTransitTriggers,
pollAsyncJob,
// AI 解读
aiReading,
aiFullReading,
+1 -2
View File
@@ -12,7 +12,6 @@ import { escapeAttr, escapeHtml } from './security.js';
const API_BASE = ''; // 同域部署,留空;Capacitor 打包时改为服务器地址
const TOKEN_KEY = 'jyotish_auth_token';
const USER_KEY = 'jyotish_auth_user';
const API_BASE_KEY = 'jyotish_api_base';
// ============================================================================
// 状态
@@ -61,7 +60,7 @@ export function getUser() { return _user; }
export function isLoggedIn() { return !!_token && !!_user; }
export function getApiBase() {
return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || localStorage.getItem(API_BASE_KEY) || API_BASE;
return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || API_BASE;
}
export function onAuthChange(cb) { _onAuthChange = cb; }
+1
View File
@@ -28,6 +28,7 @@ classifiers = [
keywords = ["jyotish", "vedic", "astrology", "astronomy", "dasha", "varga", "nakshatra", "shadbala", "panchanga", "horoscope", "birth-chart"]
dependencies = [
"pyswisseph>=2.8",
"timezonefinder>=6.5",
]
[project.optional-dependencies]
+1
View File
@@ -3,6 +3,7 @@
# 核心:Swiss Ephemeris 天文计算库(必需)
pyswisseph
timezonefinder>=6.5
# 以下为标准库,无需安装(仅供参考):
# argparse, json, sys, os, csv, math, sqlite3
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""Canonical calculation service shared by CLI, REST, and MCP adapters."""
from __future__ import annotations
import hashlib
import json
import math
import threading
from datetime import datetime
from typing import Any
from zoneinfo import ZoneInfo
import swisseph as swe
from ayanamsa_utils import apply_ayanamsa, normalize_ayanamsa_name
from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra
from jyotish_engine import SIGNS, compute_chart_data
from sade_sati import calc_sade_sati_complete
CONTRACT_VERSION = "1.0.0"
_SWISSEPH_LOCK = threading.RLock()
_PLANET_IDS = {"Saturn": swe.SATURN}
class CalculationError(ValueError):
pass
class TimezoneInferenceError(CalculationError):
pass
def _canonical_hash(payload: dict[str, Any]) -> str:
encoded = json.dumps(
payload,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _lookup_timezone_name(lat: float, lon: float) -> str | None:
try:
from timezonefinder import TimezoneFinder
except ImportError as exc:
raise TimezoneInferenceError("timezone inference dependency unavailable") from exc
return TimezoneFinder().timezone_at(lng=lon, lat=lat)
def infer_timezone_offset(*, lat: float, lon: float, local_datetime: datetime) -> float:
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
raise TimezoneInferenceError("timezone inference received invalid coordinates")
tz_name = _lookup_timezone_name(lat, lon)
if not tz_name:
raise TimezoneInferenceError("timezone inference returned no IANA zone")
try:
offset = local_datetime.replace(tzinfo=ZoneInfo(tz_name)).utcoffset()
except Exception as exc:
raise TimezoneInferenceError("timezone inference failed for IANA zone") from exc
if offset is None:
raise TimezoneInferenceError("timezone inference returned no UTC offset")
return offset.total_seconds() / 3600.0
def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]:
requested_node = str(payload.get("node_mode", payload.get("nodeMode", "mean"))).lower()
if requested_node not in {"mean", "true"}:
raise CalculationError("node_mode must be mean or true")
ayanamsa = normalize_ayanamsa_name(payload.get("ayanamsa", "lahiri"))
local_dt = datetime(
int(payload["year"]),
int(payload["month"]),
int(payload["day"]),
int(float(payload.get("hour", 0))),
int(float(payload.get("minute", 0))),
int(float(payload.get("second", 0))),
)
lat = float(payload["lat"])
lon = float(payload["lon"])
tz_requested = payload.get("tz")
timezone_source = "explicit_offset"
if tz_requested in {None, ""}:
tz = infer_timezone_offset(lat=lat, lon=lon, local_datetime=local_dt)
timezone_source = "iana_inferred"
else:
tz = float(tz_requested)
if not math.isfinite(tz) or not -14 <= tz <= 14:
raise CalculationError("tz must be a finite offset between -14 and 14")
return {
"year": local_dt.year,
"month": local_dt.month,
"day": local_dt.day,
"hour": int(float(payload.get("hour", 0))),
"minute": int(float(payload.get("minute", 0))),
"second": int(float(payload.get("second", 0))),
"lat": lat,
"lon": lon,
"tz": tz,
"timezone_source": timezone_source,
"ayanamsa": ayanamsa,
"node_mode": requested_node,
}
def _contract(requested: dict[str, Any], effective: dict[str, Any], *, algorithm: str) -> dict[str, Any]:
return {
"contract_version": CONTRACT_VERSION,
"algorithm": algorithm,
"requested": requested,
"effective": effective,
}
def compute_chart(payload: dict[str, Any]) -> dict[str, Any]:
request = _normalized_request(payload)
with _SWISSEPH_LOCK:
chart, _asc_idx, _jd, _ayanamsa = compute_chart_data(
request["year"],
request["month"],
request["day"],
request["hour"],
request["minute"],
request["lat"],
request["lon"],
request["tz"],
node_mode=request["node_mode"],
second=request["second"],
ayanamsa_name=request["ayanamsa"],
)
if not isinstance(chart, dict):
raise CalculationError("canonical chart calculation failed")
for planet in chart.get("planets", {}).values():
if not isinstance(planet, dict) or "error" in planet:
continue
planet.setdefault("lon", planet.get("degree_raw", planet.get("degree")))
if planet.get("sign") in SIGNS:
planet.setdefault("sign_idx", SIGNS.index(planet["sign"]))
birth = chart.get("birth_info", {})
effective = {
"ayanamsa": birth.get("ayanamsa_name", request["ayanamsa"]),
"node_mode": birth.get("node_mode", request["node_mode"]),
"timezone_offset": request["tz"],
"timezone_source": request["timezone_source"],
"ephemeris_source": "swisseph_calc_ut",
"ephemeris_flags_verified": False,
}
requested = {
"ayanamsa": payload.get("ayanamsa", "lahiri"),
"node_mode": payload.get("node_mode", payload.get("nodeMode", "mean")),
"timezone_offset": payload.get("tz"),
}
contract = _contract(requested, effective, algorithm="sidereal_natal_chart")
hash_payload = {
"contract": contract,
"birth": birth,
"ascendant": chart.get("ascendant"),
"planets": chart.get("planets"),
}
chart["calculation_contract"] = contract
chart["result_hash"] = _canonical_hash(hash_payload)
return chart
def compute_vimshottari_timeline(
*, birth_dt: datetime, moon_lon: float, current_date: datetime | None = None
) -> dict[str, Any]:
nak_info, progress, pada = lon_to_nakshatra(float(moon_lon) % 360)
timeline, elapsed, remaining, start_lord = build_dasha_timeline(
birth_dt.strftime("%Y-%m-%d"), nak_info, progress
)
periods = [
{
"lord": period["lord"],
"years": period["years"],
"start": period["start"].strftime("%Y-%m-%d"),
"end": period["end"].strftime("%Y-%m-%d"),
}
for period in timeline
]
contract = _contract(
{"moon_longitude": float(moon_lon) % 360},
{"year_basis_days": 365.25, "nakshatra": nak_info[0], "pada": pada},
algorithm="vimshottari_birth_balance",
)
result = {
"periods": periods,
"birth_balance": {
"lord": start_lord,
"elapsed_years": elapsed,
"remaining_years": remaining,
},
"calculation_contract": contract,
}
result["result_hash"] = _canonical_hash(result)
return result
def compute_transit_longitude(
*, planet: str, reference_date: str, tz: float, ayanamsa: str = "lahiri"
) -> dict[str, Any]:
if planet not in _PLANET_IDS:
raise CalculationError(f"unsupported transit planet: {planet}")
try:
local_dt = datetime.strptime(reference_date[:10], "%Y-%m-%d").replace(hour=12)
except (TypeError, ValueError) as exc:
raise CalculationError("reference_date must be YYYY-MM-DD") from exc
ayanamsa_name = normalize_ayanamsa_name(ayanamsa)
with _SWISSEPH_LOCK:
apply_ayanamsa(ayanamsa_name, swe)
jd = swe.julday(
local_dt.year,
local_dt.month,
local_dt.day,
12.0 - float(tz),
)
ayanamsa_value = swe.get_ayanamsa(jd)
position, flags = swe.calc_ut(jd, _PLANET_IDS[planet])
longitude = (position[0] - ayanamsa_value) % 360
return {
"planet": planet,
"longitude": longitude,
"reference_date": reference_date[:10],
"ayanamsa": ayanamsa_name,
"timezone_offset": float(tz),
"swisseph_return_flags": int(flags),
"data_layer": "true_transit_positions",
}
def compute_sade_sati(
*,
moon_degree: float,
asc_degree: float,
reference_date: str,
tz: float,
ayanamsa: str = "lahiri",
) -> dict[str, Any]:
transit = compute_transit_longitude(
planet="Saturn",
reference_date=reference_date,
tz=tz,
ayanamsa=ayanamsa,
)
result = calc_sade_sati_complete(
float(moon_degree) % 360,
float(asc_degree) % 360,
transit["longitude"],
datetime.strptime(reference_date[:10], "%Y-%m-%d"),
)
result["transit_saturn_lon"] = transit["longitude"]
result["provenance"] = transit
result["calculation_contract"] = _contract(
{"reference_date": reference_date[:10], "ayanamsa": ayanamsa, "tz": tz},
transit,
algorithm="sade_sati_true_saturn_transit",
)
result["result_hash"] = _canonical_hash(result)
return result
+249 -128
View File
@@ -15,10 +15,12 @@ import json, sys, os, math
import importlib.util
import hashlib
import re
import secrets
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from http.server import HTTPServer, BaseHTTPRequestHandler
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
@@ -43,6 +45,25 @@ _LOCAL_MODULE_CACHE = {}
_API_CHART_CACHE_SCOPE = 'api_chart_response'
_HIGH_RIGOR_JOB_SCOPE = 'high_rigor_workflow'
_UNIFIED_CONSULTATION_ORCHESTRATOR = UnifiedConsultationOrchestrator()
_ASYNC_JOB_WORKERS = max(int(os.environ.get('JYOTISH_ASYNC_JOB_WORKERS', '2')), 1)
_ASYNC_JOB_QUEUE_SIZE = max(int(os.environ.get('JYOTISH_ASYNC_JOB_QUEUE_SIZE', '8')), 0)
_ASYNC_JOB_EXECUTOR = ThreadPoolExecutor(
max_workers=_ASYNC_JOB_WORKERS,
thread_name_prefix='jyotish-job',
)
_ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE)
def _submit_background_job(callback):
if not _ASYNC_JOB_CAPACITY.acquire(blocking=False):
raise JobQueueFull('Async job queue is full')
try:
future = _ASYNC_JOB_EXECUTOR.submit(callback)
except Exception:
_ASYNC_JOB_CAPACITY.release()
raise
future.add_done_callback(lambda _future: _ASYNC_JOB_CAPACITY.release())
return future
def _western_evidence_packet_from_body(body: dict, route_packet: dict) -> dict | None:
@@ -467,29 +488,66 @@ def _async_job_path(scope: str, job_id: str) -> Path:
return _async_job_dir(scope) / f'{job_id}.json'
def _load_high_rigor_job_record(job_id: str) -> dict | None:
return _load_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id)
def _async_job_ttl_seconds() -> float:
raw = str(os.environ.get('JYOTISH_ASYNC_JOB_TTL_SECONDS', '3600')).strip()
try:
return max(float(raw), 1.0)
except ValueError:
return 3600.0
def _new_async_job_identity(prefix: str) -> dict:
return {
'job_id': f'{prefix}_{secrets.token_hex(16)}',
'access_token': secrets.token_urlsafe(32),
}
def _access_token_hash(token: str) -> str:
return hashlib.sha256(token.encode('utf-8')).hexdigest()
def _load_high_rigor_job_record(job_id: str, *, access_token: str = '') -> dict | None:
return _load_async_job_record(
_HIGH_RIGOR_JOB_SCOPE,
job_id,
access_token=access_token,
)
def _write_high_rigor_job_record(job_id: str, payload: dict) -> dict:
return _write_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id, payload)
def _load_async_job_record(scope: str, job_id: str) -> dict | None:
def _load_async_job_record(scope: str, job_id: str, *, access_token: str = '') -> dict | None:
path = _async_job_path(scope, job_id)
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding='utf-8'))
record = json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
return None
expires_at = record.get('expires_at_unix')
if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at):
try:
path.unlink()
except OSError:
pass
return None
expected = record.get('access_token_hash')
if not isinstance(expected, str) or not access_token:
raise JobAccessDenied('Async job access token required')
if not secrets.compare_digest(expected, _access_token_hash(access_token)):
raise JobAccessDenied('Async job access token invalid')
return record
def _write_async_job_record(scope: str, job_id: str, payload: dict) -> dict:
_async_job_path(scope, job_id).write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True),
encoding='utf-8',
)
path = _async_job_path(scope, job_id)
temp_path = path.with_suffix(f'.{secrets.token_hex(8)}.tmp')
temp_path.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True), encoding='utf-8')
os.chmod(temp_path, 0o600)
os.replace(temp_path, path)
return payload
@@ -809,6 +867,22 @@ class BadRequest(ValueError):
"""Client-side request validation failed."""
class Forbidden(PermissionError):
"""Request failed the local API trust boundary."""
class UnsupportedMediaType(ValueError):
"""Request body media type is not supported."""
class JobAccessDenied(PermissionError):
"""Async job capability token is missing or invalid."""
class JobQueueFull(RuntimeError):
"""Bounded async worker queue has no remaining capacity."""
class JyotishAPIHandler(BaseHTTPRequestHandler):
server_version = 'JyotishAPI/6.9.14'
@@ -832,6 +906,24 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
if origin in allowed:
self.send_header('Access-Control-Allow-Origin', origin)
def _enforce_request_security(self, *, require_json=False):
origin = self.headers.get('Origin')
allowed = getattr(self.server, 'allowed_origins', DEFAULT_ALLOWED_ORIGINS)
if origin and origin not in allowed:
raise Forbidden('Origin is not allowed')
host = (self.headers.get('Host') or '').split(':', 1)[0].strip('[]').lower()
if host and host not in {'localhost', '127.0.0.1', '::1'}:
raise Forbidden('Host is not allowed')
if require_json:
content_type = (self.headers.get('Content-Type') or '').split(';', 1)[0].strip().lower()
if content_type != 'application/json':
raise UnsupportedMediaType('Content-Type must be application/json')
def _job_access_token(self):
authorization = self.headers.get('Authorization') or ''
scheme, _, token = authorization.partition(' ')
return token.strip() if scheme.lower() == 'bearer' else ''
def _vedastro_status(self):
adapter = _load_local_module('vedastro_service_adapter')
endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip()
@@ -877,11 +969,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
}
def do_OPTIONS(self):
self._json({})
try:
self._enforce_request_security()
self._json({})
except Forbidden as exc:
self._error_json(str(exc), 403, 'ERR_FORBIDDEN')
def do_GET(self):
path = urlparse(self.path).path
try:
self._enforce_request_security()
if path == '/api/health':
swisseph_available = False
swisseph_version = None
@@ -937,6 +1034,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
self._json(self._real_case_revalidation())
else:
self._error_json('Not found', 404, 'ERR_NOT_FOUND')
except (Forbidden, JobAccessDenied) as exc:
self._error_json(str(exc), 403, 'ERR_FORBIDDEN')
except Exception:
import logging
logging.exception("[api_server] GET request failed for %s", path)
@@ -945,6 +1044,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
def do_POST(self):
path = urlparse(self.path).path
try:
self._enforce_request_security(require_json=True)
body = self._read_json_body()
if path == '/api/chart':
result = self._compute_chart(body)
@@ -1083,6 +1183,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
self._error_json(f'Unknown endpoint: {path}', 404, 'ERR_NOT_FOUND')
except BadRequest as e:
self._error_json(str(e), 400, 'ERR_BAD_REQUEST')
except Forbidden as exc:
self._error_json(str(exc), 403, 'ERR_FORBIDDEN')
except UnsupportedMediaType as exc:
self._error_json(str(exc), 415, 'ERR_UNSUPPORTED_MEDIA_TYPE')
except JobQueueFull as exc:
self._error_json(str(exc), 503, 'ERR_JOB_QUEUE_FULL')
except Exception:
import logging
logging.exception("[api_server] request failed for %s", path)
@@ -1122,13 +1228,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
tz = body.get('tz')
if tz is not None and tz != "":
return self._get_float(body, 'tz', 8, -14, 14)
from timezone_utils import infer_timezone
from datetime import datetime
try:
dt = datetime(int(year), int(month), int(day), int(hour), int(minute), int(second))
except Exception:
dt = datetime.utcnow()
return infer_timezone(lat, lon, dt)
except (TypeError, ValueError) as exc:
raise BadRequest('Invalid birth date') from exc
try:
calculation_service = _load_local_module('domain_calculation_service')
return calculation_service.infer_timezone_offset(
lat=lat,
lon=lon,
local_datetime=dt,
)
except ValueError as exc:
raise BadRequest(str(exc)) from exc
def _get_float(self, body, key, default, min_value=None, max_value=None):
value = body.get(key, default)
@@ -1960,7 +2073,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
}
def _enqueue_high_rigor_job(self, body):
job_id = f'hrw_{datetime.utcnow().strftime("%Y%m%d%H%M%S%f")}'
identity = _new_async_job_identity('hrw')
job_id = identity['job_id']
queued_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
poll_path = f'/api/high_rigor_workflow/jobs/{job_id}'
record = {
@@ -1972,13 +2086,18 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'queued_at': queued_at,
'poll_path': poll_path,
'scope': _HIGH_RIGOR_JOB_SCOPE,
'access_token': identity['access_token'],
'expires_at_unix': time.time() + _async_job_ttl_seconds(),
}
_write_high_rigor_job_record(job_id, record)
stored_record = dict(record)
stored_record.pop('access_token')
stored_record['access_token_hash'] = _access_token_hash(identity['access_token'])
_write_high_rigor_job_record(job_id, stored_record)
body_copy = dict(body or {})
def _run_job() -> None:
running = dict(record)
running = dict(stored_record)
running['status'] = 'running'
running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
_write_high_rigor_job_record(job_id, running)
@@ -1998,15 +2117,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
failed['error'] = str(exc)
_write_high_rigor_job_record(job_id, failed)
threading.Thread(
target=_run_job,
name=f'high-rigor-job-{job_id}',
daemon=True,
).start()
_submit_background_job(_run_job)
return record
def _enqueue_async_job(self, *, scope, endpoint, job_prefix, poll_base, compute_fn):
job_id = f'{job_prefix}_{datetime.utcnow().strftime("%Y%m%d%H%M%S%f")}'
identity = _new_async_job_identity(job_prefix)
job_id = identity['job_id']
queued_at = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
poll_path = f'{poll_base}/{job_id}'
record = {
@@ -2018,11 +2134,16 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'queued_at': queued_at,
'poll_path': poll_path,
'scope': scope,
'access_token': identity['access_token'],
'expires_at_unix': time.time() + _async_job_ttl_seconds(),
}
_write_async_job_record(scope, job_id, record)
stored_record = dict(record)
stored_record.pop('access_token')
stored_record['access_token_hash'] = _access_token_hash(identity['access_token'])
_write_async_job_record(scope, job_id, stored_record)
def _run_job() -> None:
running = dict(record)
running = dict(stored_record)
running['status'] = 'running'
running['started_at'] = datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
_write_async_job_record(scope, job_id, running)
@@ -2042,18 +2163,18 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
failed['error'] = str(exc)
_write_async_job_record(scope, job_id, failed)
threading.Thread(
target=_run_job,
name=f'{job_prefix}-job-{job_id}',
daemon=True,
).start()
_submit_background_job(_run_job)
return record
def _get_high_rigor_job(self, job_id):
return _load_high_rigor_job_record(job_id)
return _load_high_rigor_job_record(job_id, access_token=self._job_access_token())
def _get_chart_job(self, job_id):
return _load_async_job_record(_API_CHART_CACHE_SCOPE, job_id)
return _load_async_job_record(
_API_CHART_CACHE_SCOPE,
job_id,
access_token=self._job_access_token(),
)
def _high_rigor_birth_payload(self, body):
required = ('year', 'month', 'day', 'hour', 'minute', 'lat', 'lon')
@@ -3913,29 +4034,6 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
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):
if body.get('async') or body.get('enqueue'):
return self._enqueue_chart_job(body)
@@ -3975,78 +4073,69 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
raise BadRequest('Invalid birth date') from e
try:
import swisseph as swe
swe.set_ephe_path(os.path.join(SCRIPTS_DIR, '..', 'swiss_ephemeris'))
calculation_service = _load_local_module('domain_calculation_service')
canonical_chart = calculation_service.compute_chart({
'year': year,
'month': month,
'day': day,
'hour': hour,
'minute': minute,
'second': second,
'lat': lat,
'lon': lon,
'tz': tz,
'ayanamsa': body.get('ayanamsa', 'lahiri'),
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
})
canonical_birth = canonical_chart['birth_info']
planets_data = canonical_chart['planets']
ascendant_data = canonical_chart['ascendant']
asc_lon = float(ascendant_data['lon'])
asc_sign = ascendant_data['sign']
asc_sign_idx = SIGNS.index(asc_sign)
birth_hour_decimal = self._birth_hour_decimal(hour, minute, second)
hour_ut = birth_hour_decimal - tz
jd = swe.julday(year, month, day, hour_ut)
ayanamsa_name = body.get('ayanamsa', 'lahiri')
try:
from jyotish_engine import _apply_ayanamsa, _ayanamsa_display_name
_apply_ayanamsa(ayanamsa_name)
ayanamsa_display = _ayanamsa_display_name(ayanamsa_name)
except ImportError:
swe.set_sid_mode(swe.SIDM_LAHIRI, 0, 0)
ayanamsa_name = 'lahiri'
ayanamsa_display = 'Lahiri'
ayanamsa = swe.get_ayanamsa(jd)
jd = float(canonical_birth['julian_day'])
ayanamsa = float(canonical_birth['ayanamsa'])
ayanamsa_name = canonical_birth['ayanamsa_name']
ayanamsa_display = canonical_birth['ayanamsa_display']
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
house = canonical_chart.get('houses', {}).get(f'house_{h}', {})
sign = house.get('cusp_sign', SIGNS[(asc_sign_idx + h - 1) % 12])
houses[h] = {
'sign': sign,
'sign_idx': SIGNS.index(sign),
'cusp_degree': house.get('cusp_degree'),
}
moon_lon = float(planets_data['Moon']['lon'])
birth_dt = datetime(year, month, day, int(hour), int(minute), int(second))
elapsed_days = elapsed * 365.25636
dasha_start = birth_dt - timedelta(days=elapsed_days) if elapsed_days < 365*120 else birth_dt
canonical_dasha = calculation_service.compute_vimshottari_timeline(
birth_dt=birth_dt,
moon_lon=moon_lon,
current_date=birth_dt,
)
dasha_balance = canonical_dasha['birth_balance']
md_lord = dasha_balance['lord']
remaining = dasha_balance['remaining_years']
total_years = canonical_dasha['periods'][0]['years']
dasha_start = datetime.strptime(canonical_dasha['periods'][0]['start'], '%Y-%m-%d')
# 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)
reference_date = (
body.get('transit_date')
or body.get('today')
or body.get('current_date')
or datetime.now().strftime('%Y-%m-%d')
)
sade_sati = calculation_service.compute_sade_sati(
moon_degree=moon_lon,
asc_degree=asc_lon,
reference_date=reference_date,
tz=tz,
ayanamsa=ayanamsa_name,
)
# Dasha清单
extended_dashas = _load_local_module('extended_dashas')
@@ -4120,7 +4209,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'ayanamsa': round(ayanamsa, 4),
'ayanamsa_name': ayanamsa_name,
'ayanamsa_display': ayanamsa_display,
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
'node_mode': canonical_chart['calculation_contract']['effective']['node_mode'],
},
'ascendant': {
'sign': asc_sign,
@@ -4135,6 +4224,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'remaining_years': round(remaining, 2),
'total_years': total_years,
'start_date': dasha_start.isoformat() if hasattr(dasha_start, 'isoformat') else str(dasha_start),
'periods': canonical_dasha['periods'],
'birth_balance': canonical_dasha['birth_balance'],
'calculation_contract': canonical_dasha['calculation_contract'],
'result_hash': canonical_dasha['result_hash'],
},
'yogas': yogas,
'sade_sati': sade_sati,
@@ -4143,6 +4236,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'special_lagnas': special_lagnas,
'available_dashas': dasha_list,
'dasha_count': len(dasha_list),
'calculation_contract': canonical_chart['calculation_contract'],
'result_hash': canonical_chart['result_hash'],
}
result['modules'] = {
'chart': {
@@ -4150,6 +4245,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'ascendant': result['ascendant'],
'houses': result['houses'],
'birth_info': result['birth'],
'calculation_contract': result['calculation_contract'],
'result_hash': result['result_hash'],
},
'dasha': result['dasha'],
'shadbala': {'planets': sb.get('planets', {})} if 'sb' in locals() and isinstance(sb, dict) else {},
@@ -4740,9 +4837,20 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
tithi_num = self._get_int(body, 'tithi_num', 1, 1, 30)
vimshottari_analysis = None
canonical_dasha = None
if dasha_key == 'vimshottari':
periods = self._calc_vimshottari_periods(birth_dt, moon_lon)
precision = 'calculator'
calculation_service = _load_local_module('domain_calculation_service')
canonical_dasha = calculation_service.compute_vimshottari_timeline(
birth_dt=birth_dt,
moon_lon=moon_lon,
current_date=(
self._parse_optional_date(body.get('today') or body.get('current_date'))
if body.get('today') or body.get('current_date')
else None
),
)
periods = canonical_dasha['periods']
precision = 'canonical_birth_balance'
vimshottari_analysis = self._compute_vimshottari_analysis_layer(
birth_dt,
moon_lon,
@@ -4778,6 +4886,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
if vimshottari_analysis:
result['vimshottari_analysis'] = vimshottari_analysis
result['fragment_sources'] = ['dasha_analyzer.py', 'dasha_calculator_enhanced.py']
if canonical_dasha:
result['birth_balance'] = canonical_dasha['birth_balance']
result['calculation_contract'] = canonical_dasha['calculation_contract']
result['result_hash'] = canonical_dasha['result_hash']
return result
def _compute_vimshottari_analysis_layer(self, birth_dt, moon_lon, current_date=None):
@@ -4853,11 +4965,19 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
}
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),
calculation_service = _load_local_module('domain_calculation_service')
reference_date = (
body.get('reference_date')
or body.get('transit_date')
or body.get('current_date')
or datetime.now().strftime('%Y-%m-%d')
)
return calculation_service.compute_sade_sati(
moon_degree=self._normalize_degree(body, 'moon_degree', 0),
asc_degree=self._normalize_degree(body, 'asc_degree', 0),
reference_date=reference_date,
tz=self._get_float(body, 'tz', 0, -14, 14),
ayanamsa=body.get('ayanamsa', 'lahiri'),
)
def _compute_pmc(self, body):
@@ -7448,7 +7568,8 @@ def _parse_allowed_origins(value):
def start_server(port=5200, host='127.0.0.1', allowed_origins=None):
server = HTTPServer((host, port), JyotishAPIHandler)
server = ThreadingHTTPServer((host, port), JyotishAPIHandler)
server.daemon_threads = True
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))}')
+18 -6
View File
@@ -735,12 +735,24 @@ def _birth_datetime_from_args(args):
def _compute_chart_from_args(args):
return compute_chart_data(
args.year, args.month, args.day, args.hour, args.minute,
args.lat, args.lon, args.tz, getattr(args, 'node_mode', 'mean'),
second=_arg_second(args),
ayanamsa_name=_current_ayanamsa_name(args),
)
from domain_calculation_service import compute_chart
result = compute_chart({
'year': args.year,
'month': args.month,
'day': args.day,
'hour': args.hour,
'minute': args.minute,
'second': _arg_second(args),
'lat': args.lat,
'lon': args.lon,
'tz': args.tz,
'node_mode': getattr(args, 'node_mode', 'mean'),
'ayanamsa': _current_ayanamsa_name(args),
})
asc_idx = SIGNS.index(result['ascendant']['sign'])
birth = result['birth_info']
return result, asc_idx, birth['julian_day'], birth['ayanamsa']
def _current_ayanamsa_name(args=None):
+21 -2
View File
@@ -29,6 +29,7 @@ import sys
import re
import glob
import argparse
from urllib.parse import urlparse
try:
import markdown
@@ -352,6 +353,13 @@ def build_section(num, title, md_text):
</div>"""
def is_allowed_report_resource_url(url, *, report_url):
if url == report_url:
return True
parsed = urlparse(url)
return parsed.scheme in {'data', 'about', 'blob'}
def _html_to_pdf(html_path, pdf_path):
"""Convert HTML to PDF using Playwright headless Chromium."""
try:
@@ -364,14 +372,25 @@ def _html_to_pdf(html_path, pdf_path):
print(" Launching headless Chromium...")
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(f"file://{os.path.abspath(html_path)}", wait_until="networkidle")
context = browser.new_context(java_script_enabled=False)
page = context.new_page()
report_url = f"file://{os.path.abspath(html_path)}"
page.route(
"**/*",
lambda route: (
route.continue_()
if is_allowed_report_resource_url(route.request.url, report_url=report_url)
else route.abort()
),
)
page.goto(report_url, wait_until="networkidle")
page.pdf(
path=pdf_path,
format="A4",
print_background=True,
margin={"top": "22mm", "bottom": "24mm", "left": "20mm", "right": "20mm"},
)
context.close()
browser.close()
size_kb = os.path.getsize(pdf_path) / 1024
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env python3
"""Capture a public same-chart parity packet without overstating oracle closure."""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from domain_calculation_service import compute_chart
ROOT = Path(__file__).resolve().parents[1]
PYJHORA_ARTIFACT = ROOT / "references/oracle/artifacts/pyjhora_steve_jobs_dasha_stdout_20260627.txt"
JYOTISHGANIT_ROOT = ROOT / "references/open_source_sources/jyotishganit"
PUBLIC_CASE = {
"case_id": "steve_jobs_public_1955_lahiri",
"year": 1955,
"month": 2,
"day": 24,
"hour": 19,
"minute": 15,
"second": 0,
"lat": 37.7749,
"lon": -122.4194,
"tz": -8.0,
"ayanamsa": "lahiri",
"node_mode": "mean",
}
def _write_json(path: Path, value: dict[str, Any]) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
return path
def _capture_jyotishganit_raw(output_dir: Path) -> tuple[dict[str, Any], str]:
sys.path.insert(0, str(JYOTISHGANIT_ROOT))
try:
from jyotishganit import calculate_birth_chart, get_birth_chart_json
chart = calculate_birth_chart(
datetime(
PUBLIC_CASE["year"],
PUBLIC_CASE["month"],
PUBLIC_CASE["day"],
PUBLIC_CASE["hour"],
PUBLIC_CASE["minute"],
PUBLIC_CASE["second"],
),
PUBLIC_CASE["lat"],
PUBLIC_CASE["lon"],
PUBLIC_CASE["tz"],
location_name="San Francisco, CA",
name="Steve Jobs (public benchmark)",
)
raw = get_birth_chart_json(chart)
path = _write_json(output_dir / "jyotishganit_raw.json", raw)
return raw, str(path)
except Exception as exc:
return {"error": f"{exc.__class__.__name__}: {exc}"}, ""
finally:
try:
sys.path.remove(str(JYOTISHGANIT_ROOT))
except ValueError:
pass
def _vedastro_state(*, allow_network: bool) -> dict[str, Any]:
if not allow_network:
return {
"status": "blocked",
"official_raw_response_path": "",
"reason": "network_disabled_for_public_replay",
}
return {
"status": "blocked",
"official_raw_response_path": "",
"reason": "official_runner_requires_explicit_raw_capture_workflow",
}
def build_public_case_replay(*, output_dir: Path, allow_vedastro_network: bool = False) -> dict[str, Any]:
output_dir.mkdir(parents=True, exist_ok=True)
local = compute_chart(PUBLIC_CASE)
jyotishganit_raw, jyotishganit_path = _capture_jyotishganit_raw(output_dir)
pyjhora_available = PYJHORA_ARTIFACT.is_file()
vedastro = _vedastro_state(allow_network=allow_vedastro_network)
rows = [
{
"section": "D1",
"field": "Sun.longitude",
"local_value": local["planets"]["Sun"]["lon"],
"oracle_values": {
"VedAstro": None,
"PyJHora_JHora": None,
"jyotishganit": None,
},
"status": "blocked",
"reason": "raw_values_not_normalized_across_all_three_engines",
},
{
"section": "Panchanga",
"field": "raw_capture",
"local_value": None,
"oracle_values": {
"VedAstro": None,
"PyJHora_JHora": "dasha_only_artifact",
"jyotishganit": "captured" if jyotishganit_path else None,
},
"status": "not_comparable",
"reason": "three_engine_scope_does_not_share_this_normalized_field",
},
]
report = {
"case_id": PUBLIC_CASE["case_id"],
"birth_data_policy": "public_case_only",
"status": "partial" if pyjhora_available and jyotishganit_path else "blocked",
"tested": False,
"blocked_reason": "official_vedastro_raw_missing_or_unverified",
"engines": {
"VedAstro": vedastro,
"PyJHora_JHora": {
"status": "raw_imported" if pyjhora_available else "blocked",
"raw_output_path": str(PYJHORA_ARTIFACT) if pyjhora_available else "",
"settings": {"ayanamsa": "LAHIRI", "node_mode": "PyJHora default"},
},
"jyotishganit": {
"status": "raw_captured" if jyotishganit_path else "blocked",
"raw_output_path": jyotishganit_path,
"error": jyotishganit_raw.get("error") if isinstance(jyotishganit_raw, dict) else None,
},
},
"local": {
"result_hash": local["result_hash"],
"calculation_contract": local["calculation_contract"],
},
"comparison_rows": rows,
"runtime_boundary": (
"This packet has real public raw artifacts but remains unverified until a VedAstro "
"official raw response and normalized three-engine field comparison are imported."
),
}
_write_json(output_dir / "three_engine_parity_replay.json", report)
return report
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--output-dir", default="scratch/local/three_engine_parity")
parser.add_argument("--allow-vedastro-network", action="store_true")
args = parser.parse_args()
report = build_public_case_replay(
output_dir=ROOT / args.output_dir,
allow_vedastro_network=args.allow_vedastro_network,
)
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+3 -15
View File
@@ -1,17 +1,5 @@
from datetime import datetime
import logging
def infer_timezone(lat: float, lon: float, dt: datetime) -> float:
from domain_calculation_service import infer_timezone_offset
def infer_timezone(lat: float, lon: float, dt: datetime, default: float = 8.0) -> float:
try:
from timezonefinder import TimezoneFinder
import pytz
tf = TimezoneFinder()
tz_name = tf.timezone_at(lng=lon, lat=lat)
if tz_name:
offset_seconds = pytz.timezone(tz_name).localize(dt).utcoffset().total_seconds()
offset = float(offset_seconds / 3600.0)
logging.info(f"[Timezone Auth] Detected {tz_name} offset {offset} for {dt}")
return offset
except Exception as e:
logging.warning(f"Timezone inference failed: {e}")
return float(default)
return infer_timezone_offset(lat=lat, lon=lon, local_datetime=dt)
+16
View File
@@ -8,6 +8,16 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = str(ROOT / "scripts")
WORKBUDDY_SKILL_SCRIPTS = ".workbuddy/skills/jyotish-vedic-astrology/scripts"
SLOW_API_SECURITY_PREFIXES = (
"test_vedastro_",
"test_high_rigor_",
"test_professional_reading",
"test_api_prompt_pack",
"test_consultation_workflow",
"test_thematic_report",
"test_capability_audit",
"test_technique_catalog",
)
def ensure_project_scripts_first() -> None:
@@ -28,4 +38,10 @@ def pytest_runtest_setup() -> None:
ensure_project_scripts_first()
def pytest_collection_modifyitems(items) -> None:
for item in items:
if item.fspath.basename == "test_api_server_security.py" and item.name.startswith(SLOW_API_SECURITY_PREFIXES):
item.add_marker("slow")
ensure_project_scripts_first()
+6 -3
View File
@@ -134,7 +134,10 @@ class _HighRigorJobCaptureHandler(JyotishAPIHandler):
class _PostCaptureHandler(JyotishAPIHandler):
def __init__(self, path: str, payload: dict) -> None:
raw = json.dumps(payload).encode('utf-8')
self.headers = _FakeHeaders({'Content-Length': str(len(raw))})
self.headers = _FakeHeaders({
'Content-Length': str(len(raw)),
'Content-Type': 'application/json',
})
self.server = _FakeServer()
self.path = path
self.rfile = BytesIO(raw)
@@ -3377,7 +3380,7 @@ def test_chart_async_submit_returns_job_id(monkeypatch: pytest.MonkeyPatch) -> N
def test_high_rigor_job_poll_endpoint_returns_cached_job_payload(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(jyotish_api_server, '_load_high_rigor_job_record', lambda job_id: {
monkeypatch.setattr(jyotish_api_server, '_load_high_rigor_job_record', lambda job_id, **_kwargs: {
'success': True,
'endpoint': 'high_rigor_workflow_async',
'mode': 'async_result',
@@ -3397,7 +3400,7 @@ def test_high_rigor_job_poll_endpoint_returns_cached_job_payload(monkeypatch: py
def test_chart_job_poll_endpoint_returns_cached_job_payload(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(jyotish_api_server, '_load_async_job_record', lambda scope, job_id: {
monkeypatch.setattr(jyotish_api_server, '_load_async_job_record', lambda scope, job_id, **_kwargs: {
'success': True,
'endpoint': 'chart_async',
'mode': 'async_result',
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from types import SimpleNamespace
import pytest
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import domain_calculation_service as calculation_service # noqa: E402
import jyotish_api_server # noqa: E402
from jyotish_api_server import JyotishAPIHandler # noqa: E402
from jyotish_engine import _compute_chart_from_args # noqa: E402
BIRTH = {
"year": 1990,
"month": 1,
"day": 1,
"hour": 12,
"minute": 0,
"second": 0,
"lat": 28.6139,
"lon": 77.2090,
"tz": 5.5,
"ayanamsa": "lahiri",
}
def test_true_node_changes_effective_rahu_and_contract() -> None:
mean = calculation_service.compute_chart({**BIRTH, "node_mode": "mean"})
true = calculation_service.compute_chart({**BIRTH, "node_mode": "true"})
assert mean["planets"]["Rahu"]["lon"] != pytest.approx(
true["planets"]["Rahu"]["lon"], abs=1e-8
)
assert mean["calculation_contract"]["effective"]["node_mode"] == "mean"
assert true["calculation_contract"]["effective"]["node_mode"] == "true"
assert mean["result_hash"] != true["result_hash"]
def test_vimshottari_uses_birth_balance_as_canonical_timeline() -> None:
birth_dt = datetime(1990, 1, 1, 12, 0)
result = calculation_service.compute_vimshottari_timeline(
birth_dt=birth_dt,
moon_lon=100.0,
current_date=birth_dt,
)
first = result["periods"][0]
assert first["lord"] == "Saturn"
assert first["start"] == "1980-07-02"
assert first["end"] == "1999-07-02"
assert result["birth_balance"]["remaining_years"] == pytest.approx(9.5)
assert result["calculation_contract"]["algorithm"] == "vimshottari_birth_balance"
def test_sade_sati_uses_real_saturn_transit_for_reference_date() -> None:
result = calculation_service.compute_sade_sati(
moon_degree=300.0,
asc_degree=330.0,
reference_date="2026-07-11",
tz=5.5,
ayanamsa="lahiri",
)
oracle = calculation_service.compute_transit_longitude(
planet="Saturn",
reference_date="2026-07-11",
tz=5.5,
ayanamsa="lahiri",
)
assert result["transit_saturn_lon"] == pytest.approx(oracle["longitude"], abs=1e-8)
assert result["provenance"]["data_layer"] == "true_transit_positions"
assert result["provenance"]["reference_date"] == "2026-07-11"
def test_timezone_inference_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
calculation_service,
"_lookup_timezone_name",
lambda _lat, _lon: None,
)
with pytest.raises(calculation_service.TimezoneInferenceError, match="timezone inference"):
calculation_service.infer_timezone_offset(
lat=0.0,
lon=0.0,
local_datetime=datetime(1990, 1, 1, 12, 0),
)
def test_chart_hash_matches_domain_cli_and_rest(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0")
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0")
monkeypatch.setattr(
jyotish_api_server,
"_attach_vedastro_main_entry_overview",
lambda result, _birth: result,
)
expected = calculation_service.compute_chart({**BIRTH, "node_mode": "true"})
cli, _asc_idx, _jd, _ayanamsa = _compute_chart_from_args(
SimpleNamespace(**BIRTH, node_mode="true")
)
rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(
{**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"}
)
assert cli["result_hash"] == expected["result_hash"]
assert rest["result_hash"] == expected["result_hash"]
assert rest["birth"]["node_mode"] == "true"
assert rest["calculation_contract"]["effective"]["node_mode"] == "true"
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
import hashlib
import sys
import time
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import jyotish_api_server as api # noqa: E402
import report_builder # noqa: E402
class _Headers(dict):
def get(self, key, default=None):
return super().get(key, default)
class _Server:
allowed_origins = {"http://localhost:3456"}
server_address = ("127.0.0.1", 5200)
def _handler(headers: dict[str, str]):
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
handler.headers = _Headers(headers)
handler.server = _Server()
return handler
def test_untrusted_origin_is_rejected_before_post_side_effects() -> None:
handler = _handler(
{
"Origin": "https://evil.example",
"Host": "127.0.0.1:5200",
"Content-Type": "application/json",
}
)
with pytest.raises(api.Forbidden, match="Origin"):
handler._enforce_request_security(require_json=True)
def test_post_requires_json_content_type() -> None:
handler = _handler(
{
"Origin": "http://localhost:3456",
"Host": "127.0.0.1:5200",
"Content-Type": "text/plain",
}
)
with pytest.raises(api.UnsupportedMediaType):
handler._enforce_request_security(require_json=True)
@pytest.mark.parametrize(
"url",
[
"https://example.com/image.png",
"http://127.0.0.1:8080/private",
"file:///etc/passwd",
"ftp://example.com/file",
],
)
def test_report_renderer_blocks_external_and_local_resources(url: str) -> None:
assert report_builder.is_allowed_report_resource_url(
url,
report_url="file:///tmp/report.html",
) is False
def test_report_renderer_allows_only_document_and_embedded_resources() -> None:
assert report_builder.is_allowed_report_resource_url(
"file:///tmp/report.html",
report_url="file:///tmp/report.html",
) is True
assert report_builder.is_allowed_report_resource_url(
"data:image/png;base64,AA==",
report_url="file:///tmp/report.html",
) is True
def test_async_job_identity_is_random_and_capability_protected(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(api, "_async_job_dir", lambda _scope: tmp_path)
first = api._new_async_job_identity("chart")
second = api._new_async_job_identity("chart")
assert first["job_id"] != second["job_id"]
assert len(first["job_id"].split("_", 1)[1]) >= 32
assert first["access_token"] != second["access_token"]
record = {
"job_id": first["job_id"],
"status": "queued",
"access_token_hash": hashlib.sha256(first["access_token"].encode()).hexdigest(),
"expires_at_unix": time.time() + 60,
}
api._write_async_job_record("chart", first["job_id"], record)
assert api._load_async_job_record(
"chart", first["job_id"], access_token=first["access_token"]
)["status"] == "queued"
with pytest.raises(api.JobAccessDenied):
api._load_async_job_record("chart", first["job_id"], access_token="wrong")
def test_expired_async_job_is_deleted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr(api, "_async_job_dir", lambda _scope: tmp_path)
identity = api._new_async_job_identity("chart")
api._write_async_job_record(
"chart",
identity["job_id"],
{
"job_id": identity["job_id"],
"access_token_hash": hashlib.sha256(identity["access_token"].encode()).hexdigest(),
"expires_at_unix": time.time() - 1,
},
)
assert api._load_async_job_record(
"chart", identity["job_id"], access_token=identity["access_token"]
) is None
assert not (tmp_path / f"{identity['job_id']}.json").exists()
def test_authenticated_frontend_does_not_use_local_storage_api_base() -> None:
auth_source = (ROOT / "jyotish-app" / "auth.js").read_text(encoding="utf-8")
chat_source = (ROOT / "jyotish-app" / "ai-chat.js").read_text(encoding="utf-8")
assert "localStorage.getItem(API_BASE_KEY)" not in auth_source
assert "localStorage.getItem('jyotish_api_base')" not in chat_source
def test_frontend_async_poll_uses_ephemeral_job_capability() -> None:
bridge_source = (ROOT / "jyotish-app" / "api-bridge.js").read_text(encoding="utf-8")
assert "pollAsyncJob(data, { base })" in bridge_source
assert "Authorization: `Bearer ${job.access_token}`" in bridge_source
assert "sessionStorage.setItem('jyotish_job" not in bridge_source
def test_background_job_queue_rejects_when_capacity_is_full(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FullCapacity:
def acquire(self, blocking=False):
return False
monkeypatch.setattr(api, "_ASYNC_JOB_CAPACITY", _FullCapacity())
with pytest.raises(api.JobQueueFull):
api._submit_background_job(lambda: None)
+26
View File
@@ -0,0 +1,26 @@
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
from three_engine_parity_runner import build_public_case_replay # noqa: E402
def test_public_same_chart_replay_never_promotes_missing_vedastro_raw(tmp_path: Path) -> None:
report = build_public_case_replay(output_dir=tmp_path, allow_vedastro_network=False)
assert report["case_id"] == "steve_jobs_public_1955_lahiri"
assert report["birth_data_policy"] == "public_case_only"
assert report["engines"]["PyJHora_JHora"]["status"] == "raw_imported"
assert report["engines"]["jyotishganit"]["status"] == "raw_captured"
assert report["engines"]["VedAstro"]["status"] == "blocked"
assert report["status"] in {"partial", "blocked"}
assert report["tested"] is False
assert report["comparison_rows"]
assert all(row["status"] in {"blocked", "not_comparable"} for row in report["comparison_rows"])