Add API security and async job contracts

This commit is contained in:
732642856
2026-07-16 23:40:01 +08:00
parent a7fdfcb033
commit e013fed79c
10 changed files with 573 additions and 17 deletions
-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('Async job timed out');
}
async function fetchJson(path) {
let lastError = null;
let lastAttempt = null;
@@ -511,6 +528,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; }
+235 -14
View File
@@ -15,10 +15,13 @@ import json, sys, os, math
import importlib.util
import hashlib
import re
import sqlite3
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 HTTPServer, BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
@@ -57,6 +60,75 @@ _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)
_RATE_LIMIT_LOCK = threading.Lock()
_RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {}
def summarize_execution_status(result: dict | None) -> dict:
result = result if isinstance(result, dict) else {}
fallback = str(result.get('fallback_reason') or '')
official = 'official_blocked' if 'VedAstro official snapshot blocked' in fallback else result.get('official_evidence_status', 'unknown')
return {
'official_evidence_status': official,
'fallback_reason': result.get('fallback_reason'),
}
def build_evidence_packet_view(job_record: dict | None) -> dict:
"""Public, token-protected job view. Excludes prompt internals and raw input."""
job_record = job_record or {}
result = job_record.get('result')
result = result if isinstance(result, dict) else {}
return {
'scope': 'evidence_packet_view',
'job_id': job_record.get('job_id'),
'status': job_record.get('status', 'unknown'),
'execution_status': summarize_execution_status(result),
'machine_evidence_packet': result.get('machine_evidence_packet') or {},
'technique_audit': result.get('technique_audit') or result.get('technique_audit_table') or [],
'warnings': result.get('warnings') or [],
}
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 _rate_limit_per_minute() -> int:
raw = str(os.environ.get('JYOTISH_API_RATE_LIMIT_PER_MINUTE', '120')).strip()
try:
return max(int(raw), 0)
except ValueError:
return 120
def enforce_rate_limit(client_id: str, *, now: float | None = None) -> None:
limit = _rate_limit_per_minute()
if limit == 0:
return
now = time.time() if now is None else now
with _RATE_LIMIT_LOCK:
window, count = _RATE_LIMIT_BUCKETS.get(client_id, (now, 0))
if now - window >= 60:
window, count = now, 0
if count >= limit:
raise RateLimited('Rate limit exceeded')
_RATE_LIMIT_BUCKETS[client_id] = (window, count + 1)
def _western_evidence_packet_from_body(
@@ -752,29 +824,140 @@ 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 _async_job_backend() -> str:
return "sqlite" if os.environ.get("JYOTISH_ASYNC_JOB_BACKEND", "file").strip().lower() == "sqlite" else "file"
def _sqlite_job_db_path() -> Path:
return Path(REPO_ROOT) / "scratch" / "local" / "async_jobs.sqlite3"
def _sqlite_job_connection() -> sqlite3.Connection:
path = _sqlite_job_db_path()
path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(path, timeout=10)
connection.execute(
"CREATE TABLE IF NOT EXISTS async_jobs (scope TEXT NOT NULL, job_id TEXT NOT NULL, expires_at REAL, payload TEXT NOT NULL, PRIMARY KEY (scope, job_id))"
)
try:
os.chmod(path, 0o600)
except OSError:
pass
return connection
def prune_expired_async_jobs() -> dict:
"""Best-effort startup cleanup for local job records; never reads payloads."""
removed = 0
scanned = 0
if _async_job_backend() == "sqlite":
with _sqlite_job_connection() as connection:
scanned = connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0]
removed = connection.execute(
"DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?", (time.time(),)
).rowcount
return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed}
for scope in (_HIGH_RIGOR_JOB_SCOPE, _API_CHART_CACHE_SCOPE):
directory = _async_job_dir(scope)
if not directory.is_dir():
continue
for path in directory.glob('*.json'):
scanned += 1
try:
record = json.loads(path.read_text(encoding='utf-8'))
expires_at = record.get('expires_at_unix') if isinstance(record, dict) else None
if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at):
path.unlink()
removed += 1
except (OSError, json.JSONDecodeError):
continue
return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed}
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:
path = _async_job_path(scope, job_id)
if not path.exists():
return None
try:
return json.loads(path.read_text(encoding='utf-8'))
except (OSError, json.JSONDecodeError):
def _load_async_job_record(scope: str, job_id: str, *, access_token: str = '') -> dict | None:
path = None
if _async_job_backend() == "sqlite":
with _sqlite_job_connection() as connection:
row = connection.execute(
"SELECT payload FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id)
).fetchone()
if row is None:
return None
try:
record = json.loads(row[0])
except json.JSONDecodeError:
return None
else:
path = _async_job_path(scope, job_id)
if not path.exists():
return None
try:
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):
if _async_job_backend() == "sqlite":
with _sqlite_job_connection() as connection:
connection.execute("DELETE FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id))
elif path is not None:
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',
)
if _async_job_backend() == "sqlite":
with _sqlite_job_connection() as connection:
connection.execute(
"INSERT OR REPLACE INTO async_jobs (scope, job_id, expires_at, payload) VALUES (?, ?, ?, ?)",
(scope, job_id, payload.get("expires_at_unix"), json.dumps(payload, ensure_ascii=False, sort_keys=True)),
)
return payload
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
@@ -1098,6 +1281,26 @@ 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 RateLimited(RuntimeError):
"""Client exceeded the local fixed-window request budget."""
class JyotishAPIHandler(BaseHTTPRequestHandler):
server_version = 'JyotishAPI/6.9.14'
@@ -1121,6 +1324,19 @@ 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 _vedastro_status(self):
adapter = _load_local_module('vedastro_service_adapter')
endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip()
@@ -1234,6 +1450,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)
@@ -1378,6 +1595,10 @@ 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 e:
self._error_json(str(e), 403, 'ERR_FORBIDDEN')
except UnsupportedMediaType as e:
self._error_json(str(e), 415, 'ERR_UNSUPPORTED_MEDIA_TYPE')
except Exception:
import logging
logging.exception("[api_server] request failed for %s", path)
+8
View File
@@ -29,6 +29,7 @@ import sys
import re
import glob
import argparse
from urllib.parse import urlparse
try:
import markdown
@@ -323,6 +324,13 @@ def build_cover(name, lagna, gender, status, pkg, desc, lang="cn"):
</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 build_toc(sections, lang="cn"):
"""Generate table of contents HTML."""
toc_title = "目录" if lang == "cn" else "Table of Contents"
+105
View File
@@ -0,0 +1,105 @@
import json
import time
from pathlib import Path
import pytest
from scripts import jyotish_api_server as api
def test_evidence_packet_view_exposes_only_auditable_result_sections():
packet = api.build_evidence_packet_view({
"job_id": "job_1",
"status": "completed",
"result": {
"fallback_reason": "VedAstro official snapshot blocked: timeout",
"machine_evidence_packet": {"status": "draft", "metadata": {"capture_id": "x"}},
"technique_audit": [{"technique": "D9", "status": "used"}],
"ai_prompt_pack": {"prompt_zh": "internal prompt"},
},
})
assert packet["job_id"] == "job_1"
assert packet["execution_status"]["official_evidence_status"] == "official_blocked"
assert packet["machine_evidence_packet"]["metadata"]["capture_id"] == "x"
assert "ai_prompt_pack" not in packet
def test_async_job_route_extracts_id_before_loading(monkeypatch, tmp_path):
record = {"job_id": "chart_abc", "status": "completed", "result": {}}
monkeypatch.setattr(api, "_load_async_job_record", lambda *args, **kwargs: record)
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
handler.path = "/api/chart/jobs/chart_abc"
handler.headers = {"Origin": ""}
handler._enforce_request_security = lambda: None
captured = {}
handler._json = lambda data, status=200: captured.update(data=data, status=status)
handler._error_json = lambda message, status=500, error_code="ERR_INTERNAL": captured.update(error=error_code, status=status)
handler._job_access_token = lambda: "token"
handler.do_GET()
assert captured["status"] == 200
assert captured["data"]["job_id"] == "chart_abc"
def test_evidence_packet_page_is_present_and_does_not_embed_birth_data():
page = Path(api.REPO_ROOT) / "web" / "evidence_packet.html"
source = page.read_text(encoding="utf-8")
assert "Evidence Packet" in source
assert "access token" in source
assert "birth" not in source.lower()
def test_rectification_page_uses_choice_questionnaire_contract():
page = Path(api.REPO_ROOT) / "web" / "rectification.html"
source = page.read_text(encoding="utf-8")
assert "/api/rectification/questionnaire" in source
assert "/api/rectification/answers" in source
assert "候选簇排序" in source
def test_home_page_keeps_location_confirmation_local():
page = Path(api.REPO_ROOT) / "web" / "index.html"
source = page.read_text(encoding="utf-8")
assert "/api/location/resolve" in source
assert "第三方地理服务" in source
def test_startup_cleanup_removes_only_expired_job_records(monkeypatch, tmp_path):
expired = tmp_path / "expired.json"
active = tmp_path / "active.json"
expired.write_text(json.dumps({"expires_at_unix": time.time() - 1}), encoding="utf-8")
active.write_text(json.dumps({"expires_at_unix": time.time() + 60}), encoding="utf-8")
monkeypatch.setattr(api, "_async_job_dir", lambda scope: tmp_path)
result = api.prune_expired_async_jobs()
assert result["removed"] == 1
assert not expired.exists()
assert active.exists()
def test_rate_limit_is_configurable_and_rejects_over_budget(monkeypatch):
api._RATE_LIMIT_BUCKETS.clear()
monkeypatch.setenv("JYOTISH_API_RATE_LIMIT_PER_MINUTE", "1")
api.enforce_rate_limit("test-client", now=0)
with pytest.raises(api.RateLimited):
api.enforce_rate_limit("test-client", now=1)
def test_sqlite_async_job_backend_preserves_token_and_ttl(monkeypatch, tmp_path):
monkeypatch.setenv("JYOTISH_ASYNC_JOB_BACKEND", "sqlite")
monkeypatch.setattr(api, "_sqlite_job_db_path", lambda: tmp_path / "jobs.sqlite3")
record = {"job_id": "job_sqlite", "access_token_hash": api._access_token_hash("secret"), "expires_at_unix": time.time() + 60}
api._write_async_job_record("test_scope", "job_sqlite", record)
assert api._load_async_job_record("test_scope", "job_sqlite", access_token="secret")["job_id"] == "job_sqlite"
with pytest.raises(api.JobAccessDenied):
api._load_async_job_record("test_scope", "job_sqlite", access_token="wrong")
+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 @@
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Jyotish Evidence Packet</title>
<style>
body{margin:0;background:#f5f7f8;color:#17212b;font:15px system-ui,-apple-system,"PingFang SC",sans-serif}
main{max-width:960px;margin:0 auto;padding:28px 18px 56px}.bar{display:grid;grid-template-columns:1fr 1fr auto;gap:8px;margin:18px 0}
input,button{font:inherit;padding:10px;border:1px solid #b6c1c6;border-radius:4px}button{background:#006b6b;color:white;border-color:#006b6b;cursor:pointer}
section{background:white;border:1px solid #dce3e5;border-radius:6px;padding:18px;margin:14px 0}h1,h2{margin:0 0 10px}h1{font-size:24px}h2{font-size:17px}pre{white-space:pre-wrap;word-break:break-word;margin:0}.badge{display:inline-block;padding:3px 8px;border-radius:99px;background:#e1f2f0;color:#075d5a}
@media(max-width:640px){.bar{grid-template-columns:1fr}main{padding:20px 12px}}
</style>
<main>
<h1>Evidence Packet</h1>
<p>仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。</p>
<div class="bar"><input id="url" placeholder="/api/evidence_packet/high_rigor_workflow/{job_id}"><input id="token" placeholder="access token"><button id="load">加载</button></div>
<section><h2>运行状态</h2><div id="status">等待加载</div></section>
<section><h2>Technique Audit</h2><pre id="audit">-</pre></section>
<section><h2>Machine Evidence Packet</h2><pre id="packet">-</pre></section>
<section><h2>Warnings</h2><pre id="warnings">-</pre></section>
</main>
<script>
const show=(id,value)=>document.getElementById(id).textContent=JSON.stringify(value,null,2);
document.getElementById('load').onclick=async()=>{const url=document.getElementById('url').value.trim(),token=document.getElementById('token').value.trim();if(!url||!token)return alert('需要 job URL 与 access token');const r=await fetch(url,{headers:{Authorization:`Bearer ${token}`}});const d=await r.json();if(!r.ok){show('status',d);return}const s=d.execution_status||{};document.getElementById('status').innerHTML=`<span class="badge">${s.official_evidence_status||'unknown'}</span><pre>${JSON.stringify(s,null,2)}</pre>`;show('audit',d.technique_audit||[]);show('packet',d.machine_evidence_packet||{});show('warnings',d.warnings||[])};
</script>
</html>
+9
View File
@@ -0,0 +1,9 @@
<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Jyotish Consultation</title>
<style>body{margin:0;background:#f5f7f8;color:#17212b;font:16px system-ui,-apple-system,"PingFang SC",sans-serif}main{max-width:820px;margin:auto;padding:36px 16px}section{background:#fff;border:1px solid #dce3e5;border-radius:6px;padding:18px;margin:14px 0}a,button{display:inline-block;padding:10px 14px;margin:4px 6px 4px 0;border-radius:4px;background:#006b6b;color:#fff;text-decoration:none;border:0;font:inherit}input{padding:9px;border:1px solid #b6c1c6;border-radius:4px}#location{white-space:pre-wrap}</style>
<main><h1>Jyotish Consultation</h1><p>先确认出生资料,再选择直接排盘或主动问询式生时校正。外部引擎状态将在证据包中明示。</p>
<section><h2>开始</h2><a href="/rectification">生时不确定:主动问询校正</a><a href="/evidence">查看 Evidence Packet</a></section>
<section><h2>出生地点确认</h2><p>可使用本地城市库;未收录时请手填经纬度。此操作不调用第三方地理服务。</p><input id="city" placeholder="城市名称,例如 北京 / Beijing"><button id="resolve">确认坐标</button><div id="location"></div></section>
<section><h2>运行环境</h2><button id="doctor">检查 API</button><pre id="status">等待检查</pre></section></main>
<script>const out=(id,v)=>document.getElementById(id).textContent=JSON.stringify(v,null,2);document.getElementById('resolve').onclick=async()=>{const city=document.getElementById('city').value.trim();const r=await fetch('/api/location/resolve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({city})});out('location',await r.json())};document.getElementById('doctor').onclick=async()=>{const r=await fetch('/api/health');out('status',await r.json())};</script></html>
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>主动问询式生时校正</title>
<style>body{margin:0;background:#f5f7f8;color:#17212b;font:15px system-ui,-apple-system,"PingFang SC",sans-serif}main{max-width:860px;margin:auto;padding:28px 16px}form,.question,#result{background:#fff;border:1px solid #dce3e5;border-radius:6px;padding:16px;margin:12px 0}input,button{padding:9px;font:inherit;border:1px solid #b6c1c6;border-radius:4px}button{background:#006b6b;color:#fff;border-color:#006b6b}.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.question label{display:block;padding:5px 0}pre{white-space:pre-wrap;word-break:break-word}@media(max-width:600px){.grid{grid-template-columns:1fr}}</style>
<main><h1>主动问询式生时校正</h1><p>先扫描候选时间,再回答选择题。结果只缩小候选簇,不宣称已经精确到分钟。</p>
<form id="birth"><div class="grid"><input name="year" placeholder="出生年" required><input name="month" placeholder="月" required><input name="day" placeholder="日" required><input name="hour" placeholder="时" required><input name="minute" placeholder="分" required><input name="lat" placeholder="纬度" required><input name="lon" placeholder="经度" required><input name="tz" value="8" placeholder="时区" required><input name="time_uncertainty_minutes" value="30" placeholder="误差分钟"></div><p><button>生成第一轮问题并扫描候选盘</button></p></form><div id="error" role="alert"></div><div id="scan"></div><div id="questions"></div><div id="result"></div></main>
<script>
let questionnaire;
const asObject=f=>Object.fromEntries(new FormData(f).entries());
const fail=e=>document.querySelector('#error').textContent=`请求失败:${e.message}。请检查输入后重试。`;
async function post(url,body){const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});const d=await r.json();if(!r.ok)throw new Error(d.error||r.status);return d}
document.querySelector('#birth').onsubmit=async e=>{e.preventDefault();document.querySelector('#error').textContent='正在计算候选盘…';try{const p=asObject(e.target);for(const k of Object.keys(p))p[k]=Number(p[k]);const [q,scan]=await Promise.all([post('/api/rectification/questionnaire',p),post('/api/rectification/sensitivity_scan',p)]);questionnaire=q;document.querySelector('#error').textContent='';document.querySelector('#scan').innerHTML=`<h2>实际候选盘差异</h2><pre>${JSON.stringify({candidate_count:scan.candidate_count,step_minutes:scan.step_minutes,transitions:scan.transitions,supported_vargas:scan.supported_vargas,unavailable_vargas:scan.unavailable_vargas,pending_layers:scan.pending_layers,boundary:scan.boundary},null,2)}</pre>`;render(questionnaire.questions||[])}catch(err){fail(err)}};
function render(qs){const root=document.querySelector('#questions');root.innerHTML=qs.map(q=>`<section class="question"><strong>${q.prompt}</strong>${q.options.map(o=>`<label><input type="radio" name="${q.id}" value="${o.key}"> ${o.key}. ${o.label}</label>`).join('')}</section>`).join('')+'<button id="score">提交本轮答案</button>';document.querySelector('#score').onclick=score}
async function score(){try{const answers={};document.querySelectorAll('#questions input:checked').forEach(e=>answers[e.name]=e.value);const d=await post('/api/rectification/answers',{questionnaire,answers});document.querySelector('#result').innerHTML=`<h2>候选簇排序</h2><pre>${JSON.stringify({candidate_cluster_rankings:d.candidate_cluster_rankings,next_round:d.next_round,boundary:d.boundary},null,2)}</pre>`}catch(err){fail(err)}}
</script></html>