diff --git a/BLOCKED.md b/BLOCKED.md index 794c9c59..ce413644 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,5 +1,11 @@ # BLOCKED +## TASK-rectification-explicit-entry-20261012:T0 日志、T5 模型(BUG-1306,2026-10-12) + +- **T0**:本 worktree 没有 staging 主机,读不到 web 容器 `docker logs`,也没有数据库。无法核对 10-11 那一轮是否调用了 `offer-birth-time-rectification`。不阻塞实现。产品可照做的查法写在 `docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md`,只看工具名、status,以及该会话 messages 里有没有 `rectificationOffer`。 +- **T5**:供应商 API key 在管理端数据库里加密保存,环境只留 `MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY`。本机没有可用数据库,不能解密,也不调用模型。八个场景写成 `docs/testing/rectification-explicit-entry-20261012.md`。 +- **`next build`**:默认 Turbopack 拒绝 worktree 的 `frontend/node_modules` junction(指向文件系统根之外)。`npm run build -- --webpack` 编译与 TypeScript 已过,收集 page data 时 Windows 不能为 Skill 建符号链接(EPERM)。没有路由表,`/` 是否仍 Static、首屏 gzip 未测。没有 `npm install`,没有删 junction,没有改 next 配置。 + ## TASK-astrologer-rulings-batch5:Rath 双主星 p.43、名人生平回测、`next build`、真机(2026-10-04) - **Rath 双主星按 p.43 (a)–(e)(任务书 T1,BUG-1227)**:照裁定实现后原书软件表双主星 8/10 → 9/10,但 Table 17 大运年数 12/12 → 10/12(天蝎 10 → 1、水瓶 8 → 1),低于验收底线,按红线 2 停下、未落地。原书标准盘 Table 17 / Chart 3 / p.43 (e) 例与同书 Table 23 软件判定互相冲突,需产品与占星师二选一(见进度记录 T1 节)。替代证据:试算脚本与三种读法对照表。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ce6c74ee..2a67b86d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-10-12 — 对话里明说要校正就直接进入(待验收) + +- 用户这一轮明确要求做生时校正时,直接打开校正,不先写解读、不用点卡。推得不准时的提议仍走卡片。做不了会用一句话说明,不再沉默。普通对话不承诺收窄出生时间,申报时段不写大运、子运的起止年月日。 + ## 2026-10-09 — 事业判断表旁边注明不按星性写方向(已合入 staging;回测仍有 1 次,记为已知残余) - 事业判断表里,自然代表星、Jaimini 代表星、大运主星和宫主这几行旁边加了一句:只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。其他领域不加。行和取值没有改。系统提示没有改。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 214afc40..9ef93c98 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -17538,8 +17538,8 @@ ## BUG-1306 | 对话里明说「帮我做生时纠正」进不了校正:模型不出提议卡,反而在普通对话里自己「收窄出生时间」 -- 状态:open(根因已定,修复单 `docs/tasks/TASK-rectification-explicit-entry-20261012.md` 待领取;T0 取证未完成) -- 首次发现 / 最近更新:2026-10-11 / 2026-10-11 +- 状态:fixed-pending-verify(实现在 `codex/rectification-explicit-entry-20261012`,未 push;T0 无主机日志,T5 无模型凭据) +- 首次发现 / 最近更新:2026-10-11 / 2026-10-12 - 来源:产品在 staging(`de4cfeb8`)新对话第一句输入「请你帮我进行生时纠正」,回复截图转述。 - 现象:回复显示「已完成 3 步」,正文说「收窄出生时间这件事能做」,要用户报有年月的经历,「对得上,区间就能往里收」;全文没有提到提议卡,界面上也没有卡片。正文还按「代表分钟」写出大运 / 子运起止年月。 - 触发条件:申报出生时段的本人档案;首轮;用户明确要求做校正。 @@ -17549,10 +17549,13 @@ 3. BUG-1248 上线前的模型实测只覆盖「不认同解释 / 过去的事对不上 + 时间是估的 / 家人 / 已提议又被拒」四种说法,没有「首轮直接点名要校正」。 4. 服务端拒绝原因中 `product_disabled`、`pricing_unavailable` 要求正文「不要提校正」,用户看到的是没有回应。 - 推断(未证实):正文主动承诺能收窄,不像收到「不要提校正」的拒绝,更像模型没有调用提议工具;无模型凭据、无 staging 运行日志,暂未取证。另:用户若照做在普通对话里报经历,这些经历不会进入校正引擎计分,正文「区间就能往里收」是做不到的承诺;按代表分钟写大运起止,与申报时段指令「精确应期一律不可用」不符。 -- 待做:取该轮观测记录确认是否调用了工具及服务端结果(任务书 T0;观测只打到 web 容器控制台,需 staging 主机日志)。 +- 待做:取该轮观测记录确认是否调用了工具及服务端结果(任务书 T0;观测只打到 web 容器控制台,需 staging 主机日志)。本轮没有主机和数据库,查法见进度记录,不阻塞修复。 +- 修复:不新建工具。`offer-birth-time-rectification` 增加 `explicitRequest`。这一轮明确要校正由模型填写;`userAsked` 仍是「提议过之后用户自己再要求才出卡」。服务端判定仍是 `decideRectificationOffer`(本人、开关、无进行中 case、有价)。通过则记下与提议卡同结构、状态直接为 `opened`、尚无 caseId 的记录,`run.completed` 带 `rectificationDirectOpen`,浏览器调用已有的 `openRectificationFromChat`。同一轮已有直达或已有提议时不再开第二次。`bindOfferToCase` 也会绑定这种还没有 caseId 的 `opened` 记录,刷新后按 caseId 找回。进行中的 case 不新开:查到 `agentic_rectification_cases.session_id` 就打开那次,查不到就说可以在历史对话里接着做。`product_disabled` 与 `pricing_unavailable` 对用户是同一句「出生时间核对这会儿还做不了。」家人仍是「给家人校正暂时还不支持」。四种拒绝说明都不再写「不要提校正」。没有明确要求时的拒绝仍走原来的排盘回答。直达和明确拒绝把首轮合同视为就绪,不跑「运行合同不完整」重试、不跑空答重试、不跑 Pass4,已流出的正文换成那一句。这一轮结算走 `complete_consultation_free`,不走 `complete_consultation_response`;新开时再把提议补到会话消息上,不改请求行。规则仍只定义在 `RECTIFICATION_OFFER_RULE`:不承诺收窄出生时间,不在对话里收集经历做校正。申报时段指令补「大运、子运只说在哪一段附近,不写起止年月日」。不改校正引擎、打分、闸门和数据库。 +- 验证:`npx tsc --noEmit` 0 错。`npm run lint` 0 error(125 条既有 warning)。定向 `rectification-explicit-entry-20261012` 与 `rectification-in-chat-20261007` 26 通过。合同套件 236 条里 235 通过;唯一失败是 Skill 符号链接 EPERM。`CI=1 npm test` 4922 条,通过 4780,失败 101,cancelled 0;失败在本轮没改的文件(无 Docker、符号链接、WSL、路径拼接、junction 下的依赖解析)。telemetry 合同按直达结算改了一条断言,改完 4 条通过。默认 `next build` 因 worktree `node_modules` junction 被 Turbopack 拒绝;`npm run build -- --webpack` 编译与 TypeScript 已过,收集 page data 时 Skill 符号链接 EPERM,没有路由表,`/` Static 与首屏 gzip 未测。T5 未跑模型。 +- 防复发:`frontend/tests/rectification-explicit-entry-20261012.test.ts` 锁住直达、同一轮不重复开案、无 caseId 的 opened 绑定与找回、四种拒绝说明、两条路径不重试、普通咨询仍重试一次、规则只定义一处、申报时段「在哪一段附近」、结算调用 `complete_consultation_free`。用户可见句长度 ≤20。真机清单 `docs/testing/rectification-explicit-entry-20261012.md`。 - 相关记录:BUG-1247、BUG-1248(入口改为提议卡)、BUG-976/977(禁正则判意图)。 - 复发自:无。 -- 修复版本:未修复。 +- 修复版本:本分支提交,未 push,未部署。 - 决策日志(2026-10-12,产品):选 A——用户明确要求做生时校正时直接进入校正,不先写解读、不用点卡;意图由模型判断,禁正则 / 关键词。**推翻 BUG-1248 两处**:①明确要求的这一轮不再「只出卡、点卡才进」;②`product_disabled`、`pricing_unavailable`「正文不要提校正」改为必须一句话说明做不了,不得静默。普通对话模型不得承诺收窄出生时间、不得在对话里收集经历假装校正;申报时段下不得写大运 / 子运精确起止。服务端本人 / 开关 / 进行中 case / 价格判定不变。同日另立 BUG-1307(最近分界开场)、BUG-1308(父母静态题研究)。 ## BUG-1307 | 生时校正开场不说记录时间离最近分界多近,首轮选题面向整个窗口,用户觉得步骤多、目的不明 diff --git a/docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md b/docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md new file mode 100644 index 00000000..1d958012 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-explicit-entry-20261012.md @@ -0,0 +1,54 @@ +# PROGRESS · 明说要校正就直接进入(BUG-1306,2026-10-12) + +分支 `codex/rectification-explicit-entry-20261012`,基线 `e6baff95`。未 push,未部署。 + +## 结论 + +用户这一轮明确要求做生时校正时,直接打开校正:不先写解读,不用点卡。是否明确要求由模型填写 `explicitRequest`,不用关键词。推得不准时的主动提议仍走卡片。做不了时用一句话说明。普通对话的规则写明不承诺收窄出生时间,也不在对话里收集经历做校正。申报时段的大运、子运只说在哪一段附近。 + +## 实现 + +沿用现有工具 `offer-birth-time-rectification`,不新建工具。服务端判定仍是 `decideRectificationOffer`。通过后记录状态为 `opened`、还没有 caseId 的提议,浏览器调用已有的 `openRectificationFromChat`。已有进行中的 case 不新开,查到会话就接着打开。结算走 `complete_consultation_free`,新开时再把提议补到会话消息上,不改 `consultation_requests`。`Home()` 没有新增 `useState` / `useRef`。 + +## T0 给产品的查法 + +本 worktree 没有 staging 主机,读不到 web 容器日志,也没有数据库。不阻塞实现。 + +只查该时段 web 容器日志里的 `[agent-observability]`: + +1. `toolCalls` 里有没有 `offer-birth-time-rectification`。 +2. 该次调用的 status(有没有放行、有没有 `offered`)。 +3. 该会话的 `messages` 里有没有 `rectificationOffer` 这个字段。 + +不要打开回答正文,不要查看出生资料。 + +## T5 + +环境里没有可用模型凭据。`MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY` 与常见供应商 key、数据库连接变量都不在本机环境里(只看了是否存在,没有读取值)。供应商 key 在管理端数据库里加密保存,本机没有数据库,不能解密,也没有调用模型。八个场景写在 `docs/testing/rectification-explicit-entry-20261012.md`。 + +## 既有断言 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| `rectification-in-chat-20261007.test.ts` 的断言原文没有改。 | 无。 | 测试辅助函数 `offerTool()` 补了钩子新必填字段 `sourceSessionId` 与 `loadResumableSessionId`,否则类型检查过不了。期望值没有改。 | +| `consult-evidence-card-telemetry-20260927.test.ts`:本命片段匹配 `answerForTelemetry = output` 之后紧接着能看到 `return settleRun(() => completeResponse(`。 | 非直达仍是 `if (!state.rectificationDirectEntry) answerForTelemetry = output`,本命 `onComplete` 调用 `settleAnswer`;`settleAnswer` 在没有直达时 `return settleRun(() => completeResponse(`。 | BUG-1306:直达轮不把模型正文当回答,结算改走 `complete_consultation_free`。普通本命轮仍记 telemetry,仍走 `completeResponse`。 | + +## 验证 + +| 命令 | 结果 | +| --- | --- | +| `npx tsc --noEmit`(frontend) | 0 错 | +| `npm run lint` | 0 error,125 条 warning,都是既有未使用变量和 hook 依赖,没有新增 error | +| `rectification-explicit-entry-20261012` + `rectification-in-chat-20261007` | 26 通过,0 失败 | +| 合同套件(agentic runtime、stream recovery、birth accuracy、workflow contract、home shell、smalltalk、composer、entrypoint、rectification entry) | 236 条,235 通过。唯一失败:`consultation-agentic-runtime.test.ts`「personal Agent exposes the Jyotish Skill and named server tool」。Windows 建 Skill 符号链接 EPERM。该测试与 `skill-package-registry.ts`、`skill-binding.ts` 本轮没改 | +| `Home()` useState / useRef | 增长合同通过,没有新增 | +| `CI=1 npm test` | 4922 条,通过 4780,失败 101,cancelled 0。失败都在本轮没改的文件:无 Docker、Windows 不能建符号链接、WSL 不可用、路径被拼成 `G:\G:\`、`node_modules` junction 下 tailwind-merge / sonner 解析失败、迁移相对路径、临时目录 EBUSY。唯一由本轮源码形状引起的是 telemetry 那条,断言已按上表改过,该文件再跑 4 条通过。`consult-single-pass-answer-20260927.test.ts` 在 import 时因 Skill 符号链接 EPERM 整文件退出,路由断言没跑到 | +| `next build` | 默认 Turbopack 失败:`node_modules` junction 指向文件系统根之外。`npm run build -- --webpack` 编译成功(7.8 分钟),TypeScript 通过(4.1 分钟)。收集 page data 时 Skill 符号链接 EPERM(`/api/consult`、`/api/reports`、`/api/rectification/agent`、`/api/birth-time-guide`)。没有路由表,因此没有证明 `/` 仍是 Static,也没有首屏 gzip。没有 `npm install`,没有删 junction,没有改 next 配置。 | + +## 环境缺口 + +- T0:无 staging `docker logs`,无数据库。 +- T5:无模型凭据,未调用模型。 +- 定向套件里的 Skill 符号链接 EPERM 是本机 Windows 限制。 +- `frontend/node_modules` 是指向主仓的 junction。没有 `npm install`,没有删 junction。 +- `next build`:Turbopack 拒绝该 junction;webpack 编译与类型检查已过,收集 page data 时不能建 Skill 符号链接。`/` Static 与首屏 gzip 未测。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 93820b76..b643a531 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -124,7 +124,7 @@ | `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` | | `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 已合入(无独立验收记录) | `1b508d5e`(BUG-920/921 resolved;已部署 staging,run 2715);真机待核 | | `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 已验收 | `6d81062b`(Claude 验收:T1–T5 全通过;409 `session_not_consultation` 在扣点前、不扣点;回退路径多修了归档一条;page.tsx 1830→1830。残留 P3:列表只剩校正会话时 `activeSession` 为空,输入框可用但发送静默无效 → BUG-935 在 fix 单);真机三条欠 | -| `TASK-rectification-explicit-entry-20261012.md` | — | **明说要校正就直接进入(BUG-1306)**:模型判断意图(禁正则),服务端判定后直接开案并绑定来源对话;首轮排盘合同对此轮豁免;拒绝必须一句话说明(推翻 BUG-1248 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 待领取 | — | +| `TASK-rectification-explicit-entry-20261012.md` | `PROGRESS-rectification-explicit-entry-20261012.md` | **明说要校正就直接进入(BUG-1306)**:模型判断意图(禁正则),服务端判定后直接开案并绑定来源对话;首轮排盘合同对此轮豁免;拒绝必须一句话说明(推翻 BUG-1248 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 待验收 | — | | `TASK-rectification-nearest-boundary-opening-20261012.md` | — | **最近分界开场(BUG-1307)**:开场说记录时间离最近分盘换段点多近、两侧哪些层不同,近分界时首轮只问该分界;不改打分 / 闸门 / 4 件 3 域,77 例回放不降;改 page.tsx / 校正开场组件的部分须在 BUG-1306 合入后 | 待领取 | — | | `TASK-rectification-d12-parent-probes-research-20261012.md` | — | **父母静态是非题(D12)研究(BUG-1308)**:公开案例 ≥60、预登记、乱序与正对照,过门才立实现单;纯研究可并行 | 待领取 | — | | `TASK-rectification-1012-batch-fix-20261012.md` | — | **10-12 三单验收修复**:entry `6a799582` 通过(合入待产品);boundary `b285011a` 未通过——F1 开场与段汇总岁差写死 Raman(BUG-1307 / 新 BUG-1309)、F2 本命上升换段不说;F3 打开失败卡片仍说已开始(BUG-1306 跟进);F4 研究报告补编码问题与暂停结论(BUG-1308) | 待领取 | — | diff --git a/docs/testing/rectification-explicit-entry-20261012.md b/docs/testing/rectification-explicit-entry-20261012.md new file mode 100644 index 00000000..d917c9f9 --- /dev/null +++ b/docs/testing/rectification-explicit-entry-20261012.md @@ -0,0 +1,16 @@ +# 真机清单 · 明说要校正就直接进入(2026-10-12) + +对应 BUG-1306,任务书 `docs/tasks/TASK-rectification-explicit-entry-20261012.md` 的 T5。在 staging 上用自己的账号走。deepseek-v4-flash 一遍,产品默认模型再一遍。每个场景至少两次。回答原文和出生资料不记进仓库。本人档案除第 6 步外都用本人。申报时段用第 8 步;其余有分钟的档案即可。 + +| 步 | 怎么做 | 通过标准 | +| --- | --- | --- | +| 1 | 新对话第一句:「请你帮我进行生时纠正」 | 直接打开校正。这一轮不写解读,不用点卡。过渡只有一句 | +| 2 | 另开三条新对话,分别说「帮我校正出生时间」「想核对一下出生时间准不准」「我出生时间是估的,帮我对一下」 | 三次都直接打开,不先出卡 | +| 3 | 新对话先问一件普通的事,答完后再说「请帮我做生时校正」 | 这一轮直接打开,不把刚才的解读再写一遍 | +| 4 | 新对话问事业,答完后说「你说我事业不顺,我觉得挺顺的」 | 认下、改读。不进入校正,下面没有提议卡 | +| 5 | 接着说一件过去的事和盘对不上,并说出生时间是估的,但不要说「帮我校正」 | 回答下面出现提议卡。不直接打开校正 | +| 6 | 换到一个家人档案,第一句就说「请帮我校正出生时间」 | 一句「给家人校正暂时还不支持」。不打开校正 | +| 7 | 在校正开关关闭,或价格暂时不可用时,本人说「请帮我做生时校正」 | 一句「出生时间核对这会儿还做不了。」不沉默,不写价格,不写内部原因。构造不了这一步就在进度里标环境缺口,不要改开关去试 | +| 8 | 申报出生时段的本人档案,问一件普通的事,不要提校正 | 正文不写大运、子运的起止年月日,也不承诺能收窄出生时间 | + +做不了的两句和家人那句以界面上的原句为准。点数:直接进入的那一轮不按解读扣咨询点;校正本身仍按原来的开案扣费。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 417d7e84..23398c9f 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -11,6 +11,8 @@ 不新增等待动画;打开校正沿用既有一次揭幕。 +**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。 + ## 用量页分段计时(2026-10-05,在途) `/admin/usage` 在原表格上多一列「分段」。格子里四行:分类耗时、第 0 步、第 1 步、第 1 步开始到第一个正文字符。每步带推理、输出、输入、缓存输入 token。供应商没返回的数显示「—」。不新开页面,不改表,数字来自 `usage_ledger.metadata` 里已有的 JSON。没有新的等待动画或输入框。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 38be8527..3b7963e6 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -23,10 +23,12 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。 -## 生时校正从对话进入(2026-10-07,BUG-1247~1251) +## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306) -- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正;说出过去的具体事情和盘对不上、说出生时间是估的或记不准、或明确想核对出生时间,agent 才调用提议工具。同一次对话提议过、用户没接,不再主动提;规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。 -- **正文怎么提**:一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。给家人档案时说「给家人校正暂时还不支持」,不出卡。 +- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,才调用提议工具。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。 +- **明确要求则直接进入**:用户这一轮明确要做校正,由模型判断,不用关键词。服务端放行后直接打开校正:不先写解读,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。 +- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」 +- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。 - **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。 - **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。 diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index f854f2c5..4bd2b9e2 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -735,7 +735,7 @@ export default function Home() { startNewChat, continueInNewChat, refreshAccount, - openAccountDialog, openRectificationSession, + openAccountDialog, openRectificationSession, openRectificationFromChat, onboardingPaywallShown, setOnboardingPaywallOpen, }); const jumpToLatestVisible = !rectificationSurfaceOpen diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 7841a538..ad10b2f1 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -15,7 +15,8 @@ import { recordModerationEvent } from "@/lib/moderation/log"; import { consultationDomainSchema } from "@/lib/consultation-domain-registry"; import { parseAgentReply } from "@/lib/agent-reply"; import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat"; -import { decideRectificationOffer } from "@/lib/rectification-in-chat-server"; +import { decideRectificationOffer, persistOpenedDirectOffer } from "@/lib/rectification-in-chat-server"; +import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service"; import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata"; import { ConsultationPlanValidationError, @@ -244,7 +245,7 @@ function generalNoMinuteInstruction(hasPublicDaily: boolean) { } function declaredWindowInstruction() { - return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。这只是粗看。" + RECTIFICATION_OFFER_RULE; + return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。大运、子运只说「在哪一段附近」,不写起止年月日。这只是粗看。" + RECTIFICATION_OFFER_RULE; } function usesPublicDailyGeneralAgent( @@ -902,6 +903,55 @@ export async function POST(request: Request) { } } + // Explicit rectification (BUG-1306): one short sentence, same free settlement + // as smalltalk, so a turn with no reading does not spend a consultation credit. + // The RPC message is only role/text/responseKind. A new case's offer is patched + // onto the session message afterwards and is not written back onto the request. + async function completeDirectRectificationTurn( + usage: Promise<{ inputTokens?: number; outputTokens?: number }>, + ): Promise { + const entry = latencyState?.rectificationDirectEntry; + const offer = latencyState?.rectificationOffer; + if (!entry) throw new Error("direct_entry_missing"); + try { + const actualUsage = await usagePayload(usage); + const responseMessage = { role: "assistant" as const, text: entry.sentence, responseKind: "smalltalk" as const }; + const completion = await retryDetachedSettlement(async () => { + const { data, error } = await accounting.rpc("complete_consultation_free", { + p_user_id: userId, + p_request_id: requestId, + p_session_id: sessionId, + p_response_message: responseMessage, + p_actual_usage: actualUsage, + }); + const parsedCompletion = consultationCompletionSchema.safeParse(first(data ?? [])); + if (error || !parsedCompletion.success) { + throw new CreditRpcError(error?.message || "invalid_free_completion"); + } + if (!parsedCompletion.data.success && parsedCompletion.data.error_code !== "request_cancelled") { + throw new CreditRpcError(parsedCompletion.data.error_code || "free_completion_rejected"); + } + if (parsedCompletion.data.success && entry.kind === "new" && offer) { + await persistOpenedDirectOffer({ + client: supabase, + userId, + sessionId, + sentence: entry.sentence, + offer, + }); + } + return parsedCompletion.data; + }); + if (!completion.success && completion.error_code !== "request_cancelled") { + throw new CreditRpcError(completion.error_code || "free_completion_rejected"); + } + return "completed"; + } catch (error) { + await cancel(); + throw error; + } + } + let settlement: Promise | null = null; function settleResult(action: () => Promise) { settlement ??= action(); @@ -966,6 +1016,21 @@ export async function POST(request: Request) { }), alreadyOffered: sessionHasRectificationOffer(chatSession?.messages), revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId), + sourceSessionId: sessionId, + loadResumableSessionId: async () => { + const summary = await getRectificationEntrySummary(accounting, userId); + const caseId = summary.latestResumable?.caseId; + if (!caseId) return null; + const { data, error } = await accounting + .from("agentic_rectification_cases") + .select("session_id") + .eq("user_id", userId) + .eq("id", caseId) + .maybeSingle(); + if (error || !data) return null; + const resumableSessionId = (data as { session_id?: unknown }).session_id; + return typeof resumableSessionId === "string" && resumableSessionId ? resumableSessionId : null; + }, }; const hooks = createConsultationRuntimeHooks(state); const usages: Promise[] = []; @@ -1203,6 +1268,28 @@ export async function POST(request: Request) { } : { route: "pending", status: "blocked", preciseTiming: "blocked", missingLayers: [] }; + const settleAnswer = ( + output: string, + agentExecutionReceipt: AgentExecutionReceipt, + techniqueTruth: string, + workflow: WorkflowReceipt, + thinkingText?: string, + thinkingSections?: PublicThinkingSection[], + ) => { + if (state.rectificationDirectEntry) { + return settleRun(() => completeDirectRectificationTurn(mergeUsage(usages)), undefined); + } + return settleRun(() => completeResponse( + output, + mergeUsage(usages), + techniqueTruth, + workflow, + agentExecutionReceipt, + thinkingText, + thinkingSections, + ), undefined); + }; + if (usesPublicDailyGeneralAgent(consultationMode, generalDailyContext)) { state.workflowReceipt = workflowReceipt; const agent = getGeneralJyotishAgent(selectedModel); @@ -1260,15 +1347,14 @@ export async function POST(request: Request) { headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, - onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse( + onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleAnswer( output, - mergeUsage(usages), + agentExecutionReceipt, generalDailyContext ? "public-panchanga-only" : "not-applicable", workflowReceipt, - agentExecutionReceipt, thinkingText, thinkingSections, - ), undefined), + ), onError: (error) => settleRun( cancel, toAgentObservabilityErrorCode(error), @@ -1393,15 +1479,14 @@ export async function POST(request: Request) { headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, - onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse( + onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleAnswer( output, - mergeUsage(usages), + agentExecutionReceipt, state.techniqueTruth ?? "declared-window", state.workflowReceipt ?? workflowReceipt, - agentExecutionReceipt, thinkingText, thinkingSections, - ), undefined), + ), onError: (error) => settleRun( cancel, toAgentObservabilityErrorCode(error), @@ -1506,16 +1591,15 @@ export async function POST(request: Request) { onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => { - answerForTelemetry = output; - return settleRun(() => completeResponse( + if (!state.rectificationDirectEntry) answerForTelemetry = output; + return settleAnswer( output, - mergeUsage(usages), + agentExecutionReceipt, state.techniqueTruth ?? "unknown", state.workflowReceipt ?? workflowReceipt, - agentExecutionReceipt, thinkingText, thinkingSections, - ), undefined); + ); }, onError: (error) => settleRun( cancel, diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index b3c1ebcd..494b710c 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -155,6 +155,8 @@ export type ConsultationRunParams = { onboardingPaywallShown?: MutableRefObject; setOnboardingPaywallOpen?: (open: boolean) => void; openRectificationSession: (exactSessionId: string) => Promise | void; + /** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */ + openRectificationFromChat: (sourceSessionId: string) => Promise | void; }; export function useConsultationRun(params: ConsultationRunParams) { @@ -215,6 +217,7 @@ export function useConsultationRun(params: ConsultationRunParams) { onboardingPaywallShown, setOnboardingPaywallOpen, openRectificationSession, + openRectificationFromChat, } = params; // First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080). const openRedeemPaywallOnce = () => tryOpenRedeemPaywall({ @@ -874,6 +877,7 @@ export function useConsultationRun(params: ConsultationRunParams) { }; let agentExecutionReceipt: AgentExecutionReceipt | undefined; let rectificationOffer: RectificationOfferRecord | undefined; + let rectificationDirectOpen: Extract["rectificationDirectOpen"]; let runCompleted = false; let truncatedFailure: Extract | undefined; const reader = response.body.getReader(); @@ -953,6 +957,7 @@ export function useConsultationRun(params: ConsultationRunParams) { if (event.type === "run.completed") { runCompleted = true; rectificationOffer = event.rectificationOffer; + rectificationDirectOpen = event.rectificationDirectOpen; if (event.responseKind === "smalltalk") { responseKind = "smalltalk"; } else if (event.receipt) { @@ -1054,7 +1059,10 @@ export function useConsultationRun(params: ConsultationRunParams) { messages: [...userSession.messages, { role: "assistant", text: reply.text, - ...(responseKind === "smalltalk" ? { responseKind } : { + ...(responseKind === "smalltalk" ? { + responseKind, + ...(rectificationOffer ? { rectificationOffer } : {}), + } : { ...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}), ...(thinkingSections.length ? { thinkingSections } : {}), techniqueTruth, @@ -1066,6 +1074,11 @@ export function useConsultationRun(params: ConsultationRunParams) { updatedAt: timestamp(), }; updateSession(sessionId, () => completedSession); + if (rectificationDirectOpen?.mode === "resume" && rectificationDirectOpen.resumeSessionId) { + void openRectificationSession(rectificationDirectOpen.resumeSessionId); + } else if (rectificationDirectOpen?.mode === "new") { + void openRectificationFromChat(rectificationDirectOpen.sourceSessionId); + } try { await persistSession(completedSession); } catch (error) { diff --git a/frontend/src/lib/consultation-agent-events.ts b/frontend/src/lib/consultation-agent-events.ts index 4b3d4ce0..de91456f 100644 --- a/frontend/src/lib/consultation-agent-events.ts +++ b/frontend/src/lib/consultation-agent-events.ts @@ -139,12 +139,21 @@ const sessionTitleSchema = z.object({ type: z.literal("session.title"), title: z.string().trim().min(1).max(48), }).strict(); +/** Browser opens rectification from this turn without a card click (BUG-1306). */ +const rectificationDirectOpenSchema = z.object({ + sourceSessionId: z.string().uuid(), + mode: z.enum(["new", "resume"]), + resumeSessionId: z.string().uuid().optional(), +}).strict(); + const runCompletedSchema = z.object({ type: z.literal("run.completed"), receipt: agentExecutionReceiptSchema.optional(), responseKind: z.literal("smalltalk").optional(), /** The answer offered birth-time rectification (BUG-1248); the card hangs under it. */ rectificationOffer: rectificationOfferRecordSchema.optional(), + /** Present for a direct open or resume. A refusal is smalltalk and omits this. */ + rectificationDirectOpen: rectificationDirectOpenSchema.optional(), }).strict(); // A failure is the case the receipt is most needed for, so it carries the same // allowlisted receipt a completed run does. It stays optional because the diff --git a/frontend/src/lib/consultation-thinking-plan.ts b/frontend/src/lib/consultation-thinking-plan.ts index ced5192a..4375fa9c 100644 --- a/frontend/src/lib/consultation-thinking-plan.ts +++ b/frontend/src/lib/consultation-thinking-plan.ts @@ -455,11 +455,11 @@ export function natalAnswerShapeInstruction(): string { } /** - * C2 (TASK-rectification-in-chat-step1-20261006, BUG-1248): when "推得不准" - * may be the birth time, the agent offers rectification through the offer - * tool; it is never decided from keywords. Defined here once. + * C2 (BUG-1248) plus direct entry when this turn explicitly asks (BUG-1306). + * 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,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 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/lib/rectification-in-chat-server.ts b/frontend/src/lib/rectification-in-chat-server.ts index bcf34326..98bf2dd6 100644 --- a/frontend/src/lib/rectification-in-chat-server.ts +++ b/frontend/src/lib/rectification-in-chat-server.ts @@ -7,11 +7,13 @@ import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/cas import { loadV9CaseDossier } from "@/lib/rectification-agentic/v9/tool-service"; import { appendRectificationReturn, + attachOpenedOfferToFreeMessage, bindOfferToCase, decideRectificationOfferWith, offerForCase, sourceFromSessionRows, type RectificationOfferDecision, + type RectificationOfferRecord, type RectificationReturnRecord, type RectificationSource, } from "@/lib/rectification-in-chat"; @@ -61,6 +63,35 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId: return !error; } +/** + * After a free direct-entry reply is appended, hang the opened offer on that + * message. Idempotent: an offer already on the message (including one a later + * bind gave a case id) is left as it is. Does not touch consultation_requests. + */ +export async function persistOpenedDirectOffer(input: { + client: SupabaseClient; + userId: string; + sessionId: string; + sentence: string; + offer: RectificationOfferRecord; +}): Promise { + const { data, error } = await input.client + .from("chat_sessions") + .select("messages") + .eq("id", input.sessionId) + .eq("user_id", input.userId) + .maybeSingle(); + if (error || !data) throw new Error("direct_offer_session_unreadable"); + const attached = attachOpenedOfferToFreeMessage((data as { messages?: unknown }).messages, { + sentence: input.sentence, + offer: input.offer, + }); + if (attached === "present") return; + if (attached === "missing") throw new Error("direct_offer_message_missing"); + const wrote = await writeMessages(input.client, input.userId, input.sessionId, attached); + if (!wrote) throw new Error("direct_offer_write_failed"); +} + /** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */ export async function bindRectificationSource(input: { client: SupabaseClient; diff --git a/frontend/src/lib/rectification-in-chat.ts b/frontend/src/lib/rectification-in-chat.ts index eb851371..eda833b2 100644 --- a/frontend/src/lib/rectification-in-chat.ts +++ b/frontend/src/lib/rectification-in-chat.ts @@ -70,6 +70,18 @@ export const RECTIFICATION_OFFER_OPENED_COPY = "出生时间核对已经开始 export const RECTIFICATION_RETURN_BUTTON_LABEL = "回到刚才的对话"; export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间"; +/** + * User-visible sentences for a turn that enters rectification directly + * (BUG-1306). `complete_consultation_free` rejects text longer than 20 + * characters, so these stay short. Same sentence for "not open" and "no price": + * the user does not see the internal reason. + */ +export const RECTIFICATION_DIRECT_NEW_COPY = "好,我们开始核对出生时间。"; +export const RECTIFICATION_DIRECT_RESUME_COPY = "核对还没做完,我们接着做。"; +export const RECTIFICATION_DIRECT_UNAVAILABLE_COPY = "出生时间核对这会儿还做不了。"; +export const RECTIFICATION_DIRECT_NOT_SELF_COPY = "给家人校正暂时还不支持"; +export const RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY = "可在历史对话里接着做。"; + export function rectificationReturnDividerText(record: Pick): string { return record.adopted && record.clock ? `出生时间改为 ${record.clock}(生时校正)` @@ -136,7 +148,10 @@ export function bindOfferToCase( } for (let index = stored.length - 1; index >= 0; index -= 1) { const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer); - if (!offer || offer.status !== "offered") continue; + if (!offer || offer.caseId) continue; + // A direct open records `opened` before the case id exists (BUG-1306). + // An offer already bound to another case is left alone. + if (offer.status !== "offered" && offer.status !== "opened") continue; const next = [...stored]; next[index] = { ...stored[index], @@ -147,6 +162,29 @@ export function bindOfferToCase( return null; } +/** + * After `complete_consultation_free` appends the one-sentence reply, hang the + * opened offer on that message so a reload can still find the source. Returns + * the next array, "present" when that message already has an offer (do not + * overwrite a later case id), or "missing" when the free reply is not there yet. + */ +export function attachOpenedOfferToFreeMessage( + messages: unknown, + input: { sentence: string; offer: RectificationOfferRecord }, +): StoredMessage[] | "present" | "missing" { + const stored = asMessages(messages); + if (!stored) return "missing"; + for (let index = stored.length - 1; index >= 0; index -= 1) { + const message = stored[index]!; + if (message.role !== "assistant" || message.text !== input.sentence || message.responseKind !== "smalltalk") continue; + if (parseRectificationOfferRecord(message.rectificationOffer)) return "present"; + const next = [...stored]; + next[index] = { ...message, rectificationOffer: input.offer }; + return next; + } + return "missing"; +} + /** The offer bound to this case, if this session holds one. */ export function offerForCase(messages: unknown, caseId: string): RectificationOfferRecord | null { for (const message of asMessages(messages) ?? []) { diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 7a423dae..6ed85b66 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -313,7 +313,8 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable { + // Direct entry ends on the server sentence. Further model text is not the answer. + if (options.state.rectificationDirectEntry) return; // Text the model writes before the contract is ready is not the answer: it // is the model narrating its own in-progress or failed tool calls. Holding // it meant a later successful call released that narration as the entire @@ -1058,6 +1083,23 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { throw new Error("runtime_contract_incomplete"); } } + const directCompleted = rectificationDirectRunCompleted(options.state); + if (directCompleted) { + const sentence = options.state.rectificationDirectEntry?.sentence ?? ""; + fullOutput = sentence; + send(controller, { type: "answer.delta", text: sentence, replace: true }); + emitted = true; + recordAnswerTelemetry(); + settling = true; + const receipt = agentExecutionReceiptSchema.parse(options.receipt()); + await options.onComplete?.(sentence, receipt, undefined, undefined); + settled = true; + settling = false; + await sideEvent; + send(controller, directCompleted); + if (!disconnected) controller.close(); + return; + } if (!deliveredDegraded) { // The loop's own final step wrote the answer with the calculation // in its context (BUG-1053); there is no second, blind compose. diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index fd8bec79..7b845e13 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -1,4 +1,9 @@ import { + RECTIFICATION_DIRECT_NEW_COPY, + RECTIFICATION_DIRECT_NOT_SELF_COPY, + RECTIFICATION_DIRECT_RESUME_COPY, + RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, + RECTIFICATION_DIRECT_UNAVAILABLE_COPY, RECTIFICATION_OFFER_TOOL_ID, type RectificationOfferDecision, type RectificationOfferRecord, @@ -276,6 +281,18 @@ export type ConsultationRuntimeState = { jyotishSkillBound: boolean; /** Set when this turn's answer offered birth-time rectification (BUG-1248); persisted with the answer. */ rectificationOffer?: RectificationOfferRecord; + /** + * Set when this turn explicitly asked to rectify (BUG-1306). The stream + * replaces the answer with `sentence` and does not charge a reading. + * `new` opens a case; `resume` opens the one already in progress; `refused` + * only says why it cannot. + */ + rectificationDirectEntry?: { + kind: "new" | "resume" | "refused"; + sentence: string; + sourceSessionId: string; + resumeSessionId?: string; + }; skillReferenceReadCount: number; methodologySectionCount: number; consultationToolStarted: boolean; @@ -457,6 +474,13 @@ export type RectificationOfferHook = Readonly<{ decide: () => Promise; alreadyOffered: boolean; revisitQuestion: string; + /** This consultation. Direct entry opens from it (BUG-1306). */ + sourceSessionId: string; + /** + * Called only for an explicit request while a case is already in progress. + * The rectification session to resume, or null when it cannot be read. + */ + loadResumableSessionId: () => Promise; now?: () => Date; }>; @@ -1039,40 +1063,120 @@ const evidenceLookupInputSchema = z.object({ }).strict(); const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly["reason"], string>> = { - not_self: "问的不是用户本人的盘,生时校正暂时只支持本人。正文用一句话照实说明(例:「给家人校正暂时还不支持」),不出卡。", - product_disabled: "生时校正暂时没有开放。正文不要提校正。", + not_self: `问的不是用户本人的盘,生时校正暂时只支持本人。正文用这一句说明:「${RECTIFICATION_DIRECT_NOT_SELF_COPY}」,不出卡。`, + product_disabled: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写内部原因。`, case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。", - pricing_unavailable: "生时校正这会儿提供不了。正文不要提校正。", + pricing_unavailable: `出生时间核对这会儿做不了。正文必须用这一句说明:「${RECTIFICATION_DIRECT_UNAVAILABLE_COPY}」,不写价格。`, }; +function offerRecord( + hook: RectificationOfferHook, + decision: Extract, + status: RectificationOfferRecord["status"], +): RectificationOfferRecord { + return { + status, + creditCost: decision.creditCost, + questionEstimate: decision.questionEstimate, + sourceQuestion: hook.revisitQuestion, + offeredAt: (hook.now?.() ?? new Date()).toISOString(), + }; +} + /** - * The offer card tool (C3, BUG-1248). It only records the offer for this turn; - * the server decides whether a card may be shown and the card itself carries - * the question count and price. Never calculates, never opens a case. + * Explicit request (BUG-1306): the server already decided. A pass records an + * opened offer with no case id yet and tells the stream to open. A case + * already in progress resumes that case. Anything else is one sentence. + */ +async function finishExplicitRectificationRequest( + state: ConsultationRuntimeState, + hook: RectificationOfferHook, + decision: RectificationOfferDecision, +) { + if (decision.ok) { + state.rectificationOffer = offerRecord(hook, decision, "opened"); + state.rectificationDirectEntry = { + kind: "new", + sentence: RECTIFICATION_DIRECT_NEW_COPY, + sourceSessionId: hook.sourceSessionId, + }; + return { + offered: true as const, + directEntry: "new" as const, + note: `只写这一句过渡,不写解读:「${RECTIFICATION_DIRECT_NEW_COPY}」`, + }; + } + if (decision.reason === "case_in_progress") { + let resumeSessionId: string | null = null; + try { + resumeSessionId = await hook.loadResumableSessionId(); + } catch { + resumeSessionId = null; + } + if (resumeSessionId) { + state.rectificationDirectEntry = { + kind: "resume", + sentence: RECTIFICATION_DIRECT_RESUME_COPY, + sourceSessionId: hook.sourceSessionId, + resumeSessionId, + }; + return { + offered: false as const, + reason: decision.reason, + directEntry: "resume" as const, + note: `只写这一句,不写解读:「${RECTIFICATION_DIRECT_RESUME_COPY}」`, + }; + } + state.rectificationDirectEntry = { + kind: "refused", + sentence: RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, + sourceSessionId: hook.sourceSessionId, + }; + return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES.case_in_progress }; + } + const sentence = decision.reason === "not_self" + ? RECTIFICATION_DIRECT_NOT_SELF_COPY + : RECTIFICATION_DIRECT_UNAVAILABLE_COPY; + state.rectificationDirectEntry = { + kind: "refused", + sentence, + sourceSessionId: hook.sourceSessionId, + }; + return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] }; +} + +/** + * The offer card tool (C3, BUG-1248; direct entry BUG-1306). The server decides + * whether a card may be shown or this turn opens rectification. Never calculates. + * The rule text lives only in RECTIFICATION_OFFER_RULE. */ function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntimeState; rectificationOffer?: RectificationOfferHook }>) { return createTool({ id: RECTIFICATION_OFFER_TOOL_ID, - description: "Offer birth-time rectification under this answer. Call it before writing the answer, only when the user says a concrete past event does not fit the chart, says the birth time is an estimate or unsure, or asks to check the birth time; never for a disagreement with an interpretation. Pass userAsked=true only when the user asked for it in this turn. The result says whether a card will be shown and what to say in one sentence.", - inputSchema: z.object({ userAsked: z.boolean().optional() }).strict(), + description: "Offer birth-time rectification under this answer, or open it when explicitRequest is true. Call before writing the answer. Pass explicitRequest=true only when the user explicitly asks to do rectification this turn; then write only the one transition sentence in the result and no reading. Pass userAsked=true only when the user asks again after an earlier offer in this conversation. Never call it for a disagreement with an interpretation. Do not promise to narrow the birth time or collect life events here. The result says whether a card will be shown and what one sentence to write.", + inputSchema: z.object({ + userAsked: z.boolean().optional(), + explicitRequest: z.boolean().optional(), + }).strict(), execute: async (input) => { const hook = ctx.rectificationOffer; if (!hook) return { offered: false as const, reason: "unavailable", note: RECTIFICATION_OFFER_REFUSAL_NOTES.product_disabled }; - if (ctx.state.rectificationOffer) { - return { offered: true as const, note: "提议卡已经在了。正文用一句话提一下就好,不写价格和题数。" }; + if (input.explicitRequest === true && ctx.state.rectificationDirectEntry) { + return { offered: false as const, note: "已经在打开。不要再写解读。" }; } - if (hook.alreadyOffered && input.userAsked !== true) { + if (ctx.state.rectificationDirectEntry || ctx.state.rectificationOffer) { + if (ctx.state.rectificationOffer) { + return { offered: true as const, note: "提议卡已经在了。正文用一句话提一下就好,不写价格和题数。" }; + } + 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: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" }; } const decision = await hook.decide(); + if (input.explicitRequest === true) return finishExplicitRectificationRequest(ctx.state, hook, decision); if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] }; - ctx.state.rectificationOffer = { - status: "offered", - creditCost: decision.creditCost, - questionEstimate: decision.questionEstimate, - sourceQuestion: hook.revisitQuestion, - offeredAt: (hook.now?.() ?? new Date()).toISOString(), - }; + ctx.state.rectificationOffer = offerRecord(hook, decision, "offered"); return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" }; }, }); diff --git a/frontend/tests/consult-evidence-card-telemetry-20260927.test.ts b/frontend/tests/consult-evidence-card-telemetry-20260927.test.ts index 18d25029..9d5894bf 100644 --- a/frontend/tests/consult-evidence-card-telemetry-20260927.test.ts +++ b/frontend/tests/consult-evidence-card-telemetry-20260927.test.ts @@ -87,7 +87,13 @@ test("the consult route logs the record once per natal turn, without session or assert.match(block, /agentVersion: "consultation-evidence-card-v2"/); assert.doesNotMatch(block, /sessionId|userId/); const natal = route.slice(route.indexOf("const agent = getJyotishAgent(selectedModel, agentContext);")); - assert.match(natal, /answerForTelemetry = output;[\s\S]*return settleRun\(\(\) => completeResponse\(/); + // 原值: /answerForTelemetry = output;[\s\S]*return settleRun\(\(\) => completeResponse\(/ + // 新值: 非直达仍把 output 记入 answerForTelemetry,并经 settleAnswer 调用 completeResponse + // 原因: BUG-1306 直达轮不把模型正文当回答,结算改走 complete_consultation_free + assert.match(natal, /if \(!state\.rectificationDirectEntry\) answerForTelemetry = output;/); + assert.match(natal, /return settleAnswer\(/); + const settle = route.slice(route.indexOf("const settleAnswer ="), route.indexOf("if (usesPublicDailyGeneralAgent")); + assert.match(settle, /return settleRun\(\(\) => completeResponse\(/); // Only a finished answer counts citations; a failed run logs the card size with no citations. assert.doesNotMatch(natal, /onError: \(error, _emitted, output\)/); }); diff --git a/frontend/tests/rectification-explicit-entry-20261012.test.ts b/frontend/tests/rectification-explicit-entry-20261012.test.ts new file mode 100644 index 00000000..4ba246a2 --- /dev/null +++ b/frontend/tests/rectification-explicit-entry-20261012.test.ts @@ -0,0 +1,589 @@ +// BUG-1306: an explicit request opens rectification directly. Fictional data only. +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts"; +import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts"; +import { + RECTIFICATION_DIRECT_NEW_COPY, + RECTIFICATION_DIRECT_NOT_SELF_COPY, + RECTIFICATION_DIRECT_RESUME_COPY, + RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, + RECTIFICATION_DIRECT_UNAVAILABLE_COPY, + RECTIFICATION_OFFER_TOOL_ID, + attachOpenedOfferToFreeMessage, + bindOfferToCase, + offerForCase, + sourceFromSessionRows, + type RectificationOfferRecord, +} from "../src/lib/rectification-in-chat.ts"; +import { CONTRACT_DEGRADED_NOTE, streamAgentResponse } from "../src/lib/stream-agent-response.ts"; +import type { Pass4Mode } from "../src/lib/timing-output-guard.ts"; +import { + consultationStepBudgetReceipt, + createConsultationRuntimeState, + createConsultationTools, + publicConsultationRuntimeSteps, +} from "../src/mastra/consultation-tools.ts"; + +const CASE_ID = "11111111-1111-4111-8111-111111111111"; +const OTHER_CASE = "55555555-5555-4555-8555-555555555555"; +const RECT_SESSION = "22222222-2222-4222-8222-222222222222"; +const SOURCE_SESSION = "33333333-3333-4333-8333-333333333333"; +const RESUME_SESSION = "44444444-4444-4444-8444-444444444444"; +const REQUEST_ID = "66666666-6666-4666-8666-666666666666"; + +const DIRECT_SENTENCES = [ + RECTIFICATION_DIRECT_NEW_COPY, + RECTIFICATION_DIRECT_RESUME_COPY, + RECTIFICATION_DIRECT_UNAVAILABLE_COPY, + RECTIFICATION_DIRECT_NOT_SELF_COPY, + RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, +] as const; + +const STOP_FINISH = { type: "finish", payload: { stepResult: { reason: "stop" }, output: { usage: {}, steps: [{}] } } }; + +type OfferDecision = + | { ok: true; creditCost: number; questionEstimate: number } + | { ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }; + +function offer(status: RectificationOfferRecord["status"] = "offered", extra: Partial = {}): RectificationOfferRecord { + return { + status, + creditCost: 3, + questionEstimate: 8, + sourceQuestion: "我和伴侣还能走下去吗", + offeredAt: "2026-10-07T00:00:00.000Z", + ...extra, + }; +} + +function offerTool(options: { + alreadyOffered?: boolean; + decide?: () => Promise; + loadResumableSessionId?: () => Promise; +} = {}) { + const state = createConsultationRuntimeState(); + let decisions = 0; + let loads = 0; + const tools = createConsultationTools({ + userId: "user-fictional", + sessionId: SOURCE_SESSION, + requestId: REQUEST_ID, + consultationMode: "verified_chart", + serverChart: {} as never, + state, + rectificationOffer: { + decide: async () => { + decisions += 1; + return options.decide ? options.decide() : { ok: true, creditCost: 3, questionEstimate: 8 }; + }, + alreadyOffered: options.alreadyOffered ?? false, + revisitQuestion: "我和伴侣还能走下去吗", + sourceSessionId: SOURCE_SESSION, + loadResumableSessionId: async () => { + loads += 1; + return options.loadResumableSessionId ? options.loadResumableSessionId() : null; + }, + now: () => new Date("2026-10-07T00:00:00.000Z"), + }, + }) as Record Promise> }>; + return { state, decisions: () => decisions, loads: () => loads, tool: tools[RECTIFICATION_OFFER_TOOL_ID]! }; +} + +function src(relative: string) { + return readFileSync(new URL(relative, import.meta.url), "utf8"); +} + +function walkSources(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules") continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkSources(full)); + else if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) out.push(full); + } + return out; +} + +test("direct-entry sentences stay within the free-completion length", () => { + assert.deepEqual(DIRECT_SENTENCES.map((sentence) => Array.from(sentence).length), [13, 13, 14, 11, 11]); + for (const sentence of DIRECT_SENTENCES) assert.ok(Array.from(sentence).length <= 20); + assert.equal(RECTIFICATION_DIRECT_NEW_COPY, "好,我们开始核对出生时间。"); + assert.equal(RECTIFICATION_DIRECT_RESUME_COPY, "核对还没做完,我们接着做。"); + assert.equal(RECTIFICATION_DIRECT_UNAVAILABLE_COPY, "出生时间核对这会儿还做不了。"); + assert.equal(RECTIFICATION_DIRECT_NOT_SELF_COPY, "给家人校正暂时还不支持"); + assert.equal(RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY, "可在历史对话里接着做。"); +}); + +test("an opened offer with no case id binds, and a reload can find that case", () => { + const opened = [{ + role: "assistant", + text: RECTIFICATION_DIRECT_NEW_COPY, + responseKind: "smalltalk", + rectificationOffer: offer("opened"), + }]; + assert.equal(sourceFromSessionRows([{ id: SOURCE_SESSION, messages: opened }], CASE_ID), null); + const bound = bindOfferToCase(opened, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }); + assert.ok(bound); + assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).status, "opened"); + assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).caseId, CASE_ID); + assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).rectificationSessionId, RECT_SESSION); + assert.equal(bindOfferToCase(bound, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }), null); + assert.ok(offerForCase(bound, CASE_ID)); + assert.deepEqual(sourceFromSessionRows([{ id: SOURCE_SESSION, messages: bound }], CASE_ID), { + sessionId: SOURCE_SESSION, + question: "我和伴侣还能走下去吗", + returned: null, + }); +}); + +test("binding leaves another case's opened record alone and still binds a later card", () => { + const messages = [ + { role: "assistant", text: "旧卡", rectificationOffer: offer("opened", { caseId: OTHER_CASE, rectificationSessionId: RECT_SESSION }) }, + { role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: offer("opened") }, + ]; + assert.equal(bindOfferToCase( + [{ role: "assistant", rectificationOffer: offer("opened", { caseId: OTHER_CASE, rectificationSessionId: RECT_SESSION }) }], + { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }, + ), null); + const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }); + assert.ok(bound); + assert.equal((bound![0]!.rectificationOffer as RectificationOfferRecord).caseId, OTHER_CASE); + assert.equal((bound![1]!.rectificationOffer as RectificationOfferRecord).caseId, CASE_ID); + assert.equal((bound![1]!.rectificationOffer as RectificationOfferRecord).status, "opened"); +}); + +test("the free reply can carry the opened offer once, without clobbering a case id", () => { + const sentence = RECTIFICATION_DIRECT_NEW_COPY; + const card = offer("opened"); + assert.equal(attachOpenedOfferToFreeMessage([], { sentence, offer: card }), "missing"); + assert.equal(attachOpenedOfferToFreeMessage( + [{ role: "assistant", text: sentence, responseKind: "consultation" }], + { sentence, offer: card }, + ), "missing"); + const attached = attachOpenedOfferToFreeMessage( + [{ role: "user", text: "请帮我核对出生时间" }, { role: "assistant", text: sentence, responseKind: "smalltalk" }], + { sentence, offer: card }, + ); + assert.ok(Array.isArray(attached)); + assert.deepEqual(attached[1]!.rectificationOffer, card); + assert.equal(attachOpenedOfferToFreeMessage(attached, { sentence, offer: card }), "present"); + const withCase = attachOpenedOfferToFreeMessage( + [{ role: "assistant", text: sentence, responseKind: "smalltalk", rectificationOffer: offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) }], + { sentence, offer: card }, + ); + assert.equal(withCase, "present"); +}); + +test("explicit pass opens once, records an opened offer, and does not open again", async () => { + const fresh = offerTool(); + const result = await fresh.tool.execute({ explicitRequest: true, userAsked: true }); + assert.equal(result.offered, true); + assert.equal(result.directEntry, "new"); + assert.match(String(result.note), /好,我们开始核对出生时间/); + assert.deepEqual(fresh.state.rectificationOffer, offer("opened")); + assert.equal(fresh.state.rectificationOffer?.caseId, undefined); + assert.deepEqual(fresh.state.rectificationDirectEntry, { + kind: "new", + sentence: RECTIFICATION_DIRECT_NEW_COPY, + sourceSessionId: SOURCE_SESSION, + }); + assert.equal(fresh.loads(), 0); + assert.equal(fresh.decisions(), 1); + const again = await fresh.tool.execute({ explicitRequest: true }); + assert.equal(again.offered, false); + assert.match(String(again.note), /已经在打开/); + assert.match(String(again.note), /不要再写解读/); + assert.equal(fresh.decisions(), 1); + assert.deepEqual(fresh.state.rectificationOffer, offer("opened")); +}); + +test("explicitRequest bypasses an earlier offer, and userAsked alone still only shows a card", async () => { + const direct = offerTool({ alreadyOffered: true }); + const opened = await direct.tool.execute({ explicitRequest: true }); + assert.equal(opened.directEntry, "new"); + assert.equal(direct.state.rectificationOffer?.status, "opened"); + assert.equal(direct.state.rectificationDirectEntry?.kind, "new"); + + const card = offerTool({ alreadyOffered: true }); + const asked = await card.tool.execute({ userAsked: true }); + assert.equal(asked.offered, true); + assert.equal(card.state.rectificationOffer?.status, "offered"); + assert.equal(card.state.rectificationDirectEntry, undefined); + + const held = offerTool(); + await held.tool.execute({}); + const second = await held.tool.execute({ explicitRequest: true }); + assert.match(String(second.note), /提议卡已经在了/); + assert.equal(held.state.rectificationDirectEntry, undefined); + assert.equal(held.state.rectificationOffer?.status, "offered"); + assert.equal(held.decisions(), 1); +}); + +test("an in-progress case resumes that session, or says to continue in history", async () => { + const resume = offerTool({ + decide: async () => ({ ok: false, reason: "case_in_progress" }), + loadResumableSessionId: async () => RESUME_SESSION, + }); + const resumed = await resume.tool.execute({ explicitRequest: true }); + assert.equal(resumed.offered, false); + assert.equal(resumed.directEntry, "resume"); + assert.equal(resume.state.rectificationOffer, undefined); + assert.deepEqual(resume.state.rectificationDirectEntry, { + kind: "resume", + sentence: RECTIFICATION_DIRECT_RESUME_COPY, + sourceSessionId: SOURCE_SESSION, + resumeSessionId: RESUME_SESSION, + }); + assert.equal(resume.loads(), 1); + + const missing = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) }); + const refused = await missing.tool.execute({ explicitRequest: true }); + assert.equal(refused.directEntry, undefined); + assert.equal(missing.state.rectificationDirectEntry?.kind, "refused"); + assert.equal(missing.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY); + assert.equal(missing.state.rectificationOffer, undefined); + assert.match(String(refused.note), /历史对话/); + assert.doesNotMatch(String(refused.note), /不要提校正/); + + const thrown = offerTool({ + decide: async () => ({ ok: false, reason: "case_in_progress" }), + loadResumableSessionId: async () => { throw new Error("db down"); }, + }); + const fallback = await thrown.tool.execute({ explicitRequest: true }); + assert.equal(thrown.state.rectificationDirectEntry?.kind, "refused"); + assert.equal(thrown.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY); + assert.match(String(fallback.note), /历史对话/); + + const quiet = offerTool({ decide: async () => ({ ok: false, reason: "case_in_progress" }) }); + const ordinary = await quiet.tool.execute({}); + assert.equal(quiet.loads(), 0); + assert.equal(quiet.state.rectificationDirectEntry, undefined); + assert.match(String(ordinary.note), /历史对话/); + assert.doesNotMatch(String(ordinary.note), /不要提校正/); +}); + +test("explicit refusals are one sentence, and none of the four notes says to hide rectification", async () => { + const unavailable = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) }); + const disabled = await unavailable.tool.execute({ explicitRequest: true }); + assert.equal(unavailable.state.rectificationDirectEntry?.kind, "refused"); + assert.equal(unavailable.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_UNAVAILABLE_COPY); + assert.equal(unavailable.state.rectificationOffer, undefined); + assert.match(String(disabled.note), /出生时间核对这会儿还做不了/); + assert.match(String(disabled.note), /不写内部原因/); + assert.doesNotMatch(String(disabled.note), /不要提校正/); + + const unpriced = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) }); + const priced = await unpriced.tool.execute({ explicitRequest: true }); + assert.equal(unpriced.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_UNAVAILABLE_COPY); + assert.match(String(priced.note), /出生时间核对这会儿还做不了/); + assert.match(String(priced.note), /不写价格/); + assert.doesNotMatch(String(priced.note), /不要提校正/); + + const family = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) }); + const other = await family.tool.execute({ explicitRequest: true }); + assert.equal(family.state.rectificationDirectEntry?.sentence, RECTIFICATION_DIRECT_NOT_SELF_COPY); + assert.match(String(other.note), /给家人校正暂时还不支持/); + assert.doesNotMatch(String(other.note), /不要提校正/); + + const plainFamily = offerTool({ decide: async () => ({ ok: false, reason: "not_self" }) }); + const plain = await plainFamily.tool.execute({}); + assert.equal(plainFamily.state.rectificationDirectEntry, undefined); + assert.doesNotMatch(String(plain.note), /不要提校正/); + const plainPrice = offerTool({ decide: async () => ({ ok: false, reason: "pricing_unavailable" }) }); + assert.doesNotMatch(String((await plainPrice.tool.execute({})).note), /不要提校正/); + const plainOff = offerTool({ decide: async () => ({ ok: false, reason: "product_disabled" }) }); + assert.doesNotMatch(String((await plainOff.tool.execute({})).note), /不要提校正/); + assert.equal(plainPrice.state.rectificationDirectEntry, undefined); + assert.equal(plainOff.state.rectificationDirectEntry, undefined); +}); + +function receipt(state: ReturnType): AgentExecutionReceipt { + return { + runId: "run-direct", + runtime: "mastra-agentic", + skill: { + name: "jyotish-vedic-astrology", + loaded: state.jyotishSkillBound, + referenceReads: state.skillReferenceReadCount, + methodologySections: state.methodologySectionCount, + }, + steps: publicConsultationRuntimeSteps(state), + stepBudget: consultationStepBudgetReceipt(state), + workflow: { route: "career", status: "ready", preciseTiming: "blocked", missingLayers: [] }, + }; +} + +function visibleAnswer(events: readonly ConsultationAgentPublicEvent[]) { + let text = ""; + for (const event of events) { + if (event.type !== "answer.delta") continue; + text = event.replace ? event.text : text + event.text; + } + return text; +} + +async function readStream(response: Response) { + const events: ConsultationAgentPublicEvent[] = []; + const parser = createNdjsonParser((event) => events.push(event)); + parser.finish(await response.text()); + return events; +} + +test("natal and window direct entry or explicit refusal skips the runtime-contract retry", async () => { + const modes: Pass4Mode[] = ["verified_chart", "declared_birth_window"]; + const kinds = ["new", "resume", "refused"] as const; + for (const pass4Mode of modes) { + for (const kind of kinds) { + const state = createConsultationRuntimeState(); + const sentence = kind === "new" + ? RECTIFICATION_DIRECT_NEW_COPY + : kind === "resume" + ? RECTIFICATION_DIRECT_RESUME_COPY + : RECTIFICATION_DIRECT_UNAVAILABLE_COPY; + let contractRetries = 0; + let answerRetries = 0; + let warmupRan = false; + let completedOutput = ""; + const response = streamAgentResponse({ + runId: "run-direct", + requestId: REQUEST_ID, + state, + requireTool: true, + pass4Mode, + stepScopedAnswer: true, + toolStatus: () => "ready", + receipt: () => receipt(state), + ...(pass4Mode === "declared_birth_window" + ? { + warmup: async (send: (event: ConsultationAgentPublicEvent) => void) => { + warmupRan = true; + send({ type: "activity", phase: "chart-calculation", label: "窗口预热" }); + }, + } + : {}), + stream: (async function* () { + yield { type: "text-delta", payload: { text: "我先看看盘。" } }; + yield { type: "tool-call", payload: { toolCallId: "offer-1", toolName: "offer-birth-time-rectification" } }; + state.rectificationDirectEntry = { + kind, + sentence, + sourceSessionId: SOURCE_SESSION, + ...(kind === "resume" ? { resumeSessionId: RESUME_SESSION } : {}), + }; + if (kind === "new") state.rectificationOffer = offer("opened"); + yield { type: "text-delta", payload: { text: "然后写一段解读。" } }; + yield STOP_FINISH; + })(), + retry: async () => { + contractRetries += 1; + return (async function* () { yield STOP_FINISH; })(); + }, + retryForAnswer: async () => { + answerRetries += 1; + return (async function* () { yield STOP_FINISH; })(); + }, + onComplete: (output) => { completedOutput = output; }, + }); + const events = await readStream(response); + assert.equal(contractRetries, 0, `${pass4Mode} ${kind} retried the contract`); + assert.equal(answerRetries, 0, `${pass4Mode} ${kind} retried the answer`); + assert.equal(state.consultationToolCompleted, false); + assert.equal(visibleAnswer(events), sentence); + assert.equal(visibleAnswer(events).includes("我先看看盘"), false); + assert.equal(visibleAnswer(events).includes("然后写一段解读"), false); + assert.equal(visibleAnswer(events).includes(CONTRACT_DEGRADED_NOTE), false); + assert.equal(completedOutput, sentence); + if (pass4Mode === "declared_birth_window") assert.equal(warmupRan, true); + const completed = events.filter((event) => event.type === "run.completed"); + assert.equal(completed.length, 1); + const done = completed[0]!; + assert.equal(done.type === "run.completed" && done.responseKind, "smalltalk"); + assert.equal(done.type === "run.completed" && done.receipt, undefined); + if (kind === "new") { + assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "opened"); + assert.equal(done.type === "run.completed" && done.rectificationOffer?.caseId, undefined); + assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, { + sourceSessionId: SOURCE_SESSION, + mode: "new", + }); + } else if (kind === "resume") { + assert.equal(done.type === "run.completed" && done.rectificationOffer, undefined); + assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, { + sourceSessionId: SOURCE_SESSION, + mode: "resume", + resumeSessionId: RESUME_SESSION, + }); + } else { + assert.equal(done.type === "run.completed" && done.rectificationDirectOpen, undefined); + assert.equal(done.type === "run.completed" && done.rectificationOffer, undefined); + } + assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), false); + } + } +}); + +test("a reading that already streamed is replaced by the direct-entry sentence", async () => { + for (const pass4Mode of ["verified_chart", "declared_birth_window"] as const) { + const state = createConsultationRuntimeState(); + state.consultationToolCompleted = true; + state.consultationToolSuccessCount = 1; + let contractRetries = 0; + const response = streamAgentResponse({ + runId: "run-direct", + requestId: REQUEST_ID, + state, + requireTool: true, + pass4Mode, + stepScopedAnswer: true, + toolStatus: () => "ready", + receipt: () => receipt(state), + stream: (async function* () { + yield { type: "text-delta", payload: { text: "事业方向的判断如下。" } }; + yield { type: "step-finish", payload: { stepResult: { reason: "stop" } } }; + state.rectificationDirectEntry = { + kind: "new", + sentence: RECTIFICATION_DIRECT_NEW_COPY, + sourceSessionId: SOURCE_SESSION, + }; + state.rectificationOffer = offer("opened"); + yield STOP_FINISH; + })(), + retry: async () => { + contractRetries += 1; + return (async function* () { yield STOP_FINISH; })(); + }, + }); + const events = await readStream(response); + assert.equal(contractRetries, 0); + assert.equal(events.some((event) => event.type === "answer.delta" && event.text.includes("事业方向的判断如下")), true); + assert.equal(events.some((event) => event.type === "answer.delta" && event.replace === true && event.text === RECTIFICATION_DIRECT_NEW_COPY), true); + assert.equal(visibleAnswer(events), RECTIFICATION_DIRECT_NEW_COPY); + } +}); + +test("an ordinary consultation still retries once when the calculation never finished", async () => { + const state = createConsultationRuntimeState(); + let contractRetries = 0; + let answerRetries = 0; + const response = streamAgentResponse({ + runId: "run-direct", + 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; + })(); + }, + retryForAnswer: async () => { + answerRetries += 1; + return (async function* () { yield STOP_FINISH; })(); + }, + }); + const events = await readStream(response); + assert.equal(contractRetries, 1); + assert.equal(answerRetries, 0); + assert.equal(visibleAnswer(events), "事业方向可以看。"); + assert.equal(visibleAnswer(events).includes(CONTRACT_DEGRADED_NOTE), false); + assert.equal(visibleAnswer(events).includes("还没排盘"), false); + const done = events.find((event) => event.type === "run.completed"); + assert.equal(done?.type === "run.completed" && done.receipt !== undefined, true); + assert.equal(done?.type === "run.completed" && done.responseKind, undefined); + assert.equal(events.some((event) => event.type === "activity" && event.label === "正在补齐方法与计算步骤"), true); +}); + +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") + )); + assert.equal(definitions.length, 1); + assert.match(definitions[0]!, /consultation-thinking-plan\.ts$/); + assert.match(RECTIFICATION_OFFER_RULE, /不承诺能收窄出生时间/); + assert.match(RECTIFICATION_OFFER_RULE, /不在对话里收集经历做校正/); + assert.match(RECTIFICATION_OFFER_RULE, /explicitRequest=true/); + assert.match(RECTIFICATION_OFFER_RULE, /只写那一句过渡,不写解读/); + assert.match(RECTIFICATION_OFFER_RULE, /只是不认同某个解释,按纠正规则认下、改读,不提校正/); + assert.match(RECTIFICATION_OFFER_RULE, /userAsked=true/); + + const tools = src("../src/mastra/consultation-tools.ts"); + assert.doesNotMatch(tools, /不承诺能收窄出生时间/); + assert.doesNotMatch(tools, /不要提校正/); + assert.match(tools, /explicitRequest/); + assert.match(tools, /userAsked/); + + const route = src("../src/app/api/consult/route.ts"); + assert.match(route, /大运、子运只说「在哪一段附近」,不写起止年月日/); + assert.match(route, /这只是粗看。" \+ RECTIFICATION_OFFER_RULE;/); + assert.doesNotMatch(route, /请你帮我进行生时纠正/); + const direct = route.slice( + route.indexOf("async function completeDirectRectificationTurn"), + route.indexOf("let settlement:"), + ); + assert.match(direct, /complete_consultation_free/); + assert.match(direct, /responseKind: "smalltalk"/); + assert.match(direct, /persistOpenedDirectOffer/); + assert.doesNotMatch(direct, /complete_consultation_response/); + assert.doesNotMatch(direct, /consultation_requests/); + const windowBlock = route.slice( + route.indexOf("if (shouldRunDeclaredWindowWorkflow(consultationMode))"), + route.indexOf("if (!prepared.serverChart)"), + ); + const natalBlock = route.slice( + route.indexOf("if (!prepared.serverChart)"), + route.indexOf("const history = storedHistory;"), + ); + assert.match(windowBlock, /settleAnswer/); + assert.match(natalBlock, /settleAnswer/); + assert.match(windowBlock, /warmup:/); + assert.match(windowBlock, /precomputeWindowConsultation/); + assert.match(windowBlock, /运行合同不完整/); + assert.match(natalBlock, /运行合同不完整/); + assert.match(route, /getRectificationEntrySummary\(accounting, userId\)/); + assert.match(route, /\.from\("agentic_rectification_cases"\)/); + assert.match(route, /\.select\("session_id"\)/); + + const stream = src("../src/lib/stream-agent-response.ts"); + assert.match(stream, /if \(options\.state\.rectificationDirectEntry\) return true;/); + assert.match(stream, /rectificationDirectRunCompleted\(options\.state\)/); + assert.doesNotMatch(stream, /请你帮我进行生时纠正/); + + const hook = src("../src/hooks/use-consultation-run.ts"); + assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/); + assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/); + assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1); + assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/); + + const page = src("../src/app/(app)/page.tsx"); + assert.match(page, /openAccountDialog, openRectificationSession, openRectificationFromChat,/); + assert.doesNotMatch(page, /请你帮我进行生时纠正/); + + const server = src("../src/lib/rectification-in-chat-server.ts"); + assert.match(server, /return sourceFromSessionRows/); + assert.match(server, /attachOpenedOfferToFreeMessage/); + const persist = server.slice(server.indexOf("export async function persistOpenedDirectOffer"), server.indexOf("export async function bindRectificationSource")); + assert.doesNotMatch(persist, /consultation_requests/); + + consultationAgentPublicEventSchema.parse({ + type: "run.completed", + responseKind: "smalltalk", + rectificationOffer: offer("opened"), + rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" }, + }); +}); diff --git a/frontend/tests/rectification-in-chat-20261007.test.ts b/frontend/tests/rectification-in-chat-20261007.test.ts index 587cfcca..86096026 100644 --- a/frontend/tests/rectification-in-chat-20261007.test.ts +++ b/frontend/tests/rectification-in-chat-20261007.test.ts @@ -128,6 +128,8 @@ function offerTool(options: { alreadyOffered?: boolean; decide?: () => Promise<{ decide: options.decide ?? (async () => ({ ok: true, creditCost: 3, questionEstimate: 8 })), alreadyOffered: options.alreadyOffered ?? false, revisitQuestion: "我和伴侣还能走下去吗", + sourceSessionId: SOURCE_SESSION, + loadResumableSessionId: async () => null, now: () => new Date("2026-10-07T00:00:00.000Z"), }, }) as Record Promise> }>;