From bccceff91f8d0dfe8e3a7858676c02cef54146a7 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 13 Oct 2026 22:02:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=98=8E=E8=AF=B4=E8=A6=81=E6=A0=A1=E6=AD=A3?= =?UTF-8?q?=E6=97=B6=EF=BC=8C=E7=AC=AC=E4=B8=80=E6=AD=A5=E5=B0=B1=E8=83=BD?= =?UTF-8?q?=E6=89=93=E5=BC=80=EF=BC=88BUG-1317=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本命和申报时段的第 0 步,校正工具挂上时可以排盘,也可以直接打开校正。还没看盘就顺口提议不算已经提议过。规则第一句写明 explicitRequest=true。定向测试 149 通过,类型检查和 lint 通过,隐私标记通过。名人生平回测未跑。 Co-Authored-By: Grok (agent loop) --- CHANGELOG.md | 6 + docs/BUG_HISTORY.md | 17 +++ ...RESS-rectification-step0-entry-20261013.md | 32 +++++ .../rectification-explicit-entry-20261012.md | 2 + frontend/docs/VOICE.md | 4 +- frontend/src/app/api/consult/route.ts | 10 +- .../src/lib/consultation-thinking-plan.ts | 2 +- frontend/src/mastra/consultation-tools.ts | 49 +++++--- .../consultation-agentic-runtime.test.ts | 26 +++- .../consultation-workflow-contract.test.ts | 8 +- ...tification-explicit-entry-20261012.test.ts | 118 +++++++++++++++++- .../rectification-in-chat-20261007.test.ts | 10 +- 12 files changed, 251 insertions(+), 33 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-step0-entry-20261013.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e9167d..4bd9e392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-13 — 明说要做生时校正时,第一步就能打开(待合入 staging) + +- 新对话第一句就说「帮我做生时校正 / 生时纠正 / 核对出生时间」,会直接打开校正,只留一句过渡,不先写一段解读。 +- 问事业、感情这类普通问题,仍是先排盘再回答。只是觉得解读不准,仍是认下来改着读,不会被拉去做校正。 +- Skill 版本不变(BUG-1317,是 BUG-1306 的复发)。 + ## 2026-10-13 — 新报告正文画出分盘,中文更干净,上升行和同位列能读懂(待合入 staging) - 重新生成的报告里,「本命与分盘北印度图盘」每张有数据的盘下面有一张北印度图。同一张盘只画一次。报告最末不再重复「分盘核对」。旧报告打开仍是当时保存的正文。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7794c7c0..b7514510 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17556,6 +17556,7 @@ - 防复发:`frontend/tests/rectification-explicit-entry-20261012.test.ts` 锁住直达、同一轮不重复开案、无 caseId 的 opened 绑定与找回、四种拒绝说明、两条路径不重试、普通咨询仍重试一次、规则只定义一处、申报时段「在哪一段附近」、结算调用 `complete_consultation_free`。F3 另锁:结算记录是 `offered` 且 `autoOpen: true`、卡片在绑定前不说「已经开始」、绑定后才说已经开始、打开失败仍是 `offered`、旧的 `offered` / `opened` / `returned` 照常解析且 `returned` 不出卡。用户可见句长度 ≤20。真机清单 `docs/testing/rectification-explicit-entry-20261012.md`。 - 相关记录:BUG-1247、BUG-1248(入口改为提议卡)、BUG-976/977(禁正则判意图)。 - 复发自:无。 +- 复发:BUG-1317(2026-10-13。直达已部署,但第 0 步仍只有排盘工具,模型排完盘后没有带 explicitRequest 打开校正)。 - 修复版本:staging `b8bdc003`(实现 `6a799582` 变基到 `9f1e8257`,代码逐字节相同;2026-10-12 Claude 验收通过、产品要求合入;部署核对:health gitCommit=b8bdc003、`requiredMigrationsPresent` true,`/login` 200,未登录 `/api/account` 401)。跟进项 F3(打开失败卡片仍说已开始)见 `TASK-rectification-1012-batch-fix-20261012`。 F3 跟进(`51a77721`)已部署 staging `586561eb`(2026-10-12 核对:health gitCommit / apiGitCommit=586561eb、`requiredMigrationsPresent` true,`/login` 200,未登录 `/api/account` 401;合并后 npm test 5025 条、失败 24 逐名同 `798e15ef` 基线)。 - 决策日志(2026-10-12,产品):选 A——用户明确要求做生时校正时直接进入校正,不先写解读、不用点卡;意图由模型判断,禁正则 / 关键词。**推翻 BUG-1248 两处**:①明确要求的这一轮不再「只出卡、点卡才进」;②`product_disabled`、`pricing_unavailable`「正文不要提校正」改为必须一句话说明做不了,不得静默。普通对话模型不得承诺收窄出生时间、不得在对话里收集经历假装校正;申报时段下不得写大运 / 子运精确起止。服务端本人 / 开关 / 进行中 case / 价格判定不变。同日另立 BUG-1307(最近分界开场)、BUG-1308(父母静态题研究)。 @@ -17689,3 +17690,19 @@ - 复发自:无。 - 修复版本:分支 `codex/report-translation-gaps-20261013`,尚未合入 staging。 +## BUG-1317 | 明说要做生时校正时,仍先排完盘再写解读,校正没打开 + +- 状态:fixed-pending-verify(真机清单未走,没有模型凭据,不标 resolved) +- 首次发现 / 最近更新:2026-10-13 / 2026-10-13 +- 来源:issue #54。staging 首页普通对话第一句说要生时纠正,回答先显示排盘完成,再写校正开不了、走另一个入口。 +- 现象:没有打开校正。正文是模型自己写的解读,不是那句固定过渡。 +- 触发条件:新对话第一句明确要做校正。普通问题、以及只是觉得解读不准,不在这条里。 +- 根因:第 0 步只给排盘工具(BUG-923)。模型必须先排完盘,才看得到校正工具。规则里「明确要校正就打开」夹在禁止句中间,「纠正」又和「用户纠正解读」撞了。BUG-1306 的测试直接调用工具,没有让真实模型选工具;真机清单因没有模型钥匙没跑过。 +- 决策:产品选 B。第 0 步在校正工具确实挂上时,可以排盘,也可以打开校正。`toolChoice` 仍是 auto。两个都没按就写答案时,仍走原来的「运行合同不完整」重试,逼它先排盘。不加关键词判断。 +- 修复:本命和申报时段的第 0 步,在挂了校正工具时多放这一个工具。还没看盘就顺口提议,不算已经提议过,先去排盘。明确要求则第一步就打开,不先写解读。规则第一句改成「校正就在这个对话里能开」,并把「纠正规则」改称「改读规则」。`FOLLOW_UP_CORRECTION_RULE` 本身没改。没挂校正工具时,第 0 步仍只有排盘工具。 +- 验证:见 `docs/tasks/PROGRESS-rectification-step0-entry-20261013.md`。规则原 374 字,新 416 字,增加 42,不超过 80。 +- 防复发:第 0 步断言锁住「没挂只有排盘、挂了是两个工具」。另有三条:第一步明确要求不排盘且正文是固定过渡;一步都不按会重试到排盘后的回答;没看盘的顺口提议不占额度。 +- 相关记录:BUG-1306、BUG-923、BUG-937、BUG-922。 +- 复发自:BUG-1306。 +- 修复版本:分支 `codex/rectification-step0-entry-20261013`,尚未合入 staging。 + diff --git a/docs/tasks/PROGRESS-rectification-step0-entry-20261013.md b/docs/tasks/PROGRESS-rectification-step0-entry-20261013.md new file mode 100644 index 00000000..e7dbedd4 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-step0-entry-20261013.md @@ -0,0 +1,32 @@ +# 进度 · 明说要校正时第一步就能打开(2026-10-13) + +- 分支:`codex/rectification-step0-entry-20261013` +- 任务书:`docs/tasks/TASK-rectification-step0-entry-20261013.md` +- 基线:`origin/staging` `0c04755560f0b6a772116130392b56eb2b40ae0e`(任务书写的 `963276ca2` 的后代,也是计划里的 base) +- issue:#54,BUG-1317,复发自 BUG-1306 + +## 做了什么 + +1. 本命和申报时段的第 0 步,在校正工具挂上时可以排盘,也可以打开校正。没挂时仍只有排盘。选择仍是自动,没有改成必须调用某个工具。 +2. 还没看盘就顺口提议,不算已经提议过,先去排盘。这一轮明确要校正,第一步就打开,正文换成那句过渡。 +3. 规则第一句改成校正就在这个对话里能开,并写明校正、纠正、核对出生时间都算。不认同解读改称「改读规则」,避免和「生时纠正」撞词。 +4. 规则字数:原 374,新 416,增加 42,上限是加 80。 + +## 检查 + +| 检查 | 命令 | 结果 | +| --- | --- | --- | +| 定向测试 | `node node_modules/tsx/dist/cli.mjs --test` 五个相关测试文件 | 149 通过,0 失败,0 取消。类型收窄改完后,入口测试文件再跑 19 通过,0 失败,0 取消 | +| 类型检查 | `frontend\node_modules\.bin\tsc --noEmit -p frontend` | 通过,退出码 0。第一次在测试里把「还没提议」收成永远是空,已改成先记在局部再断言 | +| 前端 lint | `npm run lint --prefix frontend` | 退出码 0,0 error,125 条原有 warning,没有顺手改 | +| 隐私标记 | `.venv\Scripts\python -m pytest tests/test_repo_privacy_markers.py -q` | 通过,退出码 0 | +| 名人生平回测 | 未跑 | 本机没有模型钥匙 | +| 数据库 | 未跑 | 不改表 | + +## 偏离 + +- 没有。真机清单补在原来的 `docs/testing/rectification-explicit-entry-20261012.md`(第 1b 步和第 9 步),没有另写一份。 + +## 环境缺口 + +- 真实模型会不会在第一步打开校正,以及名人生平回测,都没有模型凭据,不能写成通过。部署后按真机清单,用 deepseek-v4-flash 和默认模型各走一遍。 diff --git a/docs/testing/rectification-explicit-entry-20261012.md b/docs/testing/rectification-explicit-entry-20261012.md index d917c9f9..6d2a249d 100644 --- a/docs/testing/rectification-explicit-entry-20261012.md +++ b/docs/testing/rectification-explicit-entry-20261012.md @@ -5,6 +5,7 @@ | 步 | 怎么做 | 通过标准 | | --- | --- | --- | | 1 | 新对话第一句:「请你帮我进行生时纠正」 | 直接打开校正。这一轮不写解读,不用点卡。过渡只有一句 | +| 1b | 新对话第一句:「请你帮我生时纠正」(不带「进行」) | 同样直接打开校正,不先出「已完成几步」再写解读。BUG-1317 | | 2 | 另开三条新对话,分别说「帮我校正出生时间」「想核对一下出生时间准不准」「我出生时间是估的,帮我对一下」 | 三次都直接打开,不先出卡 | | 3 | 新对话先问一件普通的事,答完后再说「请帮我做生时校正」 | 这一轮直接打开,不把刚才的解读再写一遍 | | 4 | 新对话问事业,答完后说「你说我事业不顺,我觉得挺顺的」 | 认下、改读。不进入校正,下面没有提议卡 | @@ -12,5 +13,6 @@ | 6 | 换到一个家人档案,第一句就说「请帮我校正出生时间」 | 一句「给家人校正暂时还不支持」。不打开校正 | | 7 | 在校正开关关闭,或价格暂时不可用时,本人说「请帮我做生时校正」 | 一句「出生时间核对这会儿还做不了。」不沉默,不写价格,不写内部原因。构造不了这一步就在进度里标环境缺口,不要改开关去试 | | 8 | 申报出生时段的本人档案,问一件普通的事,不要提校正 | 正文不写大运、子运的起止年月日,也不承诺能收窄出生时间 | +| 9 | 新对话问「看看我今年事业」 | 照常先排盘再解读,不出现校正,下面没有提议卡 | 做不了的两句和家人那句以界面上的原句为准。点数:直接进入的那一轮不按解读扣咨询点;校正本身仍按原来的开案扣费。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index b57d92f2..636280b9 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -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`,不写死。 diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index ad10b2f1..06ab53f2 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -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 = () => { diff --git a/frontend/src/lib/consultation-thinking-plan.ts b/frontend/src/lib/consultation-thinking-plan.ts index 4375fa9c..d99414d3 100644 --- a/frontend/src/lib/consultation-thinking-plan.ts +++ b/frontend/src/lib/consultation-thinking-plan.ts @@ -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 = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。"; diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index c7f0b699..96c2681e 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -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: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" }; } diff --git a/frontend/tests/consultation-agentic-runtime.test.ts b/frontend/tests/consultation-agentic-runtime.test.ts index 3497787e..cab14bc8 100644 --- a/frontend/tests/consultation-agentic-runtime.test.ts +++ b/frontend/tests/consultation-agentic-runtime.test.ts @@ -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", }); }); diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index 506acf94..2421e4ce 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -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( diff --git a/frontend/tests/rectification-explicit-entry-20261012.test.ts b/frontend/tests/rectification-explicit-entry-20261012.test.ts index fbe10568..b0a86dd3 100644 --- a/frontend/tests/rectification-explicit-entry-20261012.test.ts +++ b/frontend/tests/rectification-explicit-entry-20261012.test.ts @@ -72,8 +72,14 @@ function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise; loadResumableSessionId?: () => Promise; + /** 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"); diff --git a/frontend/tests/rectification-in-chat-20261007.test.ts b/frontend/tests/rectification-in-chat-20261007.test.ts index 493167a5..9c515310 100644 --- a/frontend/tests/rectification-in-chat-20261007.test.ts +++ b/frontend/tests/rectification-in-chat-20261007.test.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/); });