明说要校正时,第一步就能打开(BUG-1317)

本命和申报时段的第 0 步,校正工具挂上时可以排盘,也可以直接打开校正。还没看盘就顺口提议不算已经提议过。规则第一句写明 explicitRequest=true。定向测试 149 通过,类型检查和 lint 通过,隐私标记通过。名人生平回测未跑。

Co-Authored-By: Grok (agent loop)
This commit is contained in:
jesse-ux
2026-10-13 22:02:41 +08:00
co-authored by Grok (agent loop)
parent 0c04755560
commit bccceff91f
12 changed files with 251 additions and 33 deletions
+2 -2
View File
@@ -25,8 +25,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,才调用提议工具。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **明确要求则直接进入**:用户这一轮明确要做校正,由模型判断,不用关键词。服务端放行后直接打开校正:不先写解读,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **何时提议**:校正就在这个对话里能开。用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,要先看过盘再提议。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。还没看盘就顺口提议,不算已经提议过。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
- **明确要求则直接进入**:用户这一轮明确要做校正(校正、纠正、核对出生时间都算),由模型判断,不用关键词。第一步就可以打开,不用先排盘写解读。服务端放行后直接打开校正,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」
- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
+8 -2
View File
@@ -1215,11 +1215,17 @@ export async function POST(request: Request) {
};
const natalStreamOptions = {
...streamOptions,
prepareStep: consultationNatalPrepareStep,
prepareStep: (input: { stepNumber: number }) => consultationNatalPrepareStep({
stepNumber: input.stepNumber,
rectificationOffer: true,
}),
};
const windowStreamOptions = {
...streamOptions,
prepareStep: consultationWindowPrepareStep,
prepareStep: (input: { stepNumber: number }) => consultationWindowPrepareStep({
stepNumber: input.stepNumber,
rectificationOffer: true,
}),
};
const answerPhaseSignal = runClock.answerSignal;
const startAnswerPhase = () => {
@@ -459,7 +459,7 @@ export function natalAnswerShapeInstruction(): string {
* Never decided from keywords. Defined here once; the offer tool names the
* same parameters and does not restate this rule.
*/
export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。用户这一轮明确要做校正,就在写回答之前调用 offer-birth-time-rectification 并传 explicitRequest=true;工具通过后只写那一句过渡,不写解读。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、但没有明确要做校正,才在写回答之前调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
export const RECTIFICATION_OFFER_RULE = "校正就在这个对话里能开:用户这一轮明确要做出生时间校正(说法可能是校正、纠正、核对出生时间、看看出生时间准不准等),第一步就调用 offer-birth-time-rectification 并传 explicitRequest=true,不用先排盘,工具通过后只写那一句过渡,不写解读。用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按改读规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、但没有明确要做校正,才在看过盘之后调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
/** D4 / BUG-1166: the user corrects an earlier reading; acknowledge it, never defend the reading. */
export const FOLLOW_UP_CORRECTION_RULE = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。";
+31 -18
View File
@@ -176,34 +176,41 @@ export const MAX_EVIDENCE_LOOKUPS_PER_TURN = 3;
export const CONSULTATION_WINDOW_CALC_TOOL_ID = "run-jyotish-window-consultation";
/**
* Step 0 exposes only the natal chart tool. Some thinking-mode providers
* reject named and required toolChoice (BUG-282, BUG-937); the first step
* therefore narrows with activeTools and leaves the choice as auto. Later
* steps stay auto so the model can write after the request-scoped
* calculation is in hand.
* Step 0 narrows with activeTools and leaves toolChoice auto. Thinking-mode
* providers reject named and required toolChoice (BUG-282, BUG-937).
* When this turn mounted the rectification offer, that tool is available
* beside the chart tool so an explicit request can open before a reading
* (BUG-1317). Without it, step 0 is still only the chart tool (BUG-923).
* Later steps stay auto.
*
* Window and general agents must not share this hook: they do not own this tool.
* Window and general agents must not share the natal hook.
*/
export function consultationNatalPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
export function consultationNatalPrepareStep(input: { stepNumber: number; rectificationOffer?: boolean }) {
if (input.stepNumber !== 0) return { toolChoice: "auto" as const };
if (!input.rectificationOffer) {
return {
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID],
toolChoice: "auto" as const,
}
: {
toolChoice: "auto" as const,
};
}
return {
activeTools: [CONSULTATION_NATAL_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID],
toolChoice: "auto" as const,
};
}
export function consultationWindowPrepareStep(input: { stepNumber: number }) {
return input.stepNumber === 0
? {
export function consultationWindowPrepareStep(input: { stepNumber: number; rectificationOffer?: boolean }) {
if (input.stepNumber !== 0) return { toolChoice: "auto" as const };
if (!input.rectificationOffer) {
return {
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID],
toolChoice: "auto" as const,
}
: {
toolChoice: "auto" as const,
};
}
return {
activeTools: [CONSULTATION_WINDOW_CALC_TOOL_ID, RECTIFICATION_OFFER_TOOL_ID],
toolChoice: "auto" as const,
};
}
const CONSULTATION_DOMAIN_DURATION_MS = 31_000;
@@ -1174,6 +1181,12 @@ function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntime
}
return { offered: false as const, note: "已经在打开。不要再写解读。" };
}
if (input.explicitRequest !== true && !ctx.state.consultationToolCompleted) {
return {
offered: false as const,
note: "先排盘,看过盘再决定要不要提。这次不算已经提议过。",
};
}
if (hook.alreadyOffered && input.userAsked !== true && input.explicitRequest !== true) {
return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
}
@@ -679,26 +679,40 @@ test("guided-topic entrypoint ignores a model domain rewrite instead of executin
test("natal first step exposes only the chart calculation tool", () => {
assert.equal(CONSULTATION_NATAL_CALC_TOOL_ID, "run-jyotish-consultation");
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
// 原值: 第 0 步 toolChoice "required",后来改为只有排盘工具且 toolChoice "auto"
// 新值: 没挂校正工具时第 0 步仍只有排盘工具;挂了时是排盘工具和 offer-birth-time-rectification。toolChoice 仍是 "auto"。第 1 步不收窄。
// 原因: BUG-937 保持 auto;BUG-1317 按产品选择,第 0 步在工具确实挂上时可以打开校正。
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1 }), {
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0, rectificationOffer: false }), {
activeTools: ["run-jyotish-consultation"],
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 0, rectificationOffer: true }), {
activeTools: ["run-jyotish-consultation", "offer-birth-time-rectification"],
toolChoice: "auto",
});
assert.deepEqual(consultationNatalPrepareStep({ stepNumber: 1, rectificationOffer: true }), {
toolChoice: "auto",
});
});
test("window first step requires the window consultation tool", () => {
assert.equal(CONSULTATION_WINDOW_CALC_TOOL_ID, "run-jyotish-window-consultation");
// 原值: 第 0 步 toolChoice "required" / 新值: "auto"
// 原因: BUG-282 供应商拒收 thinking 模式下的 required,BUG-937 撤回
// 原值: 第 0 步 toolChoice "required",后来改为只有窗口排盘工具且 toolChoice "auto"
// 新值: 没挂校正工具时第 0 步仍只有窗口排盘工具;挂了时再加上 offer-birth-time-rectification。toolChoice 仍是 "auto"。
// 原因: 与本命同一条 BUG-1317 决定,申报时段也要能在第一步打开校正。
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0 }), {
activeTools: ["run-jyotish-window-consultation"],
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1 }), {
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 0, rectificationOffer: true }), {
activeTools: ["run-jyotish-window-consultation", "offer-birth-time-rectification"],
toolChoice: "auto",
});
assert.deepEqual(consultationWindowPrepareStep({ stepNumber: 1, rectificationOffer: true }), {
toolChoice: "auto",
});
});
@@ -233,8 +233,12 @@ test("consult streams reserve an answer budget and keep provider thinking on a s
route.indexOf("const natalStreamOptions = {"),
);
assert.doesNotMatch(sharedStreamOptions, /prepareStep/);
assert.match(route, /prepareStep: consultationNatalPrepareStep/);
assert.match(route, /prepareStep: consultationWindowPrepareStep/);
// 原值:prepareStep: consultationNatalPrepareStep / consultationWindowPrepareStep,函数本身不知道校正工具在不在。
// 新值:路由把 rectificationOffer: true 传进去,因为这两条路线都会挂上校正工具。
// 原因:BUG-1317,第 0 步在工具挂上时要能打开校正;没传时函数仍只留排盘工具。
assert.match(route, /prepareStep: \(input: \{ stepNumber: number \}\) => consultationNatalPrepareStep\(\{/);
assert.match(route, /prepareStep: \(input: \{ stepNumber: number \}\) => consultationWindowPrepareStep\(\{/);
assert.match(route, /rectificationOffer: true/);
assert.match(route, /streamWithOverflowRetry\(agent, natalStreamOptions\)/);
assert.match(route, /streamWithOverflowRetry\(agent, windowStreamOptions\)/);
assert.match(
@@ -72,8 +72,14 @@ function offerTool(options: {
alreadyOffered?: boolean;
decide?: () => Promise<OfferDecision>;
loadResumableSessionId?: () => Promise<string | null>;
/** False only for a step-0 call that has not seen the chart yet. */
chartReady?: boolean;
} = {}) {
const state = createConsultationRuntimeState();
if (options.chartReady !== false) {
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
}
let decisions = 0;
let loads = 0;
const tools = createConsultationTools({
@@ -520,6 +526,111 @@ test("an ordinary consultation still retries once when the calculation never fin
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
});
test("step 0 explicit request opens rectification without calculating a chart", async () => {
const fresh = offerTool({ chartReady: false });
const result = await fresh.tool.execute({ explicitRequest: true });
assert.equal(result.directEntry, "new");
assert.equal(fresh.state.consultationToolCallCount, 0);
assert.equal(fresh.state.consultationToolCompleted, false);
assert.equal(fresh.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NEW_COPY);
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-step0-explicit",
requestId: REQUEST_ID,
state: fresh.state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(fresh.state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "我先看看盘,然后写一段解读。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
return (async function* () { yield STOP_FINISH; })();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 0);
assert.equal(visibleAnswer(events), RECTIFICATION_DIRECT_NEW_COPY);
assert.equal(events.some((event) => event.type === "run.completed" && event.responseKind === "smalltalk"), true);
});
test("step 0 text without a tool still retries into a chart answer", async () => {
const state = createConsultationRuntimeState();
let contractRetries = 0;
const response = streamAgentResponse({
runId: "run-step0-empty",
requestId: REQUEST_ID,
state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "还没排盘就想写。" } };
yield STOP_FINISH;
})(),
retry: async () => {
contractRetries += 1;
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
return (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})();
},
});
const events = await readStream(response);
assert.equal(contractRetries, 1);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(state.rectificationDirectEntry, undefined);
assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true);
});
test("a soft offer before the chart is not recorded, then a chart answer can still offer", async () => {
const fresh = offerTool({ chartReady: false });
const early = await fresh.tool.execute({});
assert.equal(early.offered, false);
assert.match(String(early.note), /先排盘,看过盘再决定要不要提/);
// 记在局部上再断言。直接断言 state 上的字段会把它收成永远是空,排盘后再读就对不上类型。
const offerBeforeChart = fresh.state.rectificationOffer;
assert.equal(offerBeforeChart, undefined);
assert.equal(fresh.state.rectificationDirectEntry, undefined);
assert.equal(fresh.decisions(), 0);
fresh.state.consultationToolCompleted = true;
fresh.state.consultationToolSuccessCount = 1;
const later = await fresh.tool.execute({});
assert.equal(later.offered, true);
const offerAfterChart = fresh.state.rectificationOffer;
assert.equal(offerAfterChart?.status, "offered");
assert.equal(offerAfterChart?.autoOpen, undefined);
assert.equal(fresh.decisions(), 1);
const response = streamAgentResponse({
runId: "run-step0-soft",
requestId: REQUEST_ID,
state: fresh.state,
requireTool: true,
pass4Mode: "verified_chart",
stepScopedAnswer: true,
toolStatus: () => "ready",
receipt: () => receipt(fresh.state),
stream: (async function* () {
yield { type: "text-delta", payload: { text: "事业方向可以看。" } };
yield STOP_FINISH;
})(),
retry: async () => {
throw new Error("chart contract should already be ready");
},
});
const events = await readStream(response);
assert.equal(visibleAnswer(events), "事业方向可以看。");
assert.equal(visibleAnswer(events).includes(RECTIFICATION_DIRECT_NEW_COPY), false);
});
test("the offer rule is defined once, and direct entry settles with the free completion", () => {
const definitions = walkSources(fileURLToPath(new URL("../src/", import.meta.url))).filter((file) => (
readFileSync(file, "utf8").includes("export const RECTIFICATION_OFFER_RULE")
@@ -530,7 +641,12 @@ test("the offer rule is defined once, and direct entry settles with the free com
assert.match(RECTIFICATION_OFFER_RULE, /不在对话里收集经历做校正/);
assert.match(RECTIFICATION_OFFER_RULE, /explicitRequest=true/);
assert.match(RECTIFICATION_OFFER_RULE, /只写那一句过渡,不写解读/);
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/);
// 原值:规则以「用户说你推得不准时」开头,并写「按纠正规则认下」。
// 新值:第一句就写 explicitRequest=true,并改称「改读规则」。全文不含「纠正规则」。
// 原因:BUG-1317,「生时纠正」和「纠正解读」撞词,正向句被埋在禁止句中间。
assert.match(RECTIFICATION_OFFER_RULE.split("。")[0] ?? "", /explicitRequest=true/);
assert.match(RECTIFICATION_OFFER_RULE, /按改读规则认下、改读,不提校正/);
assert.doesNotMatch(RECTIFICATION_OFFER_RULE, /纠正规则/);
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
const tools = src("../src/mastra/consultation-tools.ts");
@@ -117,6 +117,11 @@ test("the server decides whether a card may be shown: self, product on, no case
function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{ ok: true; creditCost: number; questionEstimate: number } | { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }> } = {}) {
const state = createConsultationRuntimeState();
// 原值:建出来还没排盘也能直接提议。
// 新值:这些用例表示已经看过盘,所以先标计算完成。
// 原因:BUG-1317,还没排盘的顺口提议不算已经提议过。
state.consultationToolCompleted = true;
state.consultationToolSuccessCount = 1;
const tools = createConsultationTools({
userId: "user-fictional",
sessionId: SOURCE_SESSION,
@@ -194,7 +199,10 @@ test("the C2 rule is defined once and reaches first turns, follow-ups and declar
assert.ok(followUpAnswerShapeInstruction().includes(RECTIFICATION_OFFER_RULE));
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
assert.match(route, /这只是粗看。" \+ RECTIFICATION_OFFER_RULE;/);
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/);
// 原值:/按纠正规则认下、改读,不提校正/
// 新值:/按改读规则认下、改读,不提校正/
// 原因:BUG-1317,避免「生时纠正」被读成「用户在纠正解读」。
assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按改读规则认下、改读,不提校正/);
assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/);
});