Compute synastry from chart library profiles
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user