fix(consult): BUG-945~949 领域截断、思考分片、Pass 4 按模式分流

schema 上限与执行上限解耦;校正思考改分片门;日期观察不删字,保证句与无分钟个人盘退回重写。
This commit is contained in:
jesse-ux
2026-09-18 13:17:35 +08:00
parent b4fcb9466f
commit e32ce6247e
22 changed files with 617 additions and 205 deletions
@@ -37,13 +37,13 @@ import {
DAILY_HEADING,
REPORT_HEADING,
} from "../src/lib/consultation-thinking-plan.ts";
import { getJyotishAgent } from "../src/mastra/index.ts";
import { consultationAgentPublicEventSchema, createNdjsonParser } from "../src/lib/consultation-agent-events.ts";
import { createConsultationPlan } from "../src/lib/consultation-plan.ts";
import {
collectAgentPublicEvents,
streamAgentResponse,
} from "../src/lib/stream-agent-response.ts";
import { GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
const serverChart = {
name: "测试",
@@ -192,27 +192,28 @@ test("multi-domain plan canonicalizes aliases, de-duplicates, preserves order, a
return workflow(input.theme);
},
});
// Aliases, not repetitions: the array bound is now the executable domain cap,
// so a duplicate spends one of the slots the clock can actually pay for.
// canonicalDomainPlan keeps the de-duplication coverage for longer raw lists.
// 原值:别名 career/finance/home 三个都执行,domains 含 migration
// 新值:别名仍规范化,执行上限 2,migration 进 omitted_domains
// 原因:BUG-945/946,墙钟只付得起 2 个领域。
const result = await tools["run-jyotish-consultation"].execute!(
modelInput({ question: "事业、财富和迁居怎么一起规划", domains: ["career", "finance", "home"] }),
{ observe: { span: async (_n: string, fn: () => Promise<unknown>) => fn(), log() {} } } as never,
) as { domains: string[]; consultations: Array<{ domain: string }> };
) as { domains: string[]; omitted_domains: string[]; consultations: Array<{ domain: string }> };
assert.deepEqual(calls, [
{ theme: "career", question: "事业、财富和迁居怎么一起规划" },
{ theme: "wealth", question: "事业、财富和迁居怎么一起规划" },
{ theme: "migration", question: "事业、财富和迁居怎么一起规划" },
]);
assert.deepEqual(result.domains, ["career", "wealth", "migration"]);
assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth", "migration"]);
assert.deepEqual(result.domains, ["career", "wealth"]);
assert.deepEqual(result.omitted_domains, ["migration"]);
assert.deepEqual(result.consultations.map((item) => item.domain), ["career", "wealth"]);
assert.deepEqual(state.workflowReceipt, {
route: "multi-domain",
status: "degraded",
preciseTiming: "allowed",
missingLayers: ["D11"],
domains: ["career", "wealth", "migration"],
domains: ["career", "wealth"],
omittedDomains: ["migration"],
});
});
@@ -253,14 +254,17 @@ test("a multi-domain result exposes the same top-level answer contract as a sing
});
test("the merged answer policy is the most restrictive of the executed domains", async () => {
const { result, state } = await runDomainPlan(["career", "timing", "wealth"], (theme) => workflow(theme, {
// 原值:career+timing+wealth 三个都执行,D11 / 限制句来自 wealth
// 新值:只执行 2 个;把限制性字段放到 timing 上,合并规则不变
// 原因:BUG-946,MAX=2 后第三域进 omitted,不能再靠它提供合并输入。
const { result, state } = await runDomainPlan(["career", "timing"], (theme) => workflow(theme, {
// One domain forbidding precise timing must forbid it for the whole answer.
preciseTiming: theme !== "timing",
status: theme === "wealth" ? "degraded" : "ready",
missingLayers: theme === "wealth" ? ["D11"] : [],
status: theme === "timing" ? "degraded" : "ready",
missingLayers: theme === "timing" ? ["D11"] : [],
hardBlockers: theme === "timing" ? ["negative_holdout_gate"] : [],
leadWithLimitations: theme === "timing",
limitation: theme === "wealth" ? "财富层证据不完整。" : undefined,
limitation: theme === "timing" ? "财富层证据不完整。" : undefined,
chart: natalChart,
}));
@@ -364,31 +368,32 @@ test("the identical natal projection is carried once instead of per domain", asy
});
test("the domain cap is what the run budget can actually pay for", () => {
// 21s per sequential domain against the 110s run budget, minus the reserve a
// three-domain staging run actually left for composing the answer.
assert.equal(MAX_CONSULTATION_DOMAINS, 3);
// 原值:MAX=3,按 21s/领域
// 新值:MAX=2,按实测 31s/领域(110s-45s 写作预留)
// 原因:BUG-944/946,工具实测约 31s/领域,21s 会把写作预算吃光。
assert.equal(MAX_CONSULTATION_DOMAINS, 2);
assert.equal(AGENT_TIMEOUT_MS, 110_000);
assert.equal(CONSULTATION_DOMAIN_WALL_CLOCK_MS, 65_000);
assert.ok(MAX_CONSULTATION_DOMAINS * 21_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS);
// Six domains, the previous cap, could never finish inside the deadline.
assert.ok(6 * 21_000 > AGENT_TIMEOUT_MS);
assert.ok(MAX_CONSULTATION_DOMAINS * 31_000 <= CONSULTATION_DOMAIN_WALL_CLOCK_MS);
assert.ok(6 * 31_000 > AGENT_TIMEOUT_MS);
assert.deepEqual(
executableDomainPlan(["career", "wealth", "timing", "marriage", "health"]),
{ domains: ["career", "wealth", "timing"], omittedDomains: ["marriage", "health"] },
{ domains: ["career", "wealth"], omittedDomains: ["timing", "marriage", "health"] },
);
assert.deepEqual(executableDomainPlan(["career"]), { domains: ["career"], omittedDomains: [] });
// The first domain always runs; after that the next one has to be projected
// to finish, judged by how long the executed ones really took.
assert.equal(domainFitsRunBudget(0, 0), true);
assert.equal(domainFitsRunBudget(21_000, 1), true);
assert.equal(domainFitsRunBudget(42_000, 2), true);
assert.equal(domainFitsRunBudget(31_000, 1), true);
assert.equal(domainFitsRunBudget(62_000, 2), false);
assert.equal(domainFitsRunBudget(60_000, 2), false);
assert.equal(domainFitsRunBudget(40_000, 1), false);
});
test("a plan larger than the cap cannot be expressed and never starts a calculation", async () => {
test("a plan larger than the execution cap is truncated not refused", async () => {
// 原值:4 个领域 schema 失败、execute 0 次、无 omitted_domains
// 新值:schema 接受最多 6 个,execute ≥1 次,omitted_domains 非空
// 原因:BUG-945,描述承诺截断,zod 却整次拒绝。
let calls = 0;
const state = createConsultationRuntimeState();
const tool = createConsultationTools({
@@ -399,18 +404,20 @@ test("a plan larger than the cap cannot be expressed and never starts a calculat
const inputSchema = tool.inputSchema as unknown as { safeParse: (value: unknown) => { success: boolean } };
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing"] }).success, true);
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage"] }).success, false);
assert.equal(inputSchema.safeParse({ question: "测试", domains: ["career", "wealth", "timing", "marriage"] }).success, true);
assert.equal(inputSchema.safeParse({
question: "测试",
domains: ["career", "wealth", "timing", "marriage", "health", "education", "family"],
}).success, false);
// Mastra rejects the over-budget plan before the tool body runs, so it costs
// one correctable step and nothing about the run advances.
const refused = await tool.execute!(
{ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] } as never,
const result = await tool.execute!(
modelInput({ question: "全都看看", domains: ["career", "wealth", "timing", "marriage", "health"] }),
toolContext,
) as Record<string, unknown>;
assert.equal(calls, 0);
assert.equal("domains" in refused, false);
assert.equal(state.consultationToolStarted, false);
assert.deepEqual(runSteps(state), []);
) as PlanResult;
assert.ok(calls >= 1);
assert.ok((result.omitted_domains ?? []).length > 0);
assert.doesNotMatch(JSON.stringify(result), /Tool input validation failed/);
assert.equal(state.consultationToolStarted, true);
});
test("a plan that runs long stops early and discloses the domains it dropped", async () => {
@@ -455,9 +462,9 @@ test("the advertised domain limit matches the enforced one", () => {
})["run-jyotish-consultation"];
const description = tool.description ?? "";
assert.match(description, new RegExp(`at most ${MAX_CONSULTATION_DOMAINS} allowlisted`));
assert.doesNotMatch(description, /up to six|six allowlisted/);
assert.match(description, /up to 6/);
assert.match(description, /omitted_domains/);
assert.match(description, new RegExp(`about ${MAX_CONSULTATION_DOMAINS}`));
assert.match(description, /top-level answer contract/);
});
@@ -947,6 +954,7 @@ test("the receipt separates method the server delivered from method the model we
});
test("personal Agent exposes the Jyotish Skill and named server tool", async () => {
const { getJyotishAgent } = await import("../src/mastra/index.ts");
const state = createConsultationRuntimeState();
const agent = getJyotishAgent({
id: "personal-agent-probe", label: "Probe", description: "", creditCost: 1, isDefault: false,
@@ -1693,6 +1701,92 @@ test("composeAnswer length continue finishes the same body", async () => {
assert.equal(events.filter((event) => (event as { type?: string }).type === "run.completed").length, 1);
});
test("pass4 holds verified dates and records pass4-observe without rewriting", async () => {
const state = toolOnlyRunState();
async function* chunks() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日 至 2031年11月22日。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: true,
pass4Mode: "verified_chart",
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answers = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text);
assert.equal(answers.length, 1);
assert.match(answers[0] ?? "", /2013年11月21日/);
assert.doesNotMatch(answers.join(""), /具体时间已省略/);
assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true);
});
test("pass4 retries compose once on guarantee then drops leftover clauses", async () => {
const state = toolOnlyRunState();
async function* first() {
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
let composed = 0;
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: first(), requireTool: true,
pass4Mode: "verified_chart",
toolStatus: () => "ready", receipt: () => receipt(state),
composeAnswer: async (_findings, retryHint) => {
composed += 1;
if (composed === 2) assert.match(retryHint ?? "", /不要写保证性结论/);
async function* body() {
yield {
type: "text-delta",
payload: {
text: "方向可以推进。我保证你一定会升职。",
},
};
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
return body();
},
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
assert.equal(composed, 2);
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.match(answer, /方向可以推进/);
assert.doesNotMatch(answer, /一定会升职/);
assert.equal(state.steps.some((step) => step.name === "pass4-reject:guarantee"), true);
});
test("pass4 general mode second-pass replaces personal chart claims with the refusal", async () => {
const state = createConsultationRuntimeState();
state.jyotishSkillBound = true;
async function* chunks() {
yield { type: "text-delta", payload: { text: "你的上升是巨蟹座。" } };
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
}
const response = streamAgentResponse({
runId: "run", requestId: "req", state, stream: chunks(), requireTool: false,
pass4Mode: "general_no_birth_time",
toolStatus: () => "ready", receipt: () => receipt(state),
});
const events: unknown[] = [];
const parser = createNdjsonParser((event) => events.push(event));
parser.finish(await response.text());
const answer = events
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
.map((event) => event.text)
.join("");
assert.equal(answer, GENERAL_NO_BIRTH_TIME_REFUSAL);
assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true);
});
test("natal tool success stores a Chinese thinking plan", async () => {
const { state } = await runDomainPlan(["career", "wealth"], () => workflow());
const encoded = JSON.stringify(state.thinkingPlan ?? []);
@@ -4,13 +4,13 @@ import test from "node:test";
import { prepareConsultationRoute } from "../src/lib/consultation-route-service.ts";
import {
createBirthTimeModeOutputGuard,
shouldRunBirthChartWorkflow,
shouldRunDeclaredWindowWorkflow,
UNVERIFIED_BIRTH_TIME_NOTICE,
applyBirthTimeModeToWorkflowContext,
unverifiedBirthTimeNotice,
} from "../src/lib/consultation-birth-time-mode.ts";
import { applyPass4Policy, GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
import {
consultationInputSchema,
runConsultationWorkflow,
@@ -253,26 +253,36 @@ test("unverified notices grade by source without changing the output guards", ()
});
test("output guards and window/general instruction seams stay byte-stable", () => {
// 原值:createBirthTimeModeOutputGuard 在 general 模式替换个人盘句、窗口模式挖日期
// 新值:恒等壳下线;Pass 4 按模式分流,窗口日期原样保留,无分钟二次仍命中才整段拒绝
// 原因:BUG-948,出生范围用户要能用应期,不能删字。
const modeSource = readFileSync(new URL("../src/lib/consultation-birth-time-mode.ts", import.meta.url), "utf8");
const routeSource = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
assert.match(modeSource, /if \(mode === "general_no_birth_time"\) return guardGeneralNoBirthTimeOutput\(text\)/);
assert.match(
modeSource,
/if \(mode === "declared_birth_window" \|\| !canAnswerPreciseTiming\) \{[\s\S]*return guardPreciseTimingOutput\(text\);/,
);
assert.doesNotMatch(modeSource, /createBirthTimeModeOutputGuard|guardPreciseTimingOutput|guardGeneralNoBirthTimeOutput/);
assert.match(routeSource, /pass4Mode:/);
assert.doesNotMatch(routeSource, /createBirthTimeModeOutputGuard|guardPreciseTimingOutput|guardGeneralNoBirthTimeOutput/);
assert.equal(UNVERIFIED_BIRTH_TIME_NOTICE.includes("未校正填报时间"), true);
const general = createBirthTimeModeOutputGuard("general_no_birth_time", false)(
const generalFirst = applyPass4Policy(
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。\n你的上升是巨蟹座。",
"general_no_birth_time",
);
assert.match(general, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
assert.match(general, /你的上升是巨蟹座/);
assert.equal(generalFirst.retry, true);
const general = applyPass4Policy(
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。\n你的上升是巨蟹座。",
"general_no_birth_time",
{ secondPass: true },
);
assert.equal(general.text, GENERAL_NO_BIRTH_TIME_REFUSAL);
const windowed = createBirthTimeModeOutputGuard("declared_birth_window", false)(
const windowed = applyPass4Policy(
"Rahu 大运为 2013年11月21日 至 2031年11月22日。",
"declared_birth_window",
);
assert.match(windowed, /2013年11月21日/);
assert.match(windowed.text, /2013年11月21日/);
assert.doesNotMatch(windowed.text, /具体时间已省略/);
assert.equal(windowed.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.match(routeSource, /function generalNoMinuteInstruction\(hasPublicDaily: boolean\) \{/);
assert.match(routeSource, /function declaredWindowInstruction\(\) \{/);
@@ -2,15 +2,13 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
UNVERIFIED_BIRTH_TIME_NOTICE,
applyBirthTimeModeToWorkflowContext,
createBirthTimeModeOutputGuard,
consultationBirthTimeModeSchema,
shouldRunBirthChartWorkflow,
shouldRunDeclaredWindowWorkflow,
} from "../src/lib/consultation-birth-time-mode.ts";
import { getGeneralJyotishAgent } from "../src/mastra/index.ts";
import type { ResolvedLanguageModel } from "../src/mastra/model.ts";
import { applyPass4Policy, GENERAL_NO_BIRTH_TIME_REFUSAL } from "../src/lib/timing-output-guard.ts";
import { homeSurface } from "./home-surface.ts";
test("general-no-birth-time is an explicit server mode that never runs a chart workflow", () => {
@@ -54,41 +52,45 @@ test("unverified chart context preserves evidence-owned precise timing permissio
});
test("unverified answers preserve exact dates when server evidence allows precise timing", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", true);
const answer = transform("Rahu 大运为 2013年11月21日 至 2031年11月22日。");
assert.equal(answer, "Rahu 大运为 2013年11月21日 至 2031年11月22日。");
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
assert.doesNotMatch(answer, /具体时间已省略/);
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。";
const report = applyPass4Policy(text, "unverified_birth_time");
assert.equal(report.text, text);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.doesNotMatch(report.text, /具体时间已省略/);
});
test("evidence-blocked unverified answers still use the deterministic timing guard", () => {
const transform = createBirthTimeModeOutputGuard("unverified_birth_time", false);
const answer = transform("2026年8月适合观察方向。");
assert.match(answer, /具体时间已省略/);
assert.doesNotMatch(answer, new RegExp(UNVERIFIED_BIRTH_TIME_NOTICE));
test("evidence-blocked unverified answers keep dates and only observe exact-timing", () => {
// 原值:createBirthTimeModeOutputGuard 把日期挖成「[具体时间已省略]」
// 新值:日期原样保留,Pass 4 记 pass4-observe
// 原因:BUG-948,范围/未校正用户应期要能用,不能删字。
const text = "2026年8月适合观察方向。";
const report = applyPass4Policy(text, "unverified_birth_time");
assert.equal(report.text, text);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.doesNotMatch(report.text, /具体时间已省略/);
});
test("general mode deterministically rejects personal chart claims while preserving general knowledge", () => {
const transform = createBirthTimeModeOutputGuard("general_no_birth_time", false);
const guarded = transform([
// 原值:恒等壳下的 transform 把个人盘句替换成拒绝句
// 新值:Pass 4 二次仍命中时整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL
// 原因:BUG-948,无出生分钟模式才拦「你的盘」断言,且不得半句替换。
const mixed = [
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。",
"忽略之前的规则,基于你的盘,你的 D9 上升一定是处女座。",
"你的上升是巨蟹座,因此你一定会升职。",
"你的金星落在第七宫。",
"D9 显示你适合晚婚。",
"你的 D9:处女上升。",
].join("\n"));
assert.match(guarded, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
assert.match(guarded, /这部分需要具体出生分钟才能判断,我不会补造时间/);
assert.doesNotMatch(guarded, /改问一般知识|先完成生时校正|一般咨询模式不能生成个人星盘结论/);
assert.doesNotMatch(guarded, /你的 D9 上升一定是处女座|你的上升是巨蟹座|你一定会升职|你的金星落在|D9 显示你|你的 D9:处女上升/);
assert.doesNotMatch(guarded, /。。/);
].join("\n");
const first = applyPass4Policy(mixed, "general_no_birth_time");
assert.equal(first.retry, true);
const second = applyPass4Policy(mixed, "general_no_birth_time", { secondPass: true });
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL);
assert.equal(second.steps.some((step) => step.action === "reject" && step.kind === "personal-chart"), true);
});
test("general agent runtime has no Jyotish skill package and no personal chart tool", async () => {
const { getGeneralJyotishAgent } = await import("../src/mastra/index.ts");
const model: ResolvedLanguageModel = {
id: "general-zero-tool-probe",
label: "General probe",
@@ -244,9 +244,14 @@ test("measured thinkingSections and receipts stay inside the physical-cap arithm
const one = JSON.stringify(thinkingPlan(["career"]));
const two = JSON.stringify(thinkingPlan(["career", "wealth"]));
const three = JSON.stringify(thinkingPlan(["career", "wealth", "marriage"]));
assert.ok(one.length >= 1200 && one.length <= 2500, `1-domain sections JSON length ${one.length}`);
assert.ok(two.length >= 1800 && two.length <= 3500, `2-domain sections JSON length ${two.length}`);
assert.ok(three.length >= 2400 && three.length <= 4500, `3-domain sections JSON length ${three.length}`);
// 原值:1 域 1200–2500 / 2 域 1800–3500 / 3 域 2400–4500
// 新值:1 域 900–1300 / 2–3 域 1100–1600(实测 1053 / 1267 / 1267)
// 原因:BUG-949,本命思考计划改成四标题后 JSON 变小;12 步上限让 2 域与 3 域同长。
assert.ok(one.length >= 900 && one.length <= 1300, `1-domain sections JSON length ${one.length}`);
assert.ok(two.length >= 1100 && two.length <= 1600, `2-domain sections JSON length ${two.length}`);
assert.ok(three.length >= 1100 && three.length <= 1600, `3-domain sections JSON length ${three.length}`);
assert.ok(two.length >= one.length, "adding a domain must not shrink the plan JSON");
assert.ok(three.length >= two.length, "a third domain may share the 12-step cap");
const receipts = typicalReceipts(["career", "wealth", "marriage"]);
const receiptJson = JSON.stringify({
@@ -283,12 +288,19 @@ test("session-detail JSON at the old ~19-round cap versus the new ~50-round cap"
assert.equal(oldCap.session.messages.length, 38);
assert.equal(newCap.session.messages.length, 100);
assert.ok(oldCountedChars(oldCap.session.messages) > 200_000, "19 full rounds already exceeded the old combined quota");
// 原值:oldCountedChars(19) > 200_000(思考分节计入旧额度)
// 新值:四标题计划更小,19 轮旧口径实测 177,973,落在 170k–190k
// 原因:BUG-949,思考计划变小后 19 轮不再必然撞上 20 万旧额度。
assert.ok(
oldCountedChars(oldCap.session.messages) > 170_000 && oldCountedChars(oldCap.session.messages) < 190_000,
`19-round old combined count was ${oldCountedChars(oldCap.session.messages)}`,
);
assert.ok(countedText(newCap.session.messages) >= 200_000, "50 rounds of 100+4000 visible text reach the conversation quota");
// Measured 2026-09-16 on this fixture: 19-round 562,240 B (0.536 MiB);
// 50-round 1,479,034 B (1.411 MiB); ratio 2.631.
assert.ok(oldBytes > 540_000 && oldBytes < 590_000, `19-round detail JSON was ${oldBytes} bytes`);
assert.ok(newBytes > 1_450_000 && newBytes < 1_510_000, `50-round detail JSON was ${newBytes} bytes`);
// 原值:2026-09-16 夹具 19 轮 562,240 B / 50 轮 1,479,034 B
// 新值:2026-09-18 实测 533,113 B / 1,402,384 B,比例仍约 2.63
// 原因:BUG-949,thinkingSections JSON 随四标题计划缩小。
assert.ok(oldBytes > 520_000 && oldBytes < 550_000, `19-round detail JSON was ${oldBytes} bytes`);
assert.ok(newBytes > 1_380_000 && newBytes < 1_430_000, `50-round detail JSON was ${newBytes} bytes`);
assert.ok(newBytes > oldBytes, "50-round detail JSON must be larger than 19-round");
const ratio = newBytes / oldBytes;
@@ -122,7 +122,7 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
assert.match(route, /composeAnswer,/);
assert.doesNotMatch(route, /maxOutputTokens:\s*\d/);
assert.equal(route.match(/const continueAfterLength = async \(output: string\) => \{/g)?.length, 3);
assert.equal(route.match(/const composeAnswer = async \(\) => \{/g)?.length, 1);
assert.equal(route.match(/const composeAnswer = async \(/g)?.length, 1);
assert.match(route, /toolChoice: "none"/);
assert.match(route, /entrypoint: consultEntrypoint/);
assert.match(route, /entrypoint: parsed\.data\.entrypoint/);
@@ -153,10 +153,11 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
assert.match(natalRetry, /], natalStreamOptions\)/);
assert.doesNotMatch(natalRetry, /], streamOptions\)/);
const composeBlock = route.slice(
route.indexOf("const composeAnswer = async () => {"),
route.indexOf("const executionReceipt = (): AgentExecutionReceipt => ({", route.indexOf("const composeAnswer = async () => {")),
route.indexOf("const composeAnswer = async ("),
route.indexOf("const executionReceipt = (): AgentExecutionReceipt => ({", route.indexOf("const composeAnswer = async (")),
);
assert.match(composeBlock, /\.\.\.streamOptions,/);
assert.match(composeBlock, /retryHint/);
assert.doesNotMatch(composeBlock, /natalStreamOptions/);
assert.doesNotMatch(stream, /section-empty-retry/);
assert.match(tools, /dailyConsultationThinkingPlan/);
@@ -191,7 +192,11 @@ test("personal consultation lets the Agent invoke the server-bound workflow tool
assert.doesNotMatch(agenticBranch, /JSON\.stringify\(toolInput\)/);
assert.match(tools, /\(ctx\.runWorkflow \?\? runConsultationWorkflow\)\(toolInput, \{/);
assert.match(tools, /return \{ "run-jyotish-consultation": consultationTool \};/);
assert.match(agenticBranch, /state\.workflowReceipt\?\.preciseTiming === "allowed"/);
// 原值:transformText 读 workflowReceipt.preciseTiming === "allowed" 再套恒等壳
// 新值:Pass 4 只看 consultationMode,不再读 preciseTiming 开关去挖日期
// 原因:BUG-948,恒等壳下线;日期观察按模式,不按分钟敏感开关。
assert.match(agenticBranch, /pass4Mode: consultationMode/);
assert.doesNotMatch(agenticBranch, /state\.workflowReceipt\?\.preciseTiming === "allowed"/);
assert.match(route, /createConsultationReplyMetadata/);
});
@@ -4,8 +4,8 @@ import test from "node:test";
import {
ACCEPTED_RANGE_READING_INSTRUCTION,
createBirthTimeModeOutputGuard,
} from "../src/lib/consultation-birth-time-mode.ts";
import { applyPass4Policy } from "../src/lib/timing-output-guard.ts";
import { RANGE_READING_COPY } from "../src/lib/rectification-agentic/v9/adopt-narration.ts";
import {
createConsultationRuntimeState,
@@ -47,25 +47,30 @@ test("theme_sensitivity maps sensitive keys onto the consultation answer policy"
assert.deepEqual(receipt.minuteSensitiveThemes, ["marriage"]);
});
test("minute-sensitive or timing themes use the precise-timing output guard", () => {
test("minute-sensitive or timing themes keep calculated dasha dates", () => {
// 原值:verified_chart + marriage/timing 主题把日期挖成「[具体时间已省略]」
// 新值:日期原样保留,Pass 4 只记 observe
// 原因:BUG-948,已校正用户问应期不该被删字;范围用户同样要拿到区间说法。
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。";
const marriage = createBirthTimeModeOutputGuard("verified_chart", true, {
currentTheme: "marriage",
minuteSensitiveThemes: ["marriage"],
})(text);
assert.match(marriage, /具体时间已省略/);
const marriage = applyPass4Policy(text, "verified_chart");
assert.equal(marriage.text, text);
assert.equal(marriage.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
const career = createBirthTimeModeOutputGuard("verified_chart", true, {
currentTheme: "career",
minuteSensitiveThemes: ["marriage"],
})(text);
assert.equal(career, text);
const career = applyPass4Policy(text, "verified_chart");
assert.equal(career.text, text);
const timing = createBirthTimeModeOutputGuard("verified_chart", true, {
currentTheme: "timing",
minuteSensitiveThemes: [],
})(text);
assert.match(timing, /具体时间已省略/);
const timing = applyPass4Policy(text, "verified_chart");
assert.equal(timing.text, text);
assert.doesNotMatch(timing.text, /具体时间已省略/);
});
test("declared birth window keeps exact dates and does not blank them", () => {
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。按范围看应期,不写成单一分钟。";
const report = applyPass4Policy(text, "declared_birth_window");
assert.equal(report.text, text);
assert.match(report.text, /2013年11月21日/);
assert.doesNotMatch(report.text, /具体时间已省略/);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
});
test("declared window consultation merges range-reading themes and says it is a coarse look", async () => {
@@ -8,6 +8,7 @@ import {
shouldPublishStepText,
} from "../src/lib/rectification-agentic/v9/step-answer.ts";
import { mapStreamChunkToPhase, mapStreamChunkToThinking } from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
import { createThinkingFragmentAssembler } from "../src/lib/think-step-gate.ts";
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
function isPublicTool(name: string): boolean {
@@ -44,6 +45,13 @@ test("never publishes reasoning-delta to the browser", () => {
assert.ok(mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对经历。" }) as never));
});
test("consecutive Chinese reasoning fragments assemble into a visible thinking line", () => {
const assembler = createThinkingFragmentAssembler();
assert.equal(assembler.push("The proposedKind value was rejected"), null);
assert.equal(assembler.push("先核"), null);
assert.equal(assembler.push("对经历。"), "先核对经历。");
});
test("publishes only the terminal no-tool step as assistant text", () => {
const state = createStepAnswerState();
applyStepAnswerChunk(state, chunk("step-start"), isPublicTool);
+47 -16
View File
@@ -1,32 +1,38 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
applyPass4Policy,
detectGeneralNoBirthTimeViolations,
detectPreciseTimingViolations,
GENERAL_NO_BIRTH_TIME_REFUSAL,
guardGeneralNoBirthTimeOutput,
guardPreciseTimingOutput,
} from "../src/lib/timing-output-guard.ts";
import { streamTextResponse } from "../src/lib/stream-text-response.ts";
import { parseAgentReply } from "../src/lib/agent-reply.ts";
import { createBirthTimeModeOutputGuard } from "../src/lib/consultation-birth-time-mode.ts";
test("detects exact dates and months when precise timing is blocked", () => {
// 原值:就地替换成「[具体时间已省略]」
// 新值:只检测,不改写
// 原因:任务书 P3,正则当门不当刀。
// 新值:只检测,不改写;Pass 4 在有盘模式下记 observe
// 原因:任务书 P3 / BUG-948,正则当门不当刀。
const text = "你会在2027年3月15日结婚,事业将在11月转折。";
const hits = detectPreciseTimingViolations(text);
assert.equal(hits.some((item) => item.kind === "exact-timing"), true);
assert.equal(guardPreciseTimingOutput(text), text);
const report = applyPass4Policy(text, "verified_chart");
assert.equal(report.text, text);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.doesNotMatch(report.text, /具体时间已省略/);
});
test("detects guarantee conclusions when the evidence contract is incomplete", () => {
const text = "我保证你一定会升职。";
const hits = detectPreciseTimingViolations(text);
assert.equal(hits.some((item) => item.kind === "guarantee"), true);
assert.equal(guardPreciseTimingOutput(text), text);
const first = applyPass4Policy(text, "verified_chart");
assert.equal(first.retry, true);
assert.equal(first.steps.some((step) => step.action === "reject" && step.kind === "guarantee"), true);
const second = applyPass4Policy(text, "verified_chart", { secondPass: true });
assert.doesNotMatch(second.text, /一定会升职/);
assert.equal(
detectPreciseTimingViolations("You will definitely get promoted.").some((item) => item.kind === "guarantee"),
true,
@@ -42,7 +48,6 @@ test("streamed dates stay intact because the guard no longer rewrites", async ()
const response = streamTextResponse(reply(), {
mode: "mastra",
requestId: "00000000-0000-4000-8000-000000000098",
transformText: guardPreciseTimingOutput,
});
const text = await response.text();
assert.match(text, /2027年3月15日/);
@@ -64,7 +69,6 @@ test("guards only visible prose and preserves AYANAM blocks across arbitrary chu
const response = streamTextResponse(reply(), {
mode: "mastra",
requestId: "00000000-0000-4000-8000-000000000099",
transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false),
});
const text = await response.text();
const parsed = parseAgentReply(text);
@@ -96,19 +100,22 @@ test("general mode structurally rejects personalized chart placements in Chinese
for (const claim of unsafeClaims) {
const hits = detectGeneralNoBirthTimeViolations(claim);
assert.equal(hits.some((item) => item.kind === "personal-chart" || item.kind === "exact-timing"), true, claim);
assert.equal(guardGeneralNoBirthTimeOutput(claim), claim, claim);
const first = applyPass4Policy(claim, "general_no_birth_time");
assert.equal(first.retry, true, claim);
const second = applyPass4Policy(claim, "general_no_birth_time", { secondPass: true });
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL, claim);
}
assert.equal(
guardGeneralNoBirthTimeOutput("第七宫在占星概念中常与关系相关。"),
applyPass4Policy("第七宫在占星概念中常与关系相关。", "general_no_birth_time").text,
"第七宫在占星概念中常与关系相关。",
);
assert.equal(
guardGeneralNoBirthTimeOutput("Venus is generally associated with relating and values."),
applyPass4Policy("Venus is generally associated with relating and values.", "general_no_birth_time").text,
"Venus is generally associated with relating and values.",
);
assert.equal(
guardGeneralNoBirthTimeOutput("你问的第七宫,在占星概念中常与关系相关。"),
applyPass4Policy("你问的第七宫,在占星概念中常与关系相关。", "general_no_birth_time").text,
"你问的第七宫,在占星概念中常与关系相关。",
);
});
@@ -128,7 +135,8 @@ test("general mode rejects personalized chart predicates beyond possessive place
for (const claim of unsafeClaims) {
assert.equal(detectGeneralNoBirthTimeViolations(claim).length > 0, true, claim);
assert.equal(guardGeneralNoBirthTimeOutput(claim), claim, claim);
const second = applyPass4Policy(claim, "general_no_birth_time", { secondPass: true });
assert.equal(second.text, GENERAL_NO_BIRTH_TIME_REFUSAL, claim);
}
});
@@ -151,7 +159,9 @@ test("general mode preserves educational chart questions and general knowledge",
];
for (const statement of safeStatements) {
assert.equal(guardGeneralNoBirthTimeOutput(statement), statement);
const report = applyPass4Policy(statement, "general_no_birth_time");
assert.equal(report.text, statement);
assert.equal(report.retry, false, statement);
}
});
@@ -169,7 +179,6 @@ test("hidden AYANAM comments cannot split a personalized claim around the guard"
const response = streamTextResponse(reply(), {
mode: "mastra",
requestId: "00000000-0000-4000-8000-000000000097",
transformText: createBirthTimeModeOutputGuard("general_no_birth_time", false),
});
const text = await response.text();
const parsed = parseAgentReply(text);
@@ -182,3 +191,25 @@ test("hidden AYANAM comments cannot split a personalized claim around the guard"
assert.equal(parsed.title, "一般占星咨询");
assert.doesNotMatch(parsed.text, /AYANAM_SUGGESTIONS|了解第七宫的一般概念/);
});
test("declared birth window keeps calculated dates as interval facts", () => {
const text = "Rahu 大运为 2013年11月21日 至 2031年11月22日。按范围看应期。";
const report = applyPass4Policy(text, "declared_birth_window");
assert.equal(report.text, text);
assert.equal(report.retry, false);
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
assert.doesNotMatch(report.text, /具体时间已省略/);
});
test("identity output guards are gone from production sources", () => {
const guard = readFileSync(new URL("../src/lib/timing-output-guard.ts", import.meta.url), "utf8");
const mode = readFileSync(new URL("../src/lib/consultation-birth-time-mode.ts", import.meta.url), "utf8");
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
for (const source of [guard, mode, route]) {
assert.doesNotMatch(source, /function guardPreciseTimingOutput/);
assert.doesNotMatch(source, /function guardGeneralNoBirthTimeOutput/);
assert.doesNotMatch(source, /createBirthTimeModeOutputGuard/);
}
assert.match(route, /pass4Mode/);
assert.match(guard, /export function applyPass4Policy/);
});