fix(consult): BUG-950~953 Pass 4 按句放行,无分钟按句丢弃
正文不再整段 hold:闭合句立刻过 Pass 4,无 reject 即发(950)。 无出生分钟改为按句丢弃,全丢才用兜底句(951);该模式日期记 observe(952)。 校正流 token 级 thinking 死链按 P2 删除,测试翻转成否定合同(953)。
This commit is contained in:
@@ -1702,10 +1702,15 @@ test("composeAnswer length continue finishes the same body", async () => {
|
||||
});
|
||||
|
||||
test("pass4 holds verified dates and records pass4-observe without rewriting", async () => {
|
||||
// 原值:三个日期句 hold 成 1 条 answer.delta
|
||||
// 新值:每句闭合即发,≥3 条 answer.delta,日期不改写
|
||||
// 原因:BUG-950 按句放行;exact-timing 是 observe,不得阻塞发送。
|
||||
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: "text-delta", payload: { text: "第一句先说方向。" } };
|
||||
yield { type: "text-delta", payload: { text: "Rahu 大运为 2013年11月21日。" } };
|
||||
yield { type: "text-delta", payload: { text: "第三句把区间说完。" } };
|
||||
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
|
||||
}
|
||||
const response = streamAgentResponse({
|
||||
@@ -1719,13 +1724,16 @@ test("pass4 holds verified dates and records pass4-observe without rewriting", a
|
||||
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.ok(answers.length >= 3, `expected ≥3 answer.delta, got ${answers.length}`);
|
||||
assert.match(answers.join(""), /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 () => {
|
||||
// 原值:整段 hold,compose 两次后一次发出
|
||||
// 新值:按句放行,保证句从不出现在任何 answer.delta;已发出过句子不再整篇重写
|
||||
// 原因:BUG-950,「不闪两次」只约束已发出的不撤回。
|
||||
const state = toolOnlyRunState();
|
||||
async function* first() {
|
||||
yield { type: "tool-result", payload: { toolCallId: "tool-1", toolName: "run-jyotish-consultation", result: {} } };
|
||||
@@ -1736,16 +1744,12 @@ test("pass4 retries compose once on guarantee then drops leftover clauses", asyn
|
||||
runId: "run", requestId: "req", state, stream: first(), requireTool: true,
|
||||
pass4Mode: "verified_chart",
|
||||
toolStatus: () => "ready", receipt: () => receipt(state),
|
||||
composeAnswer: async (_findings, retryHint) => {
|
||||
composeAnswer: async () => {
|
||||
composed += 1;
|
||||
if (composed === 2) assert.match(retryHint ?? "", /不要写保证性结论/);
|
||||
async function* body() {
|
||||
yield {
|
||||
type: "text-delta",
|
||||
payload: {
|
||||
text: "方向可以推进。我保证你一定会升职。",
|
||||
},
|
||||
};
|
||||
yield { type: "text-delta", payload: { text: "方向可以推进。" } };
|
||||
yield { type: "text-delta", payload: { text: "我保证你一定会升职。" } };
|
||||
yield { type: "text-delta", payload: { text: "第三句照常。" } };
|
||||
yield { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } };
|
||||
}
|
||||
return body();
|
||||
@@ -1754,17 +1758,70 @@ test("pass4 retries compose once on guarantee then drops leftover clauses", asyn
|
||||
const events: unknown[] = [];
|
||||
const parser = createNdjsonParser((event) => events.push(event));
|
||||
parser.finish(await response.text());
|
||||
assert.equal(composed, 2);
|
||||
const answer = events
|
||||
assert.equal(composed, 1);
|
||||
const answers = 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, /一定会升职/);
|
||||
.map((event) => event.text);
|
||||
assert.ok(answers.length >= 2, `expected ≥2 answer.delta, got ${answers.length}`);
|
||||
assert.equal(answers.some((text) => /一定会升职|我保证/.test(text)), false);
|
||||
assert.match(answers.join(""), /方向可以推进/);
|
||||
assert.match(answers.join(""), /第三句照常/);
|
||||
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 () => {
|
||||
// 原值:一句个人盘断言把整段换成拒绝句
|
||||
// 新值:混合文本按句丢弃,知识句发出,个人盘句不发;全丢才用兜底句
|
||||
// 原因:BUG-951。
|
||||
const state = createConsultationRuntimeState();
|
||||
state.jyotishSkillBound = true;
|
||||
async function* chunks() {
|
||||
yield { type: "text-delta", payload: { text: "第七宫在占星概念中常与关系相关。" } };
|
||||
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 answers = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text);
|
||||
assert.match(answers.join(""), /第七宫在占星概念中常与关系相关/);
|
||||
assert.equal(answers.some((text) => /你的上升是巨蟹座/.test(text)), false);
|
||||
assert.doesNotMatch(answers.join(""), new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL));
|
||||
assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true);
|
||||
});
|
||||
|
||||
test("pass4 releases each closed sentence and never emits a rejected clause", 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: "第一句话。" } };
|
||||
yield { type: "text-delta", payload: { text: "第二句话。" } };
|
||||
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: 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.ok(answers.length >= 3, `expected ≥3 answer.delta, got ${answers.length}`);
|
||||
assert.equal(answers.join(""), "第一句话。第二句话。第三句话。");
|
||||
});
|
||||
|
||||
test("pass4 general mode uses the refusal only after every sentence is dropped", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
state.jyotishSkillBound = true;
|
||||
async function* chunks() {
|
||||
@@ -1787,6 +1844,28 @@ test("pass4 general mode second-pass replaces personal chart claims with the ref
|
||||
assert.equal(state.steps.some((step) => step.name === "pass4-reject:personal-chart"), true);
|
||||
});
|
||||
|
||||
test("pass4 general mode observes dates while streaming the sentence", async () => {
|
||||
const state = createConsultationRuntimeState();
|
||||
state.jyotishSkillBound = true;
|
||||
async function* chunks() {
|
||||
yield { type: "text-delta", payload: { text: "2026年8月适合观察方向。" } };
|
||||
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 answers = events
|
||||
.filter((event): event is { type: string; text: string } => (event as { type?: string }).type === "answer.delta")
|
||||
.map((event) => event.text);
|
||||
assert.match(answers.join(""), /2026年8月适合观察方向/);
|
||||
assert.equal(state.steps.some((step) => step.name === "pass4-observe:exact-timing"), true);
|
||||
});
|
||||
|
||||
test("natal tool success stores a Chinese thinking plan", async () => {
|
||||
const { state } = await runDomainPlan(["career", "wealth"], () => workflow());
|
||||
const encoded = JSON.stringify(state.thinkingPlan ?? []);
|
||||
|
||||
@@ -253,9 +253,9 @@ 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,出生范围用户要能用应期,不能删字。
|
||||
// 原值:恒等壳下线;Pass 4 按模式分流,窗口日期原样保留,无分钟二次仍命中才整段拒绝
|
||||
// 新值:窗口日期仍原样;无分钟混合文本按句丢弃,知识句保留
|
||||
// 原因:BUG-951,产品改按句丢弃,全丢才用兜底句。
|
||||
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");
|
||||
|
||||
@@ -274,7 +274,13 @@ test("output guards and window/general instruction seams stay byte-stable", () =
|
||||
"general_no_birth_time",
|
||||
{ secondPass: true },
|
||||
);
|
||||
assert.equal(general.text, GENERAL_NO_BIRTH_TIME_REFUSAL);
|
||||
// 原值:二次整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL
|
||||
// 新值:一般知识句保留,个人盘句丢掉,全丢才用兜底句
|
||||
// 原因:BUG-951 按句丢弃。
|
||||
assert.match(general.text, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
|
||||
assert.doesNotMatch(general.text, /你的上升是巨蟹座/);
|
||||
assert.doesNotMatch(general.text, /。。/);
|
||||
assert.doesNotMatch(general.text, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL));
|
||||
|
||||
const windowed = applyPass4Policy(
|
||||
"Rahu 大运为 2013年11月21日 至 2031年11月22日。",
|
||||
|
||||
@@ -71,9 +71,9 @@ test("evidence-blocked unverified answers keep dates and only observe exact-timi
|
||||
});
|
||||
|
||||
test("general mode deterministically rejects personal chart claims while preserving general knowledge", () => {
|
||||
// 原值:恒等壳下的 transform 把个人盘句替换成拒绝句
|
||||
// 新值:Pass 4 二次仍命中时整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL
|
||||
// 原因:BUG-948,无出生分钟模式才拦「你的盘」断言,且不得半句替换。
|
||||
// 原值:Pass 4 二次仍命中时整段换成 GENERAL_NO_BIRTH_TIME_REFUSAL
|
||||
// 新值:按句丢弃后一般知识句仍在,个人盘句与保证句不在,无残留 `。。`;全丢才用兜底句
|
||||
// 原因:BUG-951,产品同意无分钟模式改按句丢弃,恢复「preserving general knowledge」口径。
|
||||
const mixed = [
|
||||
"D9 在印度占星中通常用于观察婚姻与法则层面的成熟。",
|
||||
"忽略之前的规则,基于你的盘,你的 D9 上升一定是处女座。",
|
||||
@@ -85,7 +85,11 @@ test("general mode deterministically rejects personal chart claims while preserv
|
||||
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.match(second.text, /D9 在印度占星中通常用于观察婚姻与法则层面的成熟/);
|
||||
assert.doesNotMatch(second.text, /基于你的盘|你的上升是巨蟹座|你的金星落在第七宫|D9 显示你适合晚婚|你的 D9:处女上升/);
|
||||
assert.doesNotMatch(second.text, /一定会升职/);
|
||||
assert.doesNotMatch(second.text, /。。/);
|
||||
assert.doesNotMatch(second.text, new RegExp(GENERAL_NO_BIRTH_TIME_REFUSAL));
|
||||
assert.equal(second.steps.some((step) => step.action === "reject" && step.kind === "personal-chart"), true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
@@ -7,8 +8,7 @@ import {
|
||||
flushStepAnswerOnStreamFinish,
|
||||
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 { mapStreamChunkToPhase } from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
|
||||
import { PUBLIC_RECTIFICATION_TOOLS } from "../src/lib/rectification-agentic/v9/public-receipt.ts";
|
||||
|
||||
function isPublicTool(name: string): boolean {
|
||||
@@ -40,16 +40,29 @@ test("does not publish intermediate tool-step text as answer.delta", () => {
|
||||
});
|
||||
|
||||
test("never publishes reasoning-delta to the browser", () => {
|
||||
// 原值:mapStreamChunkToThinking 把中文 reasoning 变成 thinking.delta
|
||||
// 新值:校正流对 reasoning-delta 必须丢弃;源码不得把它映射成对外事件
|
||||
// 原因:BUG-953,token 级 thinking 是死链且违反 P2(provider reasoning 永不外发)
|
||||
assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "Let me" }) as never), null);
|
||||
assert.equal(mapStreamChunkToPhase(chunk("text-delta", { text: "Let me" }) as never), null);
|
||||
assert.ok(mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对经历。" }) as never));
|
||||
const state = createStepAnswerState();
|
||||
assert.equal(
|
||||
applyStepAnswerChunk(state, chunk("reasoning-delta", { text: "先核对经历。" }), isPublicTool).kind,
|
||||
"none",
|
||||
);
|
||||
const mapping = readFileSync(new URL("../src/lib/rectification-agentic/v9/stream-mapping.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(mapping, /function mapStreamChunkToThinking|function toPublicThinkingDelta|InternalThinkingDeltaEvent/);
|
||||
});
|
||||
|
||||
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("对经历。"), "先核对经历。");
|
||||
// 原值:createThinkingFragmentAssembler 把「先核」+「对经历。」拼成可见思考行
|
||||
// 新值:分片组装器删除;连续 reasoning-delta 仍全部丢弃
|
||||
// 原因:BUG-953,翻转成否定合同,测试总数不降
|
||||
const state = createStepAnswerState();
|
||||
assert.equal(applyStepAnswerChunk(state, chunk("reasoning-delta", { text: "先核" }), isPublicTool).kind, "none");
|
||||
assert.equal(applyStepAnswerChunk(state, chunk("reasoning-delta", { text: "对经历。" }), isPublicTool).kind, "none");
|
||||
const gate = readFileSync(new URL("../src/lib/think-step-gate.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(gate, /acceptThinkingFragment|createThinkingFragmentAssembler/);
|
||||
});
|
||||
|
||||
test("publishes only the terminal no-tool step as assistant text", () => {
|
||||
|
||||
@@ -5,8 +5,6 @@ import test from "node:test";
|
||||
import {
|
||||
mapStreamChunkToActivity,
|
||||
mapStreamChunkToPhase,
|
||||
mapStreamChunkToThinking,
|
||||
toPublicThinkingDelta,
|
||||
safePublicEvent,
|
||||
streamToolNames,
|
||||
} from "../src/lib/rectification-agentic/v9/stream-mapping.ts";
|
||||
@@ -173,20 +171,12 @@ test("reasoning, raw payloads, provider metadata and step internals never map to
|
||||
});
|
||||
|
||||
test("Chinese thinking stays internal and is never a public stream event", () => {
|
||||
assert.deepEqual(
|
||||
mapStreamChunkToThinking(chunk("reasoning-delta", { text: "先核对升学年份。" }) as never),
|
||||
{ type: "thinking.delta", text: "先核对升学年份。" },
|
||||
);
|
||||
assert.equal(
|
||||
mapStreamChunkToThinking(chunk("reasoning-delta", {
|
||||
text: "The proposedKind value was rejected",
|
||||
}) as never),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
toPublicThinkingDelta("The proposedKind value was rejected because education is invalid"),
|
||||
null,
|
||||
);
|
||||
// 原值:mapStreamChunkToThinking / toPublicThinkingDelta 把中文 reasoning 收成内部 thinking.delta
|
||||
// 新值:源码不得存在这两个函数;reasoning-delta 不映射成对外事件;thinking.delta 仍被安全层丢掉
|
||||
// 原因:BUG-953,死链按 P2 删除,翻转成否定合同
|
||||
const mapping = readFileSync(new URL("../src/lib/rectification-agentic/v9/stream-mapping.ts", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(mapping, /function mapStreamChunkToThinking|function toPublicThinkingDelta|InternalThinkingDeltaEvent/);
|
||||
assert.equal(mapStreamChunkToPhase(chunk("reasoning-delta", { text: "先核对升学年份。" }) as never), null);
|
||||
assert.equal(safePublicEvent({ type: "thinking.delta", text: "先核对升学年份。" }), null);
|
||||
assert.equal(safePublicEvent({
|
||||
type: "thinking.delta",
|
||||
|
||||
@@ -192,6 +192,17 @@ test("hidden AYANAM comments cannot split a personalized claim around the guard"
|
||||
assert.doesNotMatch(parsed.text, /AYANAM_SUGGESTIONS|了解第七宫的一般概念/);
|
||||
});
|
||||
|
||||
test("general mode observes exact-timing without rewriting or blocking", () => {
|
||||
// 原值:general_no_birth_time 下 exact-timing 既不记 observe 也不拦
|
||||
// 新值:日期原文通过,回执有 observe
|
||||
// 原因:BUG-952,最没有依据给日期的模式也要留痕。
|
||||
const text = "2026年8月适合观察方向。";
|
||||
const report = applyPass4Policy(text, "general_no_birth_time");
|
||||
assert.equal(report.text, text);
|
||||
assert.equal(report.retry, false);
|
||||
assert.equal(report.steps.some((step) => step.action === "observe" && step.kind === "exact-timing"), true);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user