feat: add Railway-ready Jyotish chat product

This commit is contained in:
Jesse_Chen
2026-07-15 22:23:17 +08:00
parent 4a268c75f2
commit 4aa0105fa4
77 changed files with 15439 additions and 80 deletions
+59
View File
@@ -0,0 +1,59 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PAGE = ROOT / "frontend" / "src" / "app" / "page.tsx"
AGENT = ROOT / "frontend" / "src" / "mastra" / "index.ts"
ONBOARDING_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "onboarding" / "route.ts"
CONSULT_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "consult" / "route.ts"
ONBOARDING_MIGRATION = (
ROOT
/ "frontend"
/ "supabase"
/ "migrations"
/ "20260715040000_agent_onboarding_cache.sql"
)
def test_onboarding_and_agent_suggestion_contract() -> None:
page = PAGE.read_text(encoding="utf-8")
agent = AGENT.read_text(encoding="utf-8")
route = ONBOARDING_ROUTE.read_text(encoding="utf-8")
consult_route = CONSULT_ROUTE.read_text(encoding="utf-8")
migration = ONBOARDING_MIGRATION.read_text(encoding="utf-8")
assert "onboarding-card" in page
assert 'type OnboardingStep = "name" | "birth" | "place"' in page
assert "text.slice(0, length)" in page
assert "window.setInterval" in page
assert "prefers-reduced-motion: reduce" in page
assert "用于计算星盘,并安全保存到你的账号" not in page
assert "saveOnboardingName" in page
assert "saveOnboardingBirth" in page
assert "saveOnboardingPlace" in page
assert "<BirthMomentFields value={profileDraft}" in page
assert "<BirthLocationFields value={profileDraft}" in page
assert "Enter 确认称呼" in page
assert 'fetch("/api/onboarding"' in page
assert "onboarding?.suggestions" in page
assert "parseAgentReply(answer, theme)" in page
assert "suggestions: reply.suggestions" in page
assert "message.suggestions.map" in page
assert "export const onboardingAgent" in agent
assert "skills: [jyotishSkillPath]" in agent
assert "This is onboarding, not a chart reading" in agent
assert "<!--AYANAM_SUGGESTIONS:" in agent
assert "grounded in the answer just given" in agent
assert "Treat the server-provided current time as authoritative" in agent
assert "supabase.auth.getUser()" in route
assert "function currentTimeContext(now = new Date())" in consult_route
assert "currentTimeContext()," in consult_route
assert "中国标准时间(UTC+8" in consult_route
assert 'profile.onboarding_version === ONBOARDING_VERSION' in route
assert "onboardingAgent.generate" in route
assert 'source: "cache"' in route
assert "onboarding_payload" in migration
assert "to service_role" in migration
assert "to authenticated" not in migration
@@ -0,0 +1,37 @@
"""Regression coverage for starting the API server as a script."""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
API_SERVER = ROOT / "scripts" / "jyotish_api_server.py"
def test_script_entrypoint_can_lazy_import_scripts_package_from_another_cwd() -> None:
"""The documented script command must retain repository-root package imports."""
program = f"""
import runpy
import sys
from pathlib import Path
root = Path({str(ROOT)!r})
sys.path[:] = [entry for entry in sys.path if Path(entry or '.').resolve() != root]
namespace = runpy.run_path({str(API_SERVER)!r}, run_name='jyotish_api_server_script_entrypoint')
handler = namespace['JyotishAPIHandler'].__new__(namespace['JyotishAPIHandler'])
result = handler._compute_vedastro_gateway_archives()
assert isinstance(result, dict)
"""
completed = subprocess.run(
[sys.executable, "-c", program],
cwd="/tmp",
capture_output=True,
text=True,
check=False,
timeout=20,
)
assert completed.returncode == 0, completed.stderr
+280
View File
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Chat-facing consultation contract regression tests."""
from __future__ import annotations
import os
import sys
SCRIPTS = os.path.join(os.path.dirname(__file__), '..', 'scripts')
if SCRIPTS not in sys.path:
sys.path.insert(0, SCRIPTS)
from jyotish_api_server import ( # noqa: E402
JyotishAPIHandler,
_attach_local_consultation_layers,
_build_consumer_context,
)
from unified_consultation_orchestrator import UnifiedConsultationOrchestrator # noqa: E402
def _handler() -> JyotishAPIHandler:
return JyotishAPIHandler.__new__(JyotishAPIHandler)
def _base_chart() -> dict:
longitudes = {
'Sun': 120.9,
'Moon': 31.4,
'Mars': 173.2,
'Mercury': 140.1,
'Jupiter': 222.1,
'Venus': 120.2,
'Saturn': 329.5,
'Rahu': 215.8,
'Ketu': 35.8,
}
asc_lon = 205.19
asc_idx = int(asc_lon / 30)
planets = {
name: {
'lon': lon,
'degree': lon % 30,
'sign_idx': int(lon / 30),
'house': ((int(lon / 30) - asc_idx) % 12) + 1,
}
for name, lon in longitudes.items()
}
return {
'success': True,
'ascendant': {'lon': asc_lon, 'sign_idx': asc_idx, 'sign': 'Libra'},
'planets': planets,
'houses': {house: {'sign_idx': (asc_idx + house - 1) % 12} for house in range(1, 13)},
'dasha': {'current_md': 'Sun'},
'modules': {'dasha': {'current_md': 'Sun'}},
}
def test_local_consultation_layers_supply_d10_a10_and_narayana_without_vedastro() -> None:
chart = _attach_local_consultation_layers(
_handler(),
_base_chart(),
{'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0},
{'current_date': '2026-07-14'},
)
modules = chart['modules']
assert 'D9_Navamsa' in modules['varga_full']
assert 'D10_Dasamsa' in modules['varga_full']
assert modules['arudha_padas']['padas']['A10']['name'] == 'Karma Pada (A10)'
assert modules['narayana_dasha']['current_dasha']['md']
assert chart['local_consultation_layers']['status'] == 'ready'
packet = UnifiedConsultationOrchestrator().machine_evidence_packet(
chart=chart,
route_packet={'question_type': 'career', 'primary_theme': 'career'},
vedastro_official={'status': 'blocked', 'runtime_truth': {'status': 'blocked'}},
)
assert packet['sections']['D10']['status'] == 'used'
assert packet['sections']['A10']['status'] == 'used'
assert packet['sections']['narayana_dasha']['status'] == 'used'
def test_consumer_context_treats_unconfigured_vedastro_as_optional_cross_check() -> None:
chart = _attach_local_consultation_layers(
_handler(),
_base_chart(),
{'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0},
{'current_date': '2026-07-14'},
)
orchestrator = UnifiedConsultationOrchestrator()
route = orchestrator.resolve_route('请分析我的事业方向', ['career'])
official = {
'status': 'service_endpoint_not_configured',
'runtime_truth': {
'status': 'partial',
'official_execution_layers': {'chart_core': 'blocked'},
'fallback_active': False,
},
}
packet = orchestrator.machine_evidence_packet(
chart=chart,
route_packet=route,
vedastro_official=official,
)
context = _build_consumer_context(
question='请分析我的事业方向',
route_packet=route,
chart=chart,
rectification={
'summary': {
'headline': '可读主盘,但高敏分盘需要降级',
'warned': ['D9', 'D10'],
'disabled': [],
},
},
machine_evidence_packet=packet,
vedastro_official=official,
)
assert context['core_status'] == 'ready'
assert context['hard_blockers'] == []
assert context['missing_route_layers'] == []
assert context['answer_policy']['can_answer_direction'] is True
assert context['answer_policy']['should_lead_with_limitations'] is False
assert context['answer_policy']['provider_unavailable_is_fatal'] is False
assert context['optional_unavailable_layers'][0]['layer'] == 'vedastro_official_cross_check'
def test_consumer_context_only_leads_with_limits_for_unavailable_precise_timing() -> None:
orchestrator = UnifiedConsultationOrchestrator()
route = orchestrator.resolve_route('具体哪一个月份适合跳槽?', ['career'])
packet = {
'sections': {
'D1': {'status': 'used'},
'D10': {'status': 'used'},
'A10': {'status': 'used'},
'dasha_boundaries': {'status': 'used'},
'narayana_dasha': {'status': 'missing'},
'external_oracle_status': {'status': 'official_blocked'},
},
}
context = _build_consumer_context(
question='具体哪一个月份适合跳槽?',
route_packet=route,
chart={'success': True},
rectification={'summary': {'warned': ['D10'], 'disabled': []}},
machine_evidence_packet=packet,
vedastro_official={'status': 'blocked'},
)
assert context['core_status'] == 'degraded'
assert context['answer_policy']['can_answer_direction'] is True
assert context['answer_policy']['can_answer_precise_timing'] is False
assert context['answer_policy']['should_lead_with_limitations'] is True
def test_thematic_career_evidence_uses_local_d10_a10_and_narayana() -> None:
handler = _handler()
chart = _attach_local_consultation_layers(
handler,
_base_chart(),
{'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0},
{'current_date': '2026-07-14'},
)
items = handler._derived_career_evidence(
chart,
{
'career': {'summary': 'career ready'},
'shadbala': {},
'full_modules': chart['modules'],
},
)
by_technique = {item['technique']: item for item in items}
assert by_technique['D10-Dashamsha-local']['chart'] == 'D10'
assert by_technique['A10-Karma-Pada-local']['chart'] == 'A10'
assert by_technique['Narayana-Dasha-local']['chart'] == 'Narayana'
assert '已完成' in by_technique['D10-Dashamsha-local']['conclusion']
def test_thematic_report_reuses_attached_chart_modules_without_claiming_d10_missing(monkeypatch) -> None:
handler = _handler()
chart = _attach_local_consultation_layers(
handler,
_base_chart(),
{'year': 1995, 'month': 8, 'day': 18, 'hour': 12, 'minute': 0},
{'current_date': '2026-07-14'},
)
monkeypatch.setattr(handler, '_compute_dasha_system', lambda body: chart['modules']['dasha'])
monkeypatch.setattr(handler, '_compute_yogas_api', lambda body: {})
monkeypatch.setattr(handler, '_compute_shadbala', lambda body: {})
monkeypatch.setattr(handler, '_compute_ashtakavarga', lambda body: {})
monkeypatch.setattr(handler, '_compute_relationship', lambda body: {})
monkeypatch.setattr(handler, '_compute_career', lambda body: {'summary': 'career ready'})
monkeypatch.setattr(handler, '_compute_jaimini', lambda body: chart['modules'].get('jaimini', {}))
result = handler._compute_thematic_report({
'theme': ['career'],
'chart_data': {
**chart,
'skip_full_reading_for_thematic': True,
},
'skip_full_reading_for_thematic': True,
})
career = result['themes']['career']
techniques = {item['technique'] for item in career['evidence']}
assert result['mode'] == 'derived_chart_evidence'
assert result['evidence_source']['source'] == 'reused_chart_modules'
assert result['evidence_source']['chart_modules_reused'] is True
assert 'D10-Dashamsha-local' in techniques
assert 'A10-Karma-Pada-local' in techniques
assert 'Narayana-Dasha-local' in techniques
assert 'Dashamsha 未提供显著信息' not in career['narrative']
def test_strict_narrative_tolerates_optional_none_contracts() -> None:
from jyotish_engine import _base_strict_narrative_payload
payload = _base_strict_narrative_payload(
'事业',
{
'event_judgement': None,
'adjudication_stages': None,
'prediction_boundary_contract': None,
},
fallback_headline='事业结构可读',
strengths=[],
risks=[],
boundaries=[],
)
assert payload['headline'] == '事业结构可读'
assert 'confidence_cap: unknown' in payload['markdown']
def test_consumer_context_does_not_surface_optional_provider_as_user_limitation() -> None:
context = _build_consumer_context(
question='请分析我的事业方向',
route_packet={'question_type': 'career', 'primary_theme': 'career'},
chart={'success': True},
rectification={'summary': {'warned': ['D9'], 'disabled': []}},
machine_evidence_packet={
'sections': {
'D1': {'status': 'used'},
'D10': {'status': 'used'},
'A10': {'status': 'used'},
'dasha_boundaries': {'status': 'used'},
'narayana_dasha': {'status': 'used'},
'external_oracle_status': {'status': 'official_blocked'},
},
},
vedastro_official={'status': 'blocked'},
)
assert context['user_facing_limitation'] is None
assert context['optional_unavailable_layers'][0]['layer'] == 'vedastro_official_cross_check'
def test_failed_vedastro_raw_packet_is_not_marked_as_used() -> None:
packet = UnifiedConsultationOrchestrator().machine_evidence_packet(
chart=_base_chart(),
route_packet={'question_type': 'career', 'primary_theme': 'career'},
vedastro_official={
'status': 'partial',
'runtime_truth': {
'status': 'partial',
'official_execution_layers': {'chart_core': 'blocked'},
},
'raw_response': {
'sections': {
'chart_core': {'Status': 'Fail', 'Payload': {'status': 'python_package_not_installed'}},
},
},
},
)
assert packet['sections']['vedastro_official_raw_response']['status'] == 'received_unverified'
+5 -5
View File
@@ -2442,8 +2442,8 @@ def test_desktop_packaging_spike_is_documented_and_checkable() -> None:
assert "desktop_packaging_preflight.py" in readme
assert "tests/run_frontend_click_smoke.py --mode all" in readme
assert "普通用户启动路径" in readme
assert "先启动网页服务:cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173" in readme
assert "再启动本地 API 服务:python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200" in readme
assert "先启动本地 API 服务:`.venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200`" in readme
assert "再启动网页服务:`cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173`" in readme
assert "打开 Trust Center,点击运行健康检查" in readme
assert "PWA 安装壳只包装网页服务,本地 API 服务仍需单独启动" in readme
assert "安装后首次打开" in spike
@@ -2458,7 +2458,7 @@ def test_desktop_packaging_spike_is_documented_and_checkable() -> None:
quality_gate = (ROOT / "scripts" / "run_quality_gate.py").read_text(encoding="utf-8")
assert "普通用户启动路径" in quality_gate
assert "cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173" in quality_gate
assert "python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200" in quality_gate
assert ".venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200" in quality_gate
def test_user_delivery_matrix_is_documented_and_checkable() -> None:
@@ -2477,7 +2477,7 @@ def test_user_delivery_matrix_is_documented_and_checkable() -> None:
"desktop-shell",
"public demo shell",
"api_required",
"python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
".venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
"npm run preview -- --host 127.0.0.1 --port 4173",
"http://localhost:5300",
]:
@@ -2755,7 +2755,7 @@ def test_user_startup_labels_are_consistent_across_recovery_surfaces() -> None:
assert token in app_text
for command in [
"cd jyotish-app && npm run dev -- --host 127.0.0.1 --port 5173",
"python3 scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
".venv/bin/python scripts/jyotish_api_server.py --host 127.0.0.1 --port 5200",
]:
assert command in readme
assert command in quality_gate
+27
View File
@@ -0,0 +1,27 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LOGIN = ROOT / "frontend" / "src" / "app" / "login" / "page.tsx"
README = ROOT / "frontend" / "README.md"
def test_email_login_sends_and_verifies_otp() -> None:
source = LOGIN.read_text(encoding="utf-8")
readme = README.read_text(encoding="utf-8")
assert ".auth.signInWithOtp" in source
assert ".auth.verifyOtp" in source
assert 'type: "email"' in source
assert "emailRedirectTo" not in source
assert "发送验证码" in source
assert "{{ .Token }}" in readme
def test_admin_pages_are_guarded_server_side() -> None:
layout = (ROOT / "frontend" / "src" / "app" / "admin" / "layout.tsx").read_text(encoding="utf-8")
assert "createServerSupabaseClient" in layout
assert "isAdminEmail(user.email)" in layout
assert 'redirect("/login")' in layout
assert 'redirect("/")' in layout
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def test_railway_services_use_the_product_frontend_and_dynamic_ports() -> None:
web = (ROOT / "deploy" / "railway-web.Dockerfile").read_text(encoding="utf-8")
api = (ROOT / "deploy" / "railway-api.Dockerfile").read_text(encoding="utf-8")
assert "COPY frontend/package.json frontend/package-lock.json" in web
assert "--hostname 0.0.0.0" in web and "${PORT:-3000}" in web
assert "next-env.d.ts" not in web
assert "COPY SKILL.md mcp_server.py" in api
assert "--host 0.0.0.0" in api and "${PORT:-5200}" in api
assert "http.server" not in api
+103
View File
@@ -0,0 +1,103 @@
import re
from pathlib import Path
MIGRATION = (
Path(__file__).resolve().parents[1]
/ "frontend"
/ "supabase"
/ "migrations"
/ "20260715030000_user_profiles_chat_sessions.sql"
)
COORDS_MIGRATION = (
Path(__file__).resolve().parents[1]
/ "frontend"
/ "supabase"
/ "migrations"
/ "20260715050000_profile_coordinates.sql"
)
PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx"
def _sql() -> str:
return re.sub(r"\s+", " ", MIGRATION.read_text(encoding="utf-8").lower()).strip()
def test_user_profile_and_chat_session_database_contract() -> None:
sql = _sql()
for definition in (
"name text",
"birth_date date",
"birth_time time without time zone",
"country_code text",
"province_code text",
"city_code text",
"district_code text",
):
assert f"add column if not exists {definition}" in sql
assert "create policy profiles_update_own" in sql
assert "for update to authenticated using ((select auth.uid()) = id) with check ((select auth.uid()) = id)" in sql
assert "grant update ( name, birth_date, birth_time, country_code, province_code, city_code, district_code, updated_at ) on table public.profiles to authenticated" in sql
assert "create table if not exists public.chat_sessions" in sql
for definition in (
"id uuid primary key default gen_random_uuid()",
"user_id uuid not null references auth.users(id) on delete cascade",
"title text not null default '新对话'",
"theme text not null default 'general'",
"messages jsonb not null default '[]'::jsonb",
"created_at timestamptz not null default now()",
"updated_at timestamptz not null default now()",
):
assert definition in sql
assert "check (theme in ('career', 'marriage', 'timing', 'general'))" in sql
assert "check (jsonb_typeof(messages) = 'array')" in sql
assert "alter table public.chat_sessions enable row level security" in sql
assert "create policy chat_sessions_select_own on public.chat_sessions for select to authenticated using ((select auth.uid()) = user_id)" in sql
assert "create policy chat_sessions_insert_own on public.chat_sessions for insert to authenticated with check ((select auth.uid()) = user_id)" in sql
assert "create policy chat_sessions_update_own on public.chat_sessions for update to authenticated using ((select auth.uid()) = user_id) with check ((select auth.uid()) = user_id)" in sql
assert "revoke all on table public.chat_sessions from anon, authenticated, service_role" in sql
assert "grant select on table public.chat_sessions to authenticated" in sql
assert "grant insert (id, user_id, title, theme, messages, updated_at) on table public.chat_sessions to authenticated" in sql
assert "grant update (title, theme, messages, updated_at) on table public.chat_sessions to authenticated" in sql
assert "grant delete" not in sql
def test_chat_page_uses_authenticated_cloud_persistence() -> None:
source = PAGE.read_text(encoding="utf-8")
assert '.from("profiles")' in source
assert '.from("chat_sessions")' in source
assert 'user_id: account.user.id' in source
assert '.upsert(' not in source
assert '.update(values)' in source
assert '.insert({' in source
assert source.index('await persistSession(userSession)') < source.index('setOnboardingJustCompleted(false)')
assert source.index('await persistSession(userSession)') < source.index('updateSession(sessionId, () => userSession)')
assert 'function completedOnboardingTranscript(profile: Profile): Message[]' in source
assert 'messages: [...preservedMessages, { role: "user", text: question }]' in source
assert "localStorage" not in source
assert "ayanam-profile" not in source
assert "ayanam-sessions" not in source
def test_profile_coordinates_are_persisted_with_database_bounds() -> None:
sql = re.sub(r"\s+", " ", COORDS_MIGRATION.read_text(encoding="utf-8").lower()).strip()
source = PAGE.read_text(encoding="utf-8")
for definition in (
"latitude double precision",
"longitude double precision",
"timezone_offset double precision",
):
assert f"add column if not exists {definition}" in sql
assert "latitude between -90 and 90" in sql
assert "longitude between -180 and 180" in sql
assert "timezone_offset between -12 and 14" in sql
assert "grant update (latitude, longitude, timezone_offset) on table public.profiles to authenticated" in sql
assert "latitude: birthPlace?.lat ?? null" in source
assert "longitude: birthPlace?.lon ?? null" in source
assert "timezone_offset: birthPlace?.tz ?? null" in source
@@ -0,0 +1,82 @@
from __future__ import annotations
import sys
def _birth_case() -> dict[str, object]:
return {
"year": 1992,
"month": 8,
"day": 17,
"hour": 14,
"minute": 30,
"lat": 23.1291,
"lon": 113.2644,
"tz": 8,
"reference_date": "2026-07-14",
}
def test_vedastro_child_process_defaults_to_active_interpreter(monkeypatch) -> None:
from scripts import vedastro_service_adapter
monkeypatch.delenv("VEDASTRO_PYTHON_BIN", raising=False)
monkeypatch.delenv("PYTHON_BIN", raising=False)
assert vedastro_service_adapter._vedastro_python_bin() == sys.executable
def test_fast_snapshot_executes_only_five_scalar_methods(monkeypatch) -> None:
from scripts import vedastro_official_capability_runner as runner
calls: list[str] = []
def fake_call(method: str, _payload: dict[str, object]) -> dict[str, object]:
calls.append(method)
return {"available": True, "status": "ok", "result": {"method": method}}
monkeypatch.setattr(runner, "SNAPSHOT_FANOUT_ENABLED", False)
monkeypatch.setattr(runner, "_call_bridge", fake_call)
result = runner.run_snapshot_bundle("official_full_snapshot", _birth_case())
assert calls == [
"DasaAtRange",
"DasaAtTime",
"GetCharaDasaAtTime",
"AllPlanetStrength",
"AshtakvargaLifeMap",
]
assert result["available"] is True
assert result["summary"]["requested_method_count"] == 5
assert result["summary"]["fanout_enabled"] is False
assert set(result["result"]["snapshot_sections"]) == {
"dasha_all",
"vimshottari_now",
"chara_dasha_now",
"shadbala",
"ashtakavarga",
}
def test_disabled_range_scan_never_calls_network(monkeypatch) -> None:
from scripts import vedastro_service_adapter
monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://api.vedastro.org/api")
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1")
monkeypatch.setenv("VEDASTRO_RANGE_SCAN_NETWORK_ENABLED", "0")
def fail_if_called(*_args, **_kwargs):
raise AssertionError("range scan network request should not run in chat mode")
monkeypatch.setattr(vedastro_service_adapter, "_post_json_with_retry", fail_if_called)
result = vedastro_service_adapter.run_range_scan_for_case(
_birth_case(),
"career",
"2026-07-14",
"2026-08-14",
)
assert result["status"] == "network_execution_disabled"
assert "interactive chat path" in result["reason"]