fix(consult): BUG-945~949 领域截断、思考分片、Pass 4 按模式分流
schema 上限与执行上限解耦;校正思考改分片门;日期观察不删字,保证句与无分钟个人盘退回重写。
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user