Compute synastry from chart library profiles

This commit is contained in:
732642856
2026-07-17 17:27:11 +08:00
parent 05e747f84e
commit a6aa84e905
3 changed files with 129 additions and 3 deletions
+85
View File
@@ -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<string, unknown>) {
const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record<string, unknown> : {};
const moon = planets.Moon && typeof planets.Moon === "object" ? planets.Moon as Record<string, unknown> : {};
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<string, unknown>;
}
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 });
}
}
+27 -3
View File
@@ -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() {
<small>{record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}</small>
</div>
<div className="chart-library-actions">
<button className="button-secondary" type="button" onClick={() => draftSynastryQuestionFromChart(record)}></button>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record)}></button>
<button className="button-secondary" type="button" onClick={() => void makeDefaultChart(record)} disabled={profileSaving}></button>
<button className="button-secondary danger-button" type="button" onClick={() => deleteOtherChart(record.id)}></button>
</div>
+17
View File
@@ -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()