Files
Jyotisha/frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts
T
jesse-uxandGrok (agent loop) 903d206f3b State the rectification opening limit.
The fixed opening and the model brief now say the step does not guarantee a single minute, and an opening that skips that limit falls back. Assertion updates record old value, new value, and why. Ran the opening copy tests: 0 fail. tsc --noEmit exit 0. npm run lint 0 errors.

Co-Authored-By: Grok (agent loop)
2026-10-27 03:52:13 +08:00

251 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* BUG-1307: the opening says how close the recorded minute is to the nearest
* chart change. Question order stays off unless the env flag is "on".
*/
import assert from "node:assert/strict";
import test from "node:test";
import { buildOpeningBrief } from "../src/lib/rectification-agentic/v9/agent-run-messages.ts";
import { orderDiscriminatorsForNearestBoundary } from "../src/lib/rectification-agentic/v9/method-followup.ts";
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { fetchOpeningBoundary, openingFactsFromBoundaryPayload, resolveOpeningBoundaryFacts } from "../src/lib/rectification-agentic/opening-boundary.ts";
import { scanCaseSegments } from "../src/lib/rectification-agentic/v9/segment-scan.ts";
import {
nearestBoundaryFromTransitions,
nearestBoundaryProbesEnabled,
orderByBoundaryDomains,
topicsForLayers,
} from "../src/lib/rectification-agentic/nearest-boundary.ts";
import {
OPENING_BODY_JARGON,
isAcceptableOpeningBody,
openingBoundarySentence,
openingSpokenBody,
} from "../src/lib/rectification-agentic/user-copy.ts";
import { dossierFixture } from "./rectification-v9-test-support.ts";
const RANGE = ["04:45", "05:15"] as const;
const PLAIN = "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。";
const BANNED = ["秒", "记录时间错了", "记录时间不准", "已确认", ...OPENING_BODY_JARGON];
function assertPlain(text: string): void {
for (const word of BANNED) assert.equal(text.includes(word), false, word);
assert.doesNotMatch(text, /[??]/);
// 原值: 最多 3 句。新值: 最多 5 句。原因: BUG-1445 限度两句;交界句仍插在第 1 句和第 2 句之间
assert.ok(text.split(/(?<=。)/).filter(Boolean).length <= 5, text);
}
test("default opening stays four sentences and a missing limit falls back", () => {
assert.equal(openingSpokenBody(RANGE), PLAIN);
// 原值: 没有窗口时只有目标句和做法句
// 新值: 这两句后面再加限度两句
// 原因: BUG-1445
assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。这一步能帮你把用来解读的星盘定下来,但不保证精确到某一分钟;对不上的地方你直接说不准,我会照实记下,不会换个说法圆回来。中途想停也可以,照你原来填的时间用。");
assert.equal(openingBoundarySentence({ kind: "period_only" }), null);
assert.equal(openingBoundarySentence({ kind: "unavailable" }), null);
assert.equal(openingSpokenBody(RANGE, null, { kind: "period_only" }), PLAIN);
assert.equal(isAcceptableOpeningBody(PLAIN, RANGE), true);
const withoutLimit = "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。";
assert.equal(isAcceptableOpeningBody(withoutLimit, RANGE), false);
assert.match(openingSpokenBody(RANGE), /不保证/);
assert.match(openingSpokenBody(RANGE), /不准/);
});
test("near, under a minute, stable, and only the birth chart", () => {
const near = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 4, topics: ["父母", "工作", "<script>"] });
assert.match(near, /差 4 分钟,两边不一样的是父母、工作。/);
assert.doesNotMatch(near, /接下来先问这几件|script/);
const close = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 0, topics: ["婚恋"], askFirst: true });
assert.match(close, /不到 1 分钟,两边不一样的是婚恋,接下来先问这几件。/);
const stable = openingBoundarySentence({ kind: "stable" });
assert.equal(stable, "你填的时间附近这些盘都不换,先从经历开始。");
const onlyBirth = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 2, topics: [] });
assert.match(onlyBirth, /差 2 分钟。/);
assert.doesNotMatch(onlyBirth, /两边不一样/);
for (const text of [near, close, openingSpokenBody(RANGE, null, { kind: "stable" }), onlyBirth]) {
assertPlain(text);
assert.equal(isAcceptableOpeningBody(text, RANGE), true);
assert.equal(isAcceptableOpeningBody(PLAIN, RANGE, null, { kind: "stable" }), false);
}
assert.equal(isAcceptableOpeningBody(`${near}再补一句。`, RANGE), false);
});
test("d1 change is named in the same sentence, and no d1 change keeps today's sentence", () => {
const changed = openingFactsFromBoundaryPayload({
found: true,
distance_minutes: 4,
layers: [{ layer: "d1" }, { layer: "d12" }, { layer: "d10" }],
}, false);
assert.equal(changed.d1Changed, true);
assert.deepEqual(changed.topics, ["工作", "父母"]);
assert.equal(openingBoundarySentence(changed), "你填的时间离最近会换的地方差 4 分钟,这里连本命上升都换,两边不一样的是工作、父母。");
const onlyBirth = openingFactsFromBoundaryPayload({
found: true,
distance_minutes: 0,
layers: [{ layer: "d1" }],
}, true);
assert.equal(onlyBirth.d1Changed, true);
assert.deepEqual(onlyBirth.topics, []);
assert.equal(onlyBirth.askFirst, false);
assert.equal(openingBoundarySentence(onlyBirth), "你填的时间离最近会换的地方不到 1 分钟,这里连本命上升都换。");
assert.equal(
openingBoundarySentence({ kind: "near", distanceMinutes: 2, topics: [], d1Changed: true, askFirst: true }),
"你填的时间离最近会换的地方差 2 分钟,这里连本命上升都换。",
);
const asked = openingBoundarySentence({ kind: "near", distanceMinutes: 2, topics: ["婚恋"], d1Changed: true, askFirst: true });
assert.equal(asked, "你填的时间离最近会换的地方差 2 分钟,这里连本命上升都换,两边不一样的是婚恋,接下来先问这几件。");
const unchanged = openingFactsFromBoundaryPayload({
found: true,
distance_minutes: 4,
layers: [{ layer: "d12" }, { layer: "d10" }],
}, false);
assert.equal(unchanged.d1Changed, false);
assert.equal(openingBoundarySentence(unchanged), "你填的时间离最近会换的地方差 4 分钟,两边不一样的是工作、父母。");
assert.equal(
openingBoundarySentence({ kind: "near", distanceMinutes: 0, topics: ["婚恋"] }),
"你填的时间离最近会换的地方不到 1 分钟,两边不一样的是婚恋。",
);
assert.equal(openingBoundarySentence({ kind: "stable" }), "你填的时间附近这些盘都不换,先从经历开始。");
assert.equal(openingBoundarySentence({ kind: "period_only" }), null);
assert.equal(openingBoundarySentence({ kind: "unavailable" }), null);
const askedFacts = { kind: "near" as const, distanceMinutes: 2, topics: ["婚恋"], d1Changed: true, askFirst: true };
for (const facts of [changed, onlyBirth, askedFacts, unchanged]) {
const text = openingSpokenBody(RANGE, null, facts);
assertPlain(text);
assert.equal(isAcceptableOpeningBody(text, RANGE, null, facts), true);
assert.equal(text.includes("D1"), false);
// 原值: 3 句。新值: 5 句。原因: BUG-1445 交界句仍在中间,限度两句在做法之后
assert.equal(text.split(/(?<=。)/).filter(Boolean).length, 5);
}
assert.match(openingSpokenBody(RANGE, null, changed), /这里连本命上升都换,两边不一样的是工作、父母。/);
});
test("topics come from the layer allowlist, not the payload string", () => {
assert.deepEqual(topicsForLayers(["d12", "d10", "d1", "kp1", "nope"]), ["工作", "父母"]);
const facts = openingFactsFromBoundaryPayload({
found: true,
distance_minutes: 0,
at: "19:15",
topics: ["已确认秒"],
layers: [
{ layer: "d12", topic: "记录时间错了" },
{ layer: "kp1", topic: "工作" },
],
}, false);
assert.equal(facts.kind, "near");
assert.deepEqual(facts.topics, ["父母"]);
assert.equal(facts.askFirst, false);
assert.equal(openingFactsFromBoundaryPayload({ found: false, reason: "no_transition" }, true).kind, "stable");
});
test("question order is a switch and defaults off", () => {
assert.equal(nearestBoundaryProbesEnabled(undefined), false);
assert.equal(nearestBoundaryProbesEnabled("on"), true);
const rows = [
{ id: "career", eventProbe: { domain: "career" }, contrastProbe: null },
{ id: "family", eventProbe: { domain: "family" }, contrastProbe: null },
{ id: "money", eventProbe: null, contrastProbe: { domain: "finance" } },
];
const scan = { transitions: [
{ layer: "d12", at: "19:16", from_sign: "白羊座", to_sign: "金牛座" },
{ layer: "kp1", at: "19:15", from_sign: "白羊座", to_sign: "金牛座" },
] };
const boundary = nearestBoundaryFromTransitions(scan.transitions, "19:15");
assert.equal(boundary.distanceMinutes, 1);
assert.deepEqual(boundary.domains, ["family"]);
const input = { windowScan: scan, reportedTime: "19:15", birthTimeSource: "approximate" };
assert.equal(orderDiscriminatorsForNearestBoundary(rows, input), rows);
const asked = orderDiscriminatorsForNearestBoundary(rows, { ...input, probeFlag: "on" });
assert.deepEqual(asked.map((row) => row.id), ["family", "career", "money"]);
assert.equal(orderDiscriminatorsForNearestBoundary(rows, { ...input, probeFlag: "on", birthTimeSource: "period_only" }), rows);
assert.equal(orderDiscriminatorsForNearestBoundary(rows, {
...input,
probeFlag: "on",
windowScan: { transitions: [{ layer: "d12", at: "19:30", from_sign: "白羊座", to_sign: "金牛座" }] },
}), rows);
const same = orderByBoundaryDomains(rows, ["family"], (row) => row.eventProbe?.domain ?? row.contrastProbe?.domain ?? "");
assert.equal(same[0]?.id, "family");
});
test("period_only and a missing clock do not scan", async () => {
let called = 0;
const loadCompute = async () => {
called += 1;
throw new Error("should not scan");
};
assert.equal((await resolveOpeningBoundaryFacts({
birthTimeSource: "period_only",
reportedBirthTime: "19:15",
candidateRange: { start_time: "19:00", end_time: "19:30" },
loadCompute,
})).kind, "period_only");
assert.equal((await resolveOpeningBoundaryFacts({
birthTimeSource: null,
reportedBirthTime: null,
candidateRange: null,
loadCompute,
})).kind, "unavailable");
assert.equal(called, 0);
const dossier = parseV9CaseDossier(dossierFixture());
assert.ok(dossier);
const brief = buildOpeningBrief(dossier);
// 原值: /正文两句大白话/ 与 /原样放在这两句之间/
// 新值: /正文三层大白话/ 与 /原样放在前两句之间/
// 原因: BUG-1445 开场加了限度层,交界句仍放在目标和做法之间
assert.match(brief, /正文三层大白话/);
assert.match(brief, /不保证/);
assert.match(brief, /不准/);
assert.doesNotMatch(brief, /服务端另有一句/);
const withSentence = buildOpeningBrief(dossier, null, "你填的时间附近这些盘都不换,先从经历开始。");
assert.match(withSentence, /原样放在前两句之间/);
assert.match(brief, /正文不用大运/);
});
test("opening and segment scans send the snapshot ayanamsa", async (t) => {
const bodies: Array<Record<string, unknown>> = [];
t.mock.method(globalThis, "fetch", async (_endpoint: unknown, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as {
start_at: string;
end_at: string;
ayanamsa?: string;
boundary_charts?: unknown;
};
bodies.push(body);
const start = Date.parse(`${body.start_at}:00Z`);
const end = Date.parse(`${body.end_at}:00Z`);
const width = (end - start) / 60000 + 1;
const minutes = Array.from({ length: width }, (_, offset) => {
const stamp = new Date(start + offset * 60000).toISOString().slice(0, 16);
return { offset, date: stamp.slice(0, 10), time: stamp.slice(11), signs: { D1: 1, D9: 2, D10: 3 } };
});
return Response.json({
start_at: body.start_at,
end_at: body.end_at,
ayanamsa: body.ayanamsa,
node_mode: "mean",
step_minutes: 1,
window_minutes: width,
minutes,
nearest_boundary: { found: false, reason: "no_transition" },
});
});
const base = {
birth_date: "2000-06-14",
latitude: 40,
longitude: -74,
timezone_offset: -4,
reported_birth_time: "09:10",
};
const range = { start_time: "09:00", end_time: "09:06" };
await fetchOpeningBoundary({ snapshot: { ...base, ayanamsa: "lahiri" }, range, recordedTime: "09:10" });
await scanCaseSegments({ snapshot: { ...base, ayanamsa: "lahiri" }, range, targets: ["D1", "D9", "D10"] });
await fetchOpeningBoundary({ snapshot: base, range, recordedTime: "09:10" });
await scanCaseSegments({ snapshot: base, range, targets: ["D1", "D9", "D10"] });
assert.equal(bodies.length, 4);
assert.deepEqual(bodies.map((body) => body.ayanamsa), ["lahiri", "lahiri", "raman", "raman"]);
assert.ok(Array.isArray(bodies[0]?.boundary_charts));
assert.equal(bodies[1]?.boundary_charts, undefined);
assert.equal(bodies[0]?.node_mode, undefined);
assert.equal(bodies[1]?.node_mode, undefined);
});