A shifted antardasha or English mahadasha was passing because the checker only recognized 大运 and 子运. Co-authored-by: Cursor <cursoragent@cursor.com>
328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import test from "node:test";
|
||
|
||
import { chatSessionCreateSchema } from "../src/lib/chat-session-write-contract.ts";
|
||
import {
|
||
chatProfileDisplayName,
|
||
chatSessionSubjectLocked,
|
||
sessionMatchesSubject,
|
||
} from "../src/lib/chat-profile-picker-model.ts";
|
||
import { SYNASTRY_CHAT_INSTRUCTIONS } from "../src/mastra/synastry-report-chat.ts";
|
||
import {
|
||
SYNASTRY_FIXED_REPLY,
|
||
auditSynastryAnswer,
|
||
settleSynastryDrafts,
|
||
} from "../src/lib/synastry-chat-fact-check.ts";
|
||
import {
|
||
SYNASTRY_CHAT_CHAR_BUDGET,
|
||
boundSynastryRead,
|
||
synastryChatContextChars,
|
||
synastrySectionMaterial,
|
||
} from "../src/lib/synastry-report-material.ts";
|
||
import { beginSynastrySectionChat } from "../src/lib/synastry-report-chat-client.ts";
|
||
import { authoritativeSynastrySession, synastrySectionQuestion } from "../src/lib/synastry-report-session.ts";
|
||
import { runSynastryReportTurn, synastryMissingCoverage } from "../src/lib/synastry-report-turn.ts";
|
||
import { buildTimelineSegments, type TimingPeriod } from "../src/lib/synastry-full-timeline.ts";
|
||
import { isListedSidebarSession } from "../src/lib/session-list-filter.ts";
|
||
import { synastryReportClosed } from "../src/lib/rectification-session-composer-guard.ts";
|
||
import type { ChatSession } from "../src/lib/home-types.ts";
|
||
|
||
const reportId = "83000000-0000-4000-8000-000000000083";
|
||
const otherReportId = "83000000-0000-4000-8000-000000000183";
|
||
const sessionId = "83000000-0000-4000-8000-000000000283";
|
||
|
||
function celebrityNote(): string {
|
||
const golden = JSON.parse(readFileSync(new URL("../../tests/golden/upstream_sync2/relationship_einstein.json", import.meta.url), "utf8")) as unknown;
|
||
const found = findNote(golden);
|
||
if (!found) throw new Error("celebrity note missing");
|
||
return found;
|
||
}
|
||
|
||
function findNote(value: unknown): string | null {
|
||
if (!value || typeof value !== "object") return null;
|
||
if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
const found = findNote(item);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
const row = value as Record<string, unknown>;
|
||
if (typeof row.note === "string" && row.note.includes("Venus在10宫")) return row.note;
|
||
for (const item of Object.values(row)) {
|
||
const found = findNote(item);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function celebrityPayload() {
|
||
const note = celebrityNote();
|
||
const dasha = "土星大运 1903-01 至 1922-01";
|
||
return {
|
||
names: { a: "Albert", b: "Mileva" },
|
||
zh: {
|
||
sections: [{
|
||
id: "climate",
|
||
title: "关系基调",
|
||
text: note,
|
||
reasons: [{ basis: `${note};${dasha}`, plain: "感情与事业阶段叠在一起。" }],
|
||
}],
|
||
},
|
||
dossier: {},
|
||
};
|
||
}
|
||
|
||
function session(change: Partial<ChatSession>): ChatSession {
|
||
return {
|
||
id: sessionId,
|
||
title: "Albert × Mileva · 合盘追问",
|
||
theme: "marriage",
|
||
modelId: "m",
|
||
messages: [],
|
||
updatedAt: 1,
|
||
sessionType: "synastry_report",
|
||
rectificationCaseId: null,
|
||
chartProfileId: "83000000-0000-4000-8000-000000000383",
|
||
chartProfileName: "Albert × Mileva",
|
||
chartProfileRole: "other",
|
||
synastryReportId: reportId,
|
||
synastrySection: "climate",
|
||
synastryPartnerSubjectId: "self",
|
||
pinned: false,
|
||
archivedAt: null,
|
||
messagesHydrated: true,
|
||
...change,
|
||
};
|
||
}
|
||
|
||
test("synastry create requires an owned section and refuses context inheritance fields", () => {
|
||
const accepted = chatSessionCreateSchema.safeParse({
|
||
id: sessionId,
|
||
title: "Albert × Mileva · 合盘追问",
|
||
theme: "marriage",
|
||
model_id: "m",
|
||
messages: [],
|
||
session_type: "synastry_report",
|
||
rectification_case_id: null,
|
||
synastry_report_id: reportId,
|
||
synastry_section: "climate",
|
||
});
|
||
assert.equal(accepted.success, true);
|
||
assert.equal(chatSessionCreateSchema.safeParse({
|
||
id: sessionId,
|
||
title: "Albert × Mileva · 合盘追问",
|
||
theme: "marriage",
|
||
model_id: "m",
|
||
messages: [],
|
||
session_type: "synastry_report",
|
||
rectification_case_id: null,
|
||
synastry_section: "practices",
|
||
}).success, false);
|
||
assert.equal(chatSessionCreateSchema.safeParse({
|
||
id: sessionId,
|
||
title: "新对话",
|
||
theme: "general",
|
||
model_id: "m",
|
||
messages: [],
|
||
session_type: "consultation",
|
||
rectification_case_id: null,
|
||
synastry_report_id: reportId,
|
||
synastry_section: "climate",
|
||
}).success, false);
|
||
});
|
||
|
||
test("session binding reads only the caller's ready synastry report", async () => {
|
||
const bound = await authoritativeSynastrySession({
|
||
reportId,
|
||
section: "timeline",
|
||
loadOwnedReport: async () => ({
|
||
report_type: "synastry_full",
|
||
status: "ready",
|
||
subject_low: "self",
|
||
subject_high: "83000000-0000-4000-8000-000000000383",
|
||
report_document: { names: { a: "Albert", b: "Mileva" } },
|
||
}),
|
||
});
|
||
assert.equal(bound.chart_profile_name, "Albert × Mileva");
|
||
assert.equal(bound.synastry_partner_subject_id, "83000000-0000-4000-8000-000000000383");
|
||
assert.equal(bound.rectification_case_id, null);
|
||
await assert.rejects(
|
||
authoritativeSynastrySession({ reportId: otherReportId, section: "climate", loadOwnedReport: async () => null }),
|
||
/synastry_report_not_owned/,
|
||
);
|
||
await assert.rejects(
|
||
authoritativeSynastrySession({ reportId, section: "practices", loadOwnedReport: async () => ({ report_type: "synastry_full", status: "ready" }) }),
|
||
/synastry_section_invalid/,
|
||
);
|
||
});
|
||
|
||
test("opening a section chat only creates a session and prefills the question", async () => {
|
||
const calls: string[] = [];
|
||
const store = new Map<string, string>();
|
||
const previous = globalThis.sessionStorage;
|
||
Object.defineProperty(globalThis, "sessionStorage", {
|
||
configurable: true,
|
||
value: {
|
||
getItem: (key: string) => store.get(key) ?? null,
|
||
setItem: (key: string, value: string) => { store.set(key, value); },
|
||
removeItem: (key: string) => { store.delete(key); },
|
||
},
|
||
});
|
||
try {
|
||
const opened = await beginSynastrySectionChat({
|
||
reportId,
|
||
sectionId: "climate",
|
||
language: "zh",
|
||
names: { a: "Albert", b: "Mileva" },
|
||
fetcher: async (input, init) => {
|
||
const url = String(input);
|
||
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||
if (url.endsWith("/api/models")) {
|
||
return new Response(JSON.stringify({ defaultModelId: "model-a" }), { status: 200 });
|
||
}
|
||
const body = JSON.parse(String(init?.body)) as { session_type?: string };
|
||
assert.equal(body.session_type, "synastry_report");
|
||
return new Response("{}", { status: 201 });
|
||
},
|
||
open: (href) => { calls.push(href); },
|
||
});
|
||
assert.equal(calls.some((call) => call.includes("/api/consult")), false);
|
||
assert.match(opened.question, /合盘报告里「关系基调」/);
|
||
assert.match(calls.at(-1) ?? "", /\/\?c=/);
|
||
assert.equal(synastrySectionQuestion("climate", "en").includes("Relationship Climate"), true);
|
||
} finally {
|
||
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: previous });
|
||
}
|
||
});
|
||
|
||
test("celebrity material accepts a stored placement and rejects an invented house or a shifted dasha", async () => {
|
||
const payload = celebrityPayload();
|
||
const material = synastrySectionMaterial({ payload, sectionId: "climate" });
|
||
assert.equal(auditSynastryAnswer(celebrityNote(), material).ok, true);
|
||
assert.equal(auditSynastryAnswer("金星在 7 宫。", material).ok, false);
|
||
assert.equal(auditSynastryAnswer("土星大运 1904-01 至 1922-01。", material).ok, false);
|
||
assert.equal(synastryChatContextChars(SYNASTRY_CHAT_INSTRUCTIONS, material) <= SYNASTRY_CHAT_CHAR_BUDGET, true);
|
||
|
||
const wrong = await runSynastryReportTurn({
|
||
question: "依据是什么?",
|
||
sectionId: "climate",
|
||
history: [],
|
||
readMaterial: () => material,
|
||
generate: async () => ({ text: "金星在 7 宫。", toolCalls: 1, inputTokens: 1, outputTokens: 1 }),
|
||
});
|
||
assert.equal(wrong.outcome, "fixed");
|
||
assert.equal(wrong.text, SYNASTRY_FIXED_REPLY);
|
||
|
||
const shifted = settleSynastryDrafts({
|
||
toolCalled: true,
|
||
material,
|
||
first: "土星大运 1904-01 至 1922-01。",
|
||
second: "土星大运 1904-01 至 1923-01。",
|
||
});
|
||
assert.equal(shifted.outcome, "fixed");
|
||
assert.equal(shifted.text, SYNASTRY_FIXED_REPLY);
|
||
|
||
const outside = await runSynastryReportTurn({
|
||
question: "他们哪一年领证?",
|
||
sectionId: "climate",
|
||
history: [],
|
||
readMaterial: () => material,
|
||
generate: async () => ({ text: "这份合盘没算到,建议回到各自的盘单独问。", toolCalls: 1, inputTokens: 1, outputTokens: 1 }),
|
||
});
|
||
assert.equal(outside.outcome, "pass");
|
||
assert.equal(synastryMissingCoverage(outside.text), true);
|
||
|
||
const missing = await runSynastryReportTurn({
|
||
question: "依据是什么?",
|
||
sectionId: "climate",
|
||
history: [],
|
||
readMaterial: () => material,
|
||
generate: async () => ({ text: "金星在 10 宫。", toolCalls: 0, inputTokens: 1, outputTokens: 1 }),
|
||
});
|
||
assert.equal(missing.outcome, "tool_missing");
|
||
assert.equal(missing.text, "");
|
||
});
|
||
|
||
test("the read tool stays on the bound report and the new files do not recast", () => {
|
||
const read = boundSynastryRead({
|
||
boundReportId: reportId,
|
||
requestedReportId: otherReportId,
|
||
sectionId: "climate",
|
||
payload: celebrityPayload(),
|
||
});
|
||
assert.equal(read.reportId, reportId);
|
||
assert.notEqual(read.reportId, otherReportId);
|
||
for (const path of [
|
||
"../src/mastra/synastry-report-chat.ts",
|
||
"../src/lib/synastry-report-consult.ts",
|
||
"../src/lib/synastry-report-turn.ts",
|
||
"../src/lib/synastry-report-material.ts",
|
||
]) {
|
||
const source = readFileSync(new URL(path, import.meta.url), "utf8");
|
||
assert.equal(source.includes("run-jyotish-consultation"), false, path);
|
||
}
|
||
});
|
||
|
||
test("sidebar keeps an empty synastry chat for either person and locks the pair", () => {
|
||
const row = session({});
|
||
assert.equal(isListedSidebarSession(row), true);
|
||
assert.equal(sessionMatchesSubject(row, "self"), true);
|
||
assert.equal(sessionMatchesSubject(row, row.chartProfileId ?? ""), true);
|
||
assert.equal(sessionMatchesSubject(row, otherReportId), false);
|
||
assert.equal(chatSessionSubjectLocked(row), true);
|
||
assert.equal(chatProfileDisplayName({ session: row, status: "loading", library: [] }), "Albert × Mileva");
|
||
assert.equal(synastryReportClosed(session({ synastryReportId: null })), true);
|
||
assert.equal(synastryReportClosed(row), false);
|
||
});
|
||
|
||
function timelineBasis(language: "zh" | "en"): string {
|
||
const periodsA: TimingPeriod[] = [
|
||
{ level: "md", lord: "Saturn", lordCn: "土星", start: "1903-01-01", end: "1922-01-01" },
|
||
{ level: "ad", lord: "Venus", lordCn: "金星", start: "1905-03-01", end: "1907-11-01" },
|
||
];
|
||
const periodsB: TimingPeriod[] = [
|
||
{ level: "md", lord: "Jupiter", lordCn: "木星", start: "1900-01-01", end: "1916-01-01" },
|
||
{ level: "ad", lord: "Moon", lordCn: "月亮", start: "1905-01-01", end: "1906-06-01" },
|
||
];
|
||
return buildTimelineSegments({
|
||
nameA: "Albert",
|
||
nameB: "Mileva",
|
||
periodsA,
|
||
periodsB,
|
||
from: "1905-06-01",
|
||
language,
|
||
}).map((segment) => segment.basis).join("\n");
|
||
}
|
||
|
||
function shiftFirstYear(sentence: string): string {
|
||
return sentence.replace(/(\d{4})/, (year) => String(Number(year) + 1));
|
||
}
|
||
|
||
test("timeline basis lines reject a shifted antardasha or English mahadasha", () => {
|
||
const zh = timelineBasis("zh");
|
||
const minor = zh.match(/金星小运 \d{4}-\d{2} 至 \d{4}-\d{2}/u)?.[0];
|
||
assert.ok(minor, zh);
|
||
assert.equal(auditSynastryAnswer(minor, { basis: zh }).ok, true);
|
||
assert.equal(auditSynastryAnswer(shiftFirstYear(minor), { basis: zh }).ok, false);
|
||
|
||
const en = timelineBasis("en");
|
||
const major = en.match(/Saturn mahadasha \d{4}-\d{2} to \d{4}-\d{2}/u)?.[0];
|
||
assert.ok(major, en);
|
||
assert.equal(auditSynastryAnswer(major, { basis: en }).ok, true);
|
||
assert.equal(auditSynastryAnswer(shiftFirstYear(major), { basis: en }).ok, false);
|
||
const antardasha = en.match(/Venus antardasha \d{4}-\d{2} to \d{4}-\d{2}/u)?.[0];
|
||
assert.ok(antardasha, en);
|
||
assert.equal(auditSynastryAnswer(shiftFirstYear(antardasha), { basis: en }).ok, false);
|
||
|
||
const shifted = settleSynastryDrafts({
|
||
toolCalled: true,
|
||
material: { basis: zh },
|
||
first: shiftFirstYear(minor),
|
||
second: shiftFirstYear(minor).replace(/至 (\d{4})/u, (_match, year: string) => `至 ${Number(year) + 1}`),
|
||
});
|
||
assert.equal(shifted.outcome, "fixed");
|
||
assert.equal(shifted.text, SYNASTRY_FIXED_REPLY);
|
||
});
|