feat: add relationship-aware synastry entry (#17)

This commit is contained in:
732642856
2026-07-20 20:21:53 +08:00
committed by GitHub
parent 54015f71d4
commit 11d64d02cd
3 changed files with 104 additions and 17 deletions
+52 -1
View File
@@ -11,6 +11,8 @@ type Profile = {
districtCode?: string;
};
type RelationshipType = "romance" | "business" | "family" | "general";
const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
const china = chinaLocations.country;
@@ -102,6 +104,30 @@ function relationshipReport(synastry: Record<string, unknown>, selfD9: Record<st
};
}
function vargaPlanetSign(varga: Record<string, unknown>, division: string, planet: string) {
const result = varga.result && typeof varga.result === "object" ? varga.result as Record<string, unknown> : {};
const chart = result[division] && typeof result[division] === "object" ? result[division] as Record<string, unknown> : {};
const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record<string, unknown> : {};
return planetSign(planet === "Ascendant" ? chart.ascendant : planets[planet]);
}
function businessReport(selfVargas: Record<string, unknown>, partnerVargas: Record<string, unknown>) {
return {
status: "partial_evidence",
scoreBand: "not_scored",
headline: "已完成基础合作结构筛查;这不是合作成败、收益或契约保证。",
strengths: [
`D10 事业轴:本人 ${vargaPlanetSign(selfVargas, "D10_Dasamsa", "Ascendant")} / 对方 ${vargaPlanetSign(partnerVargas, "D10_Dasamsa", "Ascendant")}`,
`D2 财富轴:本人 Moon ${vargaPlanetSign(selfVargas, "D2_Hora", "Moon")} / 对方 Moon ${vargaPlanetSign(partnerVargas, "D2_Hora", "Moon")}`,
`D11 收益轴:本人 Sun ${vargaPlanetSign(selfVargas, "D11_Rudramsa", "Sun")} / 对方 Sun ${vargaPlanetSign(partnerVargas, "D11_Rudramsa", "Sun")}`,
],
risks: [
"尚未完成 A10、功能吉凶、双方 Vimshottari + Narayana、Shadbala/AV 与外部数值一致性,不得据此断言合作结果或精确时点。",
],
nextEvidence: ["A10", "功能吉凶", "双方 Vimshottari + Narayana", "D10/D2/D11 原始度数与外部校验"],
};
}
async function postPython(path: string, body: unknown) {
const response = await fetch(`${apiBase}${path}`, {
method: "POST",
@@ -118,12 +144,37 @@ async function postPython(path: string, body: unknown) {
export async function POST(request: Request) {
try {
const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile } | null;
const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile; relationshipType?: RelationshipType } | 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 relationshipType = body.relationshipType === "business" || body.relationshipType === "family" || body.relationshipType === "general"
? body.relationshipType
: "romance";
if (relationshipType === "business") {
const [selfVargas, partnerVargas] = await Promise.all([
postPython("/api/varga_full", { ...birthPayload(body.selfProfile), planets: selfChart.planets, ascendant: selfChart.ascendant, divisions: ["D2", "D10", "D11"] }),
postPython("/api/varga_full", { ...birthPayload(body.partnerProfile), planets: partnerChart.planets, ascendant: partnerChart.ascendant, divisions: ["D2", "D10", "D11"] }),
]);
return NextResponse.json({
status: "ok",
relationshipType,
claimStatus: "partial",
method: "d2_d10_d11_business_screening_partial",
evidenceLayers: ["d2_hora", "d10_dashamsa", "d11_ekadashamsa"],
blockedLayers: ["A10", "functional_benefic_malefic", "vimshottari_narayana", "shadbala_ashtakavarga", "external_engine_parity"],
relationshipReport: businessReport(selfVargas, partnerVargas),
});
}
if (relationshipType !== "romance") {
return NextResponse.json({
status: "blocked",
relationshipType,
message: "该关系类型尚无可验证的专用合盘计算合同;已保留问题草稿。",
});
}
const selfD9 = await postPython("/api/varga_full", {
...birthPayload(body.selfProfile),
planets: selfChart.planets,
+30 -14
View File
@@ -89,6 +89,7 @@ type ChartLibraryRecord = {
profile: Profile;
updatedAt: number;
};
type SynastryRelationshipType = "romance" | "business" | "family" | "general";
type ChartLibraryApiRecord = {
id: string;
role: "self" | "other";
@@ -355,12 +356,18 @@ function profilePlaceLabel(profile: Profile) {
return selectedBirthPlace(profile)?.label || "地点未完整";
}
function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile) {
function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, relationshipType: SynastryRelationshipType) {
const relationshipLabel = relationshipType === "business" ? "商业合作" : relationshipType === "family" ? "亲友/家庭" : relationshipType === "general" ? "其他关系" : "婚恋";
const evidenceRequest = relationshipType === "business"
? "请先说明 D2/D10/D11 已用层与 A10、双方 Dasha/Narayana、功能吉凶等缺失层;不得给出合作成败、收益保证或精确时点。"
: relationshipType === "romance"
? "请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。"
: "请先说明当前缺少专用合盘计算合同,只基于可验证资料提出需要补充的现实关系信息,不作确定性判断。";
return [
`请用印度占星合盘分析我和${partnerProfile.name || "对方"}的关系。`,
`请用印度占星分析我和${partnerProfile.name || "对方"}${relationshipLabel}关系。`,
`我的资料:${selfProfile.name || "本人"}${selfProfile.date} ${selfProfile.time}${profilePlaceLabel(selfProfile)}`,
`对方资料:${partnerProfile.name || "对方"}${partnerProfile.date} ${partnerProfile.time}${profilePlaceLabel(partnerProfile)}`,
"请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。",
evidenceRequest,
].join("\n");
}
@@ -668,6 +675,7 @@ export default function Home() {
const [activeAccountDialog, setActiveAccountDialog] = useState<AccountDialog | null>(null);
const [chartLibrary, setChartLibrary] = useState<ChartLibraryRecord[]>([]);
const [chartLibraryOpen, setChartLibraryOpen] = useState(false);
const [synastryRelationshipType, setSynastryRelationshipType] = useState<SynastryRelationshipType>("romance");
const [synastryPendingId, setSynastryPendingId] = useState<string | null>(null);
const [otherProfileDraft, setOtherProfileDraft] = useState<Profile>(emptyProfile);
const [synastryReportCard, setSynastryReportCard] = useState<SynastryReportCard | null>(null);
@@ -1461,9 +1469,8 @@ export default function Home() {
setOtherProfileDraft(emptyProfile);
if (cloudSaved) {
setAccountError("");
setProfileNotice("已保存到云端星盘库。");
setProfileNotice("已保存到云端星盘库。请选择关系类型后点击“用于合盘”。");
}
await draftSynastryQuestionFromChart(record);
}
async function deleteOtherChart(recordId: string) {
@@ -1745,24 +1752,27 @@ export default function Home() {
);
}
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord) {
async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) {
if (record.role !== "other") return;
if (synastryPendingId) return;
const baseQuestion = buildSynastryQuestion(profile, record.profile);
const baseQuestion = buildSynastryQuestion(profile, record.profile, relationshipType);
setSynastryPendingId(record.id);
setComposerNotice("正在计算基础合盘证据,请稍候。");
try {
const response = await fetch("/api/synastry", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ selfProfile: profile, partnerProfile: record.profile }),
body: JSON.stringify({ selfProfile: profile, partnerProfile: record.profile, relationshipType }),
});
const payload = await response.json().catch(() => null) as { status?: string; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null;
const payload = await response.json().catch(() => null) as { status?: string; claimStatus?: string; blockedLayers?: string[]; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: 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;
const layers = (payload.evidenceLayers || []).join(" / ") || "Ashtakoot / Moon / D9";
const evidenceSummary = relationshipType === "business"
? `已完成基础商业合作证据筛查:${layers};声明状态:${payload.claimStatus || "partial"};未用层:${(payload.blockedLayers || []).join(" / ") || "A10 / 双方 Dasha-Narayana / 功能吉凶"}。请勿将其表述为合作保证或精确时点。`
: `已计算基础合盘证据:${layers}Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`;
const reportCard: SynastryReportCard = {
id: `${record.id}-${Date.now()}`,
partnerName: record.profile.name || "对方",
@@ -1795,15 +1805,15 @@ export default function Home() {
chooseSuggestedQuestion([
baseQuestion,
"",
`已计算基础合盘证据:${layers}Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`,
evidenceSummary,
payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "",
].join("\n"), "marriage");
].join("\n"), relationshipType === "business" ? "career" : "marriage");
} else {
chooseSuggestedQuestion(baseQuestion, "marriage");
chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage");
setComposerNotice(payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。");
}
} catch {
chooseSuggestedQuestion(baseQuestion, "marriage");
chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage");
setComposerNotice("合盘计算暂时不可用,已先生成问题草稿。");
} finally {
setSynastryPendingId(null);
@@ -2600,7 +2610,13 @@ 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={() => void draftSynastryQuestionFromChart(record)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</button>
<select aria-label="关系类型" value={synastryRelationshipType} onChange={(event) => setSynastryRelationshipType(event.target.value as SynastryRelationshipType)} disabled={synastryPendingId !== null}>
<option value="romance"></option>
<option value="business"></option>
<option value="family">/</option>
<option value="general"></option>
</select>
<button className="button-secondary" type="button" onClick={() => void draftSynastryQuestionFromChart(record, synastryRelationshipType)} disabled={synastryPendingId !== null}>{synastryPendingId === record.id ? "正在计算合盘..." : "用于合盘"}</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>
@@ -23,8 +23,9 @@ test("other chart save falls back to local library when cloud sync fails", () =>
assert.doesNotMatch(source, /deleteOtherChart[\s\S]{0,500}return;\s*}\s*setChartLibrary/);
});
test("adding another chart immediately opens the synastry path", () => {
assert.match(source, /async function saveOtherChart[\s\S]{0,1400}await draftSynastryQuestionFromChart\(record\)/);
test("adding another chart waits for the user to choose a relationship type", () => {
assert.doesNotMatch(source, /async function saveOtherChart[\s\S]{0,1400}await draftSynastryQuestionFromChart\(/);
assert.match(source, /请选择关系类型后点击“用于合盘”。/);
assert.match(source, /用于合盘/);
assert.match(source, /\/api\/synastry/);
});
@@ -55,3 +56,22 @@ test("synastry selection exposes a pending state while the evidence packet is co
assert.match(source, /disabled=\{synastryPendingId !== null\}/);
assert.match(source, /synastryPendingId === record\.id \? "正在计算合盘\.\.\." : "用于合盘"/);
});
test("relationship intent selects domain-specific evidence instead of treating every pairing as romance", () => {
const route = readFileSync(new URL("../src/app/api/synastry/route.ts", import.meta.url), "utf8");
assert.match(source, /商业合作/);
assert.match(source, /亲友\/家庭/);
assert.match(source, /其他关系/);
assert.match(source, /relationshipType: SynastryRelationshipType/);
assert.match(source, /body: JSON\.stringify\(\{ selfProfile: profile, partnerProfile: record\.profile, relationshipType \}\)/);
assert.match(route, /relationshipType === "business"/);
assert.match(route, /divisions: \["D2", "D10", "D11"\]/);
assert.match(route, /"D10_Dasamsa"/);
assert.match(route, /"D11_Rudramsa"/);
assert.match(route, /blockedLayers:/);
assert.match(route, /"A10"/);
assert.match(route, /"functional_benefic_malefic"/);
assert.match(route, /"vimshottari_narayana"/);
assert.match(source, /基础商业合作证据筛查/);
assert.match(source, /relationshipType === "business"/);
});