From a6aa84e9050f34477f690a359ccc1f798fdc5b5d Mon Sep 17 00:00:00 2001 From: 732642856 <732642856@qq.com> Date: Fri, 17 Jul 2026 17:27:11 +0800 Subject: [PATCH] Compute synastry from chart library profiles --- frontend/src/app/api/synastry/route.ts | 85 +++++++++++++++++++++++ frontend/src/app/page.tsx | 30 +++++++- tests/test_supabase_user_data_contract.py | 17 +++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/api/synastry/route.ts diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts new file mode 100644 index 00000000..4c4ae891 --- /dev/null +++ b/frontend/src/app/api/synastry/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; +import { chinaLocations } from "@/data/china-locations"; + +type Profile = { + name?: string; + date?: string; + time?: string; + countryCode?: "CN"; + provinceCode?: string; + cityCode?: string; + districtCode?: string; +}; + +const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; +const china = chinaLocations.country; + +function birthPayload(profile: Profile) { + const [year, month, day] = String(profile.date || "").split("-").map(Number); + const [hour, minute] = String(profile.time || "").split(":").map(Number); + const province = china.provinces.find((item) => item.code === profile.provinceCode); + const city = province?.cities.find((item) => item.code === profile.cityCode); + const district = city?.districts.find((item) => item.code === profile.districtCode); + const location = district ?? city; + if (![year, month, day, hour, minute].every(Number.isFinite) || !location) { + throw new Error("birth_profile_incomplete"); + } + return { + year, month, day, hour, minute, + second: 0, + lat: location.center[1], + lon: location.center[0], + tz: china.timezone, + }; +} + +function moonLongitude(chart: Record) { + const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record : {}; + const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record : {}; + const value = moon.lon ?? moon.longitude ?? moon.degree; + const numeric = Number(value); + if (!Number.isFinite(numeric)) throw new Error("moon_longitude_missing"); + return numeric; +} + +async function postPython(path: string, body: unknown) { + const response = await fetch(`${apiBase}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(45_000), + }); + const data = await response.json().catch(() => null); + if (!response.ok || !data || typeof data !== "object") { + throw new Error(`jyotish_api_${response.status}`); + } + return data as Record; +} + +export async function POST(request: Request) { + try { + const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile } | null; + if (!body?.selfProfile || !body.partnerProfile) { + return NextResponse.json({ error: "请提供双方星盘资料" }, { status: 400 }); + } + const selfChart = await postPython("/api/chart", birthPayload(body.selfProfile)); + const partnerChart = await postPython("/api/chart", birthPayload(body.partnerProfile)); + const synastry = await postPython("/api/synastry", { + male_moon: moonLongitude(selfChart), + female_moon: moonLongitude(partnerChart), + }); + return NextResponse.json({ + status: "ok", + method: "ashtakoot_from_computed_moon", + selfChart: { moon: moonLongitude(selfChart) }, + partnerChart: { moon: moonLongitude(partnerChart) }, + synastry, + }); + } catch (error) { + return NextResponse.json({ + status: "blocked", + error: error instanceof Error ? error.message : "synastry_unavailable", + message: "合盘计算暂时不可用;可先保留合盘问题草稿。", + }, { status: 503 }); + } +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ec32933f..7cb0f21a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1247,9 +1247,33 @@ export default function Home() { window.requestAnimationFrame(() => composerInput.current?.focus()); } - function draftSynastryQuestionFromChart(record: ChartLibraryRecord) { + async function draftSynastryQuestionFromChart(record: ChartLibraryRecord) { if (record.role !== "other") return; - chooseSuggestedQuestion(buildSynastryQuestion(profile, record.profile), "marriage"); + const baseQuestion = buildSynastryQuestion(profile, record.profile); + try { + const response = await fetch("/api/synastry", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ selfProfile: profile, partnerProfile: record.profile }), + }); + const payload = await response.json().catch(() => null) as { status?: string; synastry?: { total_score?: number; max_score?: number; assessment?: string } } | null; + if (response.ok && payload?.status === "ok") { + const score = payload.synastry?.total_score; + const max = payload.synastry?.max_score; + const assessment = payload.synastry?.assessment; + chooseSuggestedQuestion([ + baseQuestion, + "", + `已计算基础合盘证据:Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`, + ].join("\n"), "marriage"); + } else { + chooseSuggestedQuestion(baseQuestion, "marriage"); + setComposerNotice(payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。"); + } + } catch { + chooseSuggestedQuestion(baseQuestion, "marriage"); + setComposerNotice("合盘计算暂时不可用,已先生成问题草稿。"); + } setProfileOpen(false); } @@ -1950,7 +1974,7 @@ export default function Home() { {record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}
- +
diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index 188156d3..5c051a4b 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -33,6 +33,7 @@ CHART_PROFILE_MIGRATION = ( PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx" CHART_PROFILE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "route.ts" CHART_PROFILE_DELETE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "[id]" / "route.ts" +SYNASTRY_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "synastry" / "route.ts" def _sql() -> str: @@ -142,6 +143,8 @@ def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None: "deleteCloudChartProfile", "buildSynastryQuestion", "draftSynastryQuestionFromChart", + 'fetch("/api/synastry"', + "Ashtakoot", "chartLibraryStorageKey", "Cloud chart library is best-effort", "星盘库", @@ -154,6 +157,20 @@ def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None: assert "ayanam-sessions" not in page +def test_synastry_route_orchestrates_python_chart_and_ashtakoot() -> None: + route = SYNASTRY_ROUTE.read_text(encoding="utf-8") + for token in ( + 'const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"', + 'postPython("/api/chart", birthPayload(body.selfProfile))', + 'postPython("/api/chart", birthPayload(body.partnerProfile))', + 'postPython("/api/synastry"', + "moonLongitude(selfChart)", + "ashtakoot_from_computed_moon", + 'status: "blocked"', + ): + assert token in route + + def test_consultation_credit_lifecycle_is_idempotent_and_server_only() -> None: sql = re.sub(r"\s+", " ", CONSULTATION_MIGRATION.read_text(encoding="utf-8").lower()).strip()