Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a799582d8 | ||
|
|
e6baff952c | ||
|
|
fddddaabb3 | ||
|
|
de4cfeb868 |
@@ -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 节)。替代证据:试算脚本与三种读法对照表。
|
||||
|
||||
+6
-2
@@ -1,11 +1,15 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-09 — 事业判断表旁边注明不按星性写方向(第二次回测仍有 1 次,未标待验收)
|
||||
## 2026-10-12 — 对话里明说要校正就直接进入(待验收)
|
||||
|
||||
- 用户这一轮明确要求做生时校正时,直接打开校正,不先写解读、不用点卡。推得不准时的提议仍走卡片。做不了会用一句话说明,不再沉默。普通对话不承诺收窄出生时间,申报时段不写大运、子运的起止年月日。
|
||||
|
||||
## 2026-10-09 — 事业判断表旁边注明不按星性写方向(已合入 staging;回测仍有 1 次,记为已知残余)
|
||||
|
||||
- 事业判断表里,自然代表星、Jaimini 代表星、大运主星和宫主这几行旁边加了一句:只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。其他领域不加。行和取值没有改。系统提示没有改。
|
||||
- 9 人 × 4 个领域 × 3 次,108 份都有回答。严重冲突仍是 1,对照组误报 0。27 份事业题里仍有 1 份把谋生写成靠能力和表达。不标待验收。Skill 版本不变。未推送,未合入,未上线。
|
||||
|
||||
## 2026-10-09 — 事业方向只写力量、阻力和时间;预热同一份资料只算一次(回测未达标,未推送,未合入,未上线)
|
||||
## 2026-10-09 — 事业方向只写力量、阻力和时间;预热同一份资料只算一次(已合入 staging)
|
||||
|
||||
- 事业清单写明:「方向」只写力量、阻力、时间,以及机会出现、成形、公开落地这三件事。职业类型和工作方式仍问用户。系统提示没有改。判断表的行和取值没有改。
|
||||
- 保存后的预热按同一张盘、同一天只算一次。同时最多预热 1 份,满了就跳过,不会让紧接着的提问收到 429。预热失败不影响保存。
|
||||
|
||||
+50
-5
@@ -17490,7 +17490,7 @@
|
||||
|
||||
## BUG-1303 | 事业题仍偶发按星性写方向(BUG-1301 残余)
|
||||
|
||||
- 状态:investigating(`TASK-consult-career-type-residual-20261009.md`;清单句之后又在事业判断表行旁加了呈现句,3 次抽样仍有 1 次行业冲突,按任务书停下,不标 resolved)
|
||||
- 状态:mitigated(已知残余,产品 2026-10-09 接受;清单句 + 判断表呈现句已快进合入 staging;生平回测 3 次抽样事业 27 份中行业类型冲突 1 份,均为同一张公开人物盘)
|
||||
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
|
||||
- 来源:BUG-1301 修复后的生平回测。
|
||||
- 影响面:普通咨询事业回答。
|
||||
@@ -17499,14 +17499,14 @@
|
||||
- 根因:清单句只规定「方向」写什么,模型仍会从事业宫主和自然代表星的星性写谋生方式。行旁的呈现句没有把这 1 次消掉。
|
||||
- 修复:事业清单把「方向」限定为力量、阻力、时间与三件事。事业判断表的自然代表星、Jaimini 代表星、大运主星、Narayana 主星和宫主行旁边加一句呈现文字,其他领域不加。不进系统提示,不删行,不改取值。两次回测都没有把行业类型冲突降到 0,所以不标 resolved。
|
||||
- 验证:`frontend/tests/consult-condensed-checklist-20260927.test.ts` 锁住清单句,系统提示源码切片仍是 15,291 字符。`frontend/tests/consult-card-full-source-20261007.test.ts` 锁住呈现句只出现在事业表,重复行仍留这句,行星取值不变。第二次生平回测 108 份、失败 0。阅读评分见 `docs/tasks/PROGRESS-consult-career-type-residual-20261009.md`:严重冲突 1,行业类型冲突 1,对照组误报 0。
|
||||
- 防复发:上述测试。评分口径没有改。任务书允许的两处都用过了,3 次抽样仍有 1 次,按让步顺序停下,不再加规则。
|
||||
- 防复发:上述测试。评分口径没有改。任务书允许的两处都用过了,3 次抽样仍有 1 次,按让步顺序停下,不再加规则。产品 2026-10-09 选择合入并记为已知残余,等真实用户反馈再定是否另开单。
|
||||
- 相关记录:BUG-1301、BUG-1176。
|
||||
- 复发自:BUG-1301(残余,非新问题)。
|
||||
- 修复版本:本分支提交。未推送,未部署。
|
||||
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
|
||||
|
||||
## BUG-1304 | 保存后马上提问,预热与用户请求重复计算同一份资料包
|
||||
|
||||
- 状态:resolved(分支 `codex/consult-career-type-residual-20261009`;有针对性回归测试。未推送,未部署)
|
||||
- 状态:resolved(分支 `codex/consult-career-type-residual-20261009`,已快进合入 staging;有针对性回归测试)
|
||||
- 首次发现 / 最近更新:2026-10-09 / 2026-10-09
|
||||
- 来源:Claude 验收 BUG-1302 时读代码发现。
|
||||
- 影响面:保存星盘后立刻提问的第一轮等待时长与服务器 CPU。不会因此返回 429。
|
||||
@@ -17518,7 +17518,7 @@
|
||||
- 防复发:上述并发测试。
|
||||
- 相关记录:BUG-1300、BUG-1302。
|
||||
- 复发自:无。
|
||||
- 修复版本:本分支提交。未推送,未部署。
|
||||
- 修复版本:本分支提交,2026-10-09 快进合入 staging。
|
||||
|
||||
## BUG-1305 | 保存星盘后安排预热抛错,已写库的保存返回 500(staging 门禁 run 1784 红)
|
||||
|
||||
@@ -17535,3 +17535,48 @@
|
||||
- 相关记录:BUG-1300、BUG-1302、BUG-1118(同类:数据库套件只在门禁里跑)。
|
||||
- 复发自:无。
|
||||
- 修复版本:本提交。
|
||||
|
||||
## BUG-1306 | 对话里明说「帮我做生时纠正」进不了校正:模型不出提议卡,反而在普通对话里自己「收窄出生时间」
|
||||
|
||||
- 状态:fixed-pending-verify(实现在 `codex/rectification-explicit-entry-20261012`,未 push;T0 无主机日志,T5 无模型凭据)
|
||||
- 首次发现 / 最近更新:2026-10-11 / 2026-10-12
|
||||
- 来源:产品在 staging(`de4cfeb8`)新对话第一句输入「请你帮我进行生时纠正」,回复截图转述。
|
||||
- 现象:回复显示「已完成 3 步」,正文说「收窄出生时间这件事能做」,要用户报有年月的经历,「对得上,区间就能往里收」;全文没有提到提议卡,界面上也没有卡片。正文还按「代表分钟」写出大运 / 子运起止年月。
|
||||
- 触发条件:申报出生时段的本人档案;首轮;用户明确要求做校正。
|
||||
- 已确认事实(代码,`origin/staging` `de4cfeb8`):
|
||||
1. BUG-1247 删除首页入口与文字跳转后,新用户进校正只剩一条路:咨询模型调用 `offer-birth-time-rectification` → 服务端 `decideRectificationOffer` 放行 → 回答下出卡 → 用户点卡。
|
||||
2. 申报时段路径(`createWindowConsultationAgentContext`)确实挂了该工具,指令里也有 `RECTIFICATION_OFFER_RULE`;规则以「用户说你推得不准时」开头,「明确想核对出生时间」只是其中一个分支。
|
||||
3. BUG-1248 上线前的模型实测只覆盖「不认同解释 / 过去的事对不上 + 时间是估的 / 家人 / 已提议又被拒」四种说法,没有「首轮直接点名要校正」。
|
||||
4. 服务端拒绝原因中 `product_disabled`、`pricing_unavailable` 要求正文「不要提校正」,用户看到的是没有回应。
|
||||
- 推断(未证实):正文主动承诺能收窄,不像收到「不要提校正」的拒绝,更像模型没有调用提议工具;无模型凭据、无 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 | 生时校正开场不说记录时间离最近分界多近,首轮选题面向整个窗口,用户觉得步骤多、目的不明
|
||||
|
||||
- 状态:open(任务书 `docs/tasks/TASK-rectification-nearest-boundary-opening-20261012.md` 待领取)
|
||||
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
|
||||
- 来源:产品拿上游 Skill 的一次私人样例校正比较,认为上游步骤短。上游先找记录时间最近的分盘换段点,只问两侧不同的层。
|
||||
- 现象:我方开场不讲记录时间与分界的关系;`window_scan` 有逐分钟 11 层换段数据,但不以记录时间为锚;记录时间只在交付时与区间比较(`reportedTimeOffsetCopy`)。
|
||||
- 根因:扫描数据从未以记录时间为锚组织,呈现与选题都面向整个窗口。
|
||||
- 决策:只改呈现与首轮选题顺序,不改打分、闸门、4 件 3 域;对外不说秒级、不说「记录时间是错的」。
|
||||
- 相关记录:BUG-1306、BUG-1115~1117(盘型口径)、BUG-1143/1144(按段选题)、BUG-691(记录优先)、BUG-1240(不说秒级)。
|
||||
- 复发自:无。
|
||||
- 修复版本:未修复。
|
||||
|
||||
## BUG-1308 | 父母静态是非题(D12)对出生时间分界两侧有没有区分力未经评测
|
||||
|
||||
- 状态:open(研究单 `docs/tasks/TASK-rectification-d12-parent-probes-research-20261012.md` 待领取)
|
||||
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
|
||||
- 来源:同 BUG-1307 的上游样例:上游用五道父母是非题区分 D12 换段两侧。同一输入上游两次结论相反,下结论的那次靠单条父母事实的事后解读。
|
||||
- 现象:我方无静态父母题;上游 10-02 的 D12 绑定(`bbb16115` 等)针对带日期的父母事件,静态题两边都没有评测。
|
||||
- 决策:先研究,公开案例、预登记、乱序与正对照,过门才立实现单;过门前不进题库、不计分。
|
||||
- 相关记录:BUG-1306、BUG-1307、BUG-1141 / BUG-1240(同类研究均未过门)。
|
||||
- 复发自:无。
|
||||
- 修复版本:不适用(研究)。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PROGRESS · 事业方向残余 + 预热单飞(2026-10-09)
|
||||
|
||||
分支 `codex/consult-career-type-residual-20261009`。先接到 `origin/staging` `b4eedb77`(BUG-1305,保存时预热安排不上不再让保存失败)。状态:执行中。判断表提示之后,3 次抽样仍有 1 次行业类型冲突。按任务书停下,不标待验收,不改口径。这一版未推送,未合入,未上线。
|
||||
分支 `codex/consult-career-type-residual-20261009`。先接到 `origin/staging` `b4eedb77`(BUG-1305,保存时预热安排不上不再让保存失败)。状态:已验收(Claude,2026-10-09)。判断表提示之后,3 次抽样仍有 1 次行业类型冲突;产品选择合入、记为已知残余,快进合入 staging。
|
||||
|
||||
## 结论
|
||||
|
||||
@@ -50,3 +50,15 @@ must_not 原文 6 处都是「可能不在身边,也可能人在、只是话
|
||||
| 快速门、Python 全量、npm test 全量、next build、Linux | 本轮没跑。 |
|
||||
|
||||
H1 未过。任务书允许的两处都用过了。按让步顺序停下,交给产品决定。不改评分口径,不再加一条规则,不标待验收。
|
||||
|
||||
## Claude 验收(2026-10-09,Linux,`d6eb0cfe`)
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| Python 全量 | 失败 62 条,与基线逐名相同 |
|
||||
| 快速门(`--skip-yoga-logic --skip-frontend-runtime`) | 通过 |
|
||||
| `npm test`(Node 22) | 5,001 条 / 失败 24,与基线逐名相同;无消失的测试 |
|
||||
| tsc / lint | 0 / 0 error(125 warning 原有) |
|
||||
| `next build --webpack` | `/` 仍 ○ Static |
|
||||
| H2 单飞 | 代码复核通过:同键并发只建一次,预热上限 1、满了跳过;API 是单进程 `ThreadingHTTPServer`,进程内锁有效。本轮未改保存路由,数据库套件不受影响 |
|
||||
| H1 | 未达任务书标准(1/27),产品选择合入并记为已知残余 |
|
||||
|
||||
@@ -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 未测。
|
||||
@@ -124,6 +124,9 @@
|
||||
| `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` | `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、预登记、乱序与正对照,过门才立实现单;纯研究可并行 | 待领取 | — |
|
||||
|
||||
### 聊天主链路与首页
|
||||
|
||||
@@ -132,7 +135,7 @@
|
||||
| `TASK-consult-card-full-source-20261007.md` | `PROGRESS-consult-card-full-source-20261007.md` | **对话数据卡与报告同源 + 领域综合判断表**:事业卡只有约 7,600 字符、无自然代表星、无大运主星关系、D10 联动只能补取、补取上限 1;全 12 领域;综合判断表、参与清单(不止 D1/D9,含精度闸)、补取 3 次、资料目录均已确认;KP 不上卡、不联网、不接书籍语料;排在 sync6 与报告单之后 | 验收未通过(`35dafd1e`,见 fix-20261009) | `codex/consult-card-full-source-20261007` |
|
||||
| `TASK-consult-card-full-source-fix-20261009.md` | `PROGRESS-consult-card-full-source-fix-20261009.md` | **对话卡同源修复**:缓存键无日期致当前大运冻结、整卡 37.5K(每格带内部路径)、星照整列 blocked(资料库其实有出处)、前端测试手写数据、无预热;合入前用临时 key 跑模型 A/B 与生平回测 | F1–F5 已验收,F6 未过(见 fix2) | `codex/consult-card-full-source-20261007` |
|
||||
| `TASK-consult-card-full-source-fix2-20261009.md` | `PROGRESS-consult-card-full-source-fix2-20261009.md` | **对话卡同源第二轮**:齐达内事业被「水星=说写算」带偏致严重冲突 1→3;事业清单加「代表星只看力量受冲、不按星性猜职业」后重跑回测(≤1 才合入);预热改为不占重计算名额 | 已验收并快进合入 staging(2026-10-09,产品按严重冲突 ≤ 1 放行;剩余 1 次见 career-type-residual) | `codex/consult-card-full-source-20261007` |
|
||||
| `TASK-consult-career-type-residual-20261009.md` | `PROGRESS-consult-career-type-residual-20261009.md` | **事业题按星性写方向的残余 + 预热单飞**:方向一节只写力量、阻力、时间和三件事;判断表事业行旁加一句只看力量;预热按缓存键单飞、同时最多 1 份 | 执行中(H1:判断表提示后 3 次抽样仍有 1 次行业冲突,未让步,未标待验收) | `codex/consult-career-type-residual-20261009` |
|
||||
| `TASK-consult-career-type-residual-20261009.md` | `PROGRESS-consult-career-type-residual-20261009.md` | **事业题按星性写方向的残余 + 预热单飞**:方向一节只写力量、阻力、时间和三件事;判断表事业行旁加一句只看力量;预热按缓存键单飞、同时最多 1 份 | 已验收并快进合入 staging(2026-10-09;事业 1/27 行业冲突为产品接受的已知残余,BUG-1303 mitigated) | `codex/consult-career-type-residual-20261009` |
|
||||
| — (产品 09-27 拍板 D1–D4,直接执行) | [PROGRESS](PROGRESS-home-landing-blank-20260927.md) | **登录后 / 裸 `/` 落空白首页**:真机登录后在「首页」提问其实问进了上一次生时校正。删登录返回存根(401 / 次级页链接写、登录后写回 `?c=` 打开),裸 `/` 不再落最近会话,一律当前人物的空白首页(复用空草稿);`?c=` / `?new=1` / 对话内刷新不变;推翻 BUG-1038 存根与 BUG-599 默认落点 | 已验收(真机欠) | `codex/home-landing-blank-20260927`(BUG-1052,本地未推) |
|
||||
| — (产品 09-26 口头拍板 D1–D3,直接执行) | `PROGRESS-consult-answer-truncation-20260926.md` | **普通咨询回答写到一半被掐断仍扣点(BUG-1051,复发自 BUG-305)**:工具循环与写回答共用 110 秒 signal;Mastra 1.50 超时不抛错(`abort` 块 + `finish(tripwire)` 后正常关流),结算只认抛错与 `length`。D1 写回答自有 70 秒时钟(首用起算,续写 / 回答重试共用,最坏 180 秒,`maxDuration` 240);D2 写回答的最后一个流不是 `stop` 且有正文 → `answer_truncated`、不扣点、记 abort 步、不冲半句,`length` 续写不变;D3 观测加 `composeFinishReason` / `composeAborted` / `answerVisibleChars` | 已验收(真机欠) | `codex/consult-answer-truncation-20260926`(本地,未推送);新回归 15 条用真实 Mastra `Agent`(修复前 11 条红);全量失败名单 0 新增;Python 948/1;`/` ○、gzip 0%;真机清单 `docs/testing/consult-answer-truncation-20260926.md` |
|
||||
| `TASK-consult-evidence-card-research-20260927.md` | `PROGRESS-consult-evidence-card-research-20260927.md` | **普通对话数据卡调研**:引擎输出逐项分五类计量(现约 4 万 token、父母问题相关约 3.5%);四处领域→技法来源对账并起草各领域数据卡(家庭拆父母/子女);卡体量与逐字一致性;按卡算的提速空间;反馈迭代埋点方案。只调研不改线上 | 已验收(待产品拍板 7 项) | `codex/consult-evidence-card-research-20260927` 快进 staging;报告 `docs/research/consult_evidence_card_research_2026_09_27.md`;投影缺口记 BUG-1054 investigating |
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# TASK · 研究:父母静态是非题(D12)能不能区分分界两侧(BUG-1308,2026-10-12)
|
||||
|
||||
- 基线:`origin/staging` @ `fddddaab`。
|
||||
- 分支 / worktree:`codex/rectification-d12-parent-probes-research-20261012` / `.worktrees/rectification-d12-parent-probes-research-20261012`
|
||||
- 性质:**纯研究**,只新增 `scripts/research/`、`tests/`、`docs/research/`、`references/real_case_calibration/` 下的文件,不改生产代码。与 `TASK-rectification-explicit-entry-20261012`、`TASK-rectification-nearest-boundary-opening-20261012` 并行。
|
||||
- BUG 编号:本单为 BUG-1308(已建记录,状态 open)。
|
||||
|
||||
## 1. 背景与事故实证
|
||||
|
||||
- 产品 10-12 拿上游 Skill 的一次私人样例校正来比较。上游在记录时间前后最近的分界(D12 上升换座)两侧,问了五道父母是非题,例如「母亲是否比父亲更多负责日常照料」「父亲是否长期两地奔波」。题不需要年份,答得快。
|
||||
- 同一样例上游两次会话结论相反。下结论的那一次,核心论据是一条父母职业事实的**事后**解读(先知道答案再读盘),且它的另一候选窗对母亲一侧的匹配评价更高。单例、事后、无对照,不能当证据。
|
||||
- 我方此前同类结论:不带年份的性格题信号弱,已降级为平局裁决(±1、不淘汰)。四类方法(人拍权重 / 数据权重 / 缺席证据 / 角度技法)都不能在分钟级提出信号(`docs/research/rectification_angle_timing_2026_10_01.md`、`rectification_nadi_seconds_2026_10_05.md`)。
|
||||
- 上游参照(`/workspace/yinduzhanxing` `origin/main`,只作口径参照,不搬代码):
|
||||
- `bbb16115`「bind parent rectification to D12」:`father_death` / `mother_death` 加 `vargas: [D1, D12]` 与 profile `parent_father_d12` / `parent_mother_d12`;
|
||||
- `fe56dd84`:父亲事件主宫默认 9 宫(Parashari),另存北印 10 宫备选 profile;
|
||||
- `e3bac915`:各领域事件绑分盘(婚恋 D9+UL、事业 D10+A10、财富 D2/D11 等)。
|
||||
- 注意:上游绑的是**带日期的父母事件**(如过世),不是静态是非题。静态父母题在上游也没有评测;父母盲测与现实校准合同仍待占星师确认(`docs/research/astrologer_questions_v2_2026_10_02.md` 甲2)。
|
||||
- sync5(`TASK-upstream-sync5-20261002` §2 第 3 条)当时不搬上游的显式权重 / 行运锚点 / 双轨门,理由是我方 v9 架构不同、待占星师甲8 / 甲9。也不搬父母 / 事业盲测与现实校准合同,理由是甲2、甲3 未定。本单不改这一决定,只测静态父母题本身有没有区分力。
|
||||
- 我方 D12 现状:家人领域已用 D12(`scripts/active_rectification_event_engine.py` family 行、`scripts/rectification/event_probes.py` `SCORING_LAYERS`),用于带日期事件的计分,没有静态父母题。
|
||||
|
||||
## 2. 决策记录(产品 2026-10-12)
|
||||
|
||||
1. 同意把「父母静态是非题」作为候选题型研究;**过门才立实现单**,过门前不进生产题库、不计分。
|
||||
2. 只用公开、可查来源的案例(AA / A 级出生时间),**不用任何真实用户资料**(包括本次私人样例)。
|
||||
3. 判读规则必须**在看答案之前**预登记(参照 `docs/research/nadi_seconds_preregistration_2026_10_05.json` 的做法)。
|
||||
|
||||
## 3. 硬红线
|
||||
|
||||
1. 不改生产代码、打分、题库、`decision_policy`;不升算法身份。
|
||||
2. 预登记文件先提交、后跑评测;预登记后改判据算作新一轮,旧结果照记。
|
||||
3. 必须有乱序对照(把父母事实在案例间随机打乱 ≥200 次)和正对照(人为埋入「与 D12 读法一致」的事实,流程须能测出),否则结论无效。
|
||||
4. 父母事实必须有可引用的出处(传记、访谈、权威百科条目),每条记来源链接与可信等级;拿不到出处的不入集。
|
||||
5. 文档与数据集不得含真实用户资料;推送前跑 `tests/test_repo_privacy_markers.py`。
|
||||
|
||||
## 4. 任务分解
|
||||
|
||||
### R0 案例集
|
||||
|
||||
- 从 `references/real_case_calibration/holdout_v5_roster.json` 的 77 例与 AA 公开名人中,挑父母信息可查的案例,目标 ≥60 例。每例记录 4–6 条可核父母事实,对应上游五题的维度:主要照料者、家庭财务主导、父亲是否指导学业、父亲职业类型(体制 / 技能奔波 / 经商等)、父母是否长期分隔两地。答案取「是 / 否 / 不清楚」。
|
||||
- 验收:`references/real_case_calibration/parent_facts_v1.json` + 来源表;不清楚率逐题报告。
|
||||
|
||||
### R1 预登记
|
||||
|
||||
- 每题写清:读哪张盘(D12;D1 9/4 宫与日、月作对照)、哪几宫 / 宫主 / 征象星、什么配置算「是」。给 9 宫父 / 10 宫父两种 profile(对照上游 `fe56dd84`),各自预登记。
|
||||
- 验收:`docs/research/d12_parent_probes_preregistration_2026_10_12.json` 先于评测提交。
|
||||
|
||||
### R2 区分力评测
|
||||
|
||||
- 对每例,在真实出生时间 ±10 / ±30 窗口内,取 D12 上升最近的换段点两侧各一个代表分钟,看预登记判读对「真实一侧」的选中率。另报:同窗内全部 D12 段的头段命中、真值段保留。
|
||||
- 对照:乱序对照的分布与真实值的分位;正对照能被测出;Bonferroni 校正后显著才算过。
|
||||
- 稳健性:换岁差(Lahiri / Raman)、换交点(mean / true)后判读改变的比例。
|
||||
- 验收:`docs/research/rectification_d12_parent_probes_2026_10_12.md` + 结果 JSON;脚本两次运行逐字节一致;Python 定向测试 + 快速门全量。
|
||||
|
||||
### R3 结论
|
||||
|
||||
- 过门(真实一侧选中率显著高于乱序对照,且岁差 / 交点稳健性改变比例 <20%):写实现建议(题干、计分方式,只作平局裁决还是计分,接到 `TASK-rectification-nearest-boundary-opening-20261012` 的首轮选题)。
|
||||
- 不过门:写明「不上线」,给产品一句对外口径。
|
||||
|
||||
## 5. 让步顺序
|
||||
|
||||
案例不足 60 例 → 先用能凑到的跑并标「欠力」,不下「无效」结论;稳健性只做岁差一项。正 / 乱序对照不可让。
|
||||
|
||||
## 6. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-d12-parent-probes-research-20261012 .worktrees/rectification-d12-parent-probes-research-20261012 origin/staging
|
||||
grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
|
||||
export PYTHONHASHSEED=0
|
||||
```
|
||||
|
||||
完成后 BUG-1308 改 closed(附结论)或转为实现单的引用。
|
||||
@@ -0,0 +1,114 @@
|
||||
# TASK · 对话里明说要校正就直接进入生时校正(BUG-1306,2026-10-12)
|
||||
|
||||
- 基线:`origin/staging` @ `fddddaab`(已部署代码 `de4cfeb8`,含 BUG-1247~1251「校正并入对话」第一步)。
|
||||
- 分支 / worktree:`codex/rectification-explicit-entry-20261012` / `.worktrees/rectification-explicit-entry-20261012`
|
||||
- 串行顺序:**本单最先做**。`TASK-rectification-nearest-boundary-opening-20261012` 若改到 `frontend/src/app/(app)/page.tsx`、`use-rectification-surface.ts` 或校正开场组件,必须等本单合入 staging 后再开工;`TASK-rectification-d12-parent-probes-research-20261012` 是纯研究,可并行。
|
||||
- BUG 编号:本单修 BUG-1306(已存在,状态 investigating)。实现中另发现的问题从开工时 `docs/BUG_HISTORY.md` 最大号顺延(写作时最大为 BUG-1308)。
|
||||
|
||||
## 1. 事故实证
|
||||
|
||||
产品 10-11 在 staging 新对话第一句输入「请你帮我进行生时纠正」(本人档案,申报出生时段)。回复显示「已完成 3 步」,正文:
|
||||
|
||||
- 「收窄出生时间这件事能做……对得上,区间就能往里收」,要用户报有年月的经历;
|
||||
- 全文没有提卡片,界面上也没有提议卡;
|
||||
- 按「代表分钟」写出大运、子运的起止年月。
|
||||
|
||||
代码事实(行号按符号定位):
|
||||
|
||||
| 位置 | 事实 |
|
||||
| --- | --- |
|
||||
| `frontend/src/lib/consultation-thinking-plan.ts` `RECTIFICATION_OFFER_RULE` | 规则以「用户说你推得不准时」开头;「明确想核对出生时间」只是其中一支 |
|
||||
| `frontend/src/mastra/consultation-tools.ts` `createRectificationOfferTool` | 工具只记录提议,进入校正要用户再点卡 |
|
||||
| 同文件 `RECTIFICATION_OFFER_REFUSAL_NOTES` | `product_disabled`、`pricing_unavailable` 要求「正文不要提校正」 → 用户看到的是没回应 |
|
||||
| `frontend/src/app/api/consult/route.ts` `declaredWindowInstruction` | 已写「精确应期一律不可用」,模型仍写了子运起止 |
|
||||
| 同文件 natal / window 两处「运行合同不完整」重试 | 首轮没调计算工具就强制重跑 → 「要校正」这一轮也会被逼着先排盘写解读 |
|
||||
| BUG-1247 | 删除首页入口与文字口令后,新用户进校正只剩「模型调工具 → 服务端放行 → 出卡 → 点卡」一条路 |
|
||||
| BUG-1248 上线前模型实测 | 只测了四种说法,没有「首轮直接点名要校正」 |
|
||||
|
||||
## 2. 根因
|
||||
|
||||
1. 入口完全依赖模型判断,规则写成「推得不准」场景,没覆盖「用户直接要做校正」。
|
||||
2. 服务端拒绝时有两种原因被设计成静默。
|
||||
3. 普通对话模型没有「不能在对话里做校正」的边界,于是自己收经历、承诺收窄。用户照做也不会进入校正引擎计分。
|
||||
4. 首轮强制排盘合同没有给「要校正」这轮留出口。
|
||||
|
||||
## 3. 决策记录(产品 2026-10-12)
|
||||
|
||||
1. **用户明确要求做生时校正时,直接进入校正**:不先写解读,不用点卡。是否「明确要求」由模型判断,**禁止正则 / 关键词**(BUG-976/977 口径不变)。
|
||||
2. **推翻 BUG-1248 的两条**:
|
||||
- 「工具只记录提议、进入要点卡」:对用户明确要求的这一轮,改为直接打开。「推得不准、可能是时间问题」时主动提议仍走卡片,不变。
|
||||
- `product_disabled`、`pricing_unavailable`「正文不要提校正」:改为**必须用一句话说明现在做不了**,不得静默。措辞对照 `frontend/docs/VOICE.md`,不写价格、不写内部原因名。
|
||||
3. 普通对话模型**不得**承诺「能收窄出生时间」,不得在对话里收集经历假装校正;用户要做校正,就走第 1 条。
|
||||
4. 申报时段下不得写大运、子运的精确起止(守 `declaredWindowInstruction` 原有规定)。只补指令与回归测试,不改证据卡。
|
||||
5. 服务端判定不变:本人、`isProductEnabled("rectification")`、无进行中 case、`feature_pricing` 有价,缺一不开。家人档案、已有进行中 case 照原口径一句话说明(进行中的 case 可给「接着做」入口,见 T3)。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. 不用正则 / 关键词判断意图,不新增前端意图判断。
|
||||
2. 不改数据库结构、不动迁移;不改校正引擎、打分、`decision_policy`、闸门。
|
||||
3. 守 AGENTS §6:`scripts/jyotish_api_server.py` 类方法数不增长;`frontend/src/app/(app)/page.tsx` 的 `Home()` 中 `useState` / `useRef` 数不增长(新逻辑进 hook / lib)。
|
||||
4. 不在任何文件、测试、日志里写真实用户资料;实测只用公开名人 golden 盘或虚构资料。
|
||||
5. 计费:直接进入校正与原来点卡开案一样,走原有开案扣费路径,不新增扣费点;这一轮咨询如果没有写解读,不得按解读扣咨询点数(与 smalltalk 路径一样核对结算)。
|
||||
6. 测试总数不得低于基线;改既有断言写「原值 / 新值 / 原因」三栏。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### T0 取证(先做,做不到写成环境缺口)
|
||||
|
||||
- 目标:确认 10-11 那一轮模型是否调用了 `offer-birth-time-rectification`,服务端返回了什么。
|
||||
- 途径:`[agent-observability]` 只打到 web 容器控制台(`frontend/src/lib/agent-observability.ts` `consoleSink`)。需要 staging 主机 `docker logs`(按 `deploy/README.md`)里该时段 `toolCalls`。另一佐证:该会话的 `chat_sessions.messages` 里有没有 `rectificationOffer`。
|
||||
- 没有主机 / 数据库访问:写进 `BLOCKED.md`,在进度记录给产品一条可照做的查法(只查工具名与结果,不复制正文与出生资料)。**不阻塞 T1–T5**。
|
||||
- 验收:进度记录写明取证结果或环境缺口。
|
||||
|
||||
### T1 直达工具语义
|
||||
|
||||
- `offer-birth-time-rectification` 增加「用户这一轮明确要求做校正」这一参数,或拆出一个开案工具(执行方选一种,写理由)。由模型判断,不用正则。
|
||||
- 服务端判定通过时,`run.completed` 事件(或新事件)带「直接打开」的指令和来源会话号;浏览器收到后调用现有打开流程:`useRectificationSurface` 的打开函数,`intent=homepage` 加 `sourceSessionId`,复用 BUG-1249/1250 的来源绑定与「回到刚才的对话」。
|
||||
- 本轮助手消息只留一句过渡(例如「好,我们开始核对出生时间。」),消息上记一条与提议卡同结构的记录(状态直接为 `opened`),保证刷新后来源仍能找回(`findRectificationSource` 只认带 caseId 的提议卡)。
|
||||
- 验收:单测覆盖「通过 → 直接打开且绑定来源」「同一轮不重复开案」「刷新后来源可找回」;`bindRectificationSource` 对 `opened` 记录幂等。
|
||||
|
||||
### T2 首轮合同豁免
|
||||
|
||||
- natal 与 window 两条路径的「运行合同不完整」重试:本轮已经走直达(T1 通过)时不重试、不要求计算工具,也不写解读。
|
||||
- 服务端拒绝时同样不重试,只写一句说明。
|
||||
- 验收:单测断言两条路径在直达 / 拒绝时都不触发重试;正常咨询轮重试行为不变(原测试不改或三栏说明)。
|
||||
|
||||
### T3 拒绝不静默
|
||||
|
||||
- 改 `RECTIFICATION_OFFER_REFUSAL_NOTES`:`product_disabled`、`pricing_unavailable` 改为一句话说明现在做不了。`case_in_progress` 除说明外,直达场景下直接打开那个进行中的 case(与历史里打开相同),不新开。
|
||||
- 验收:四种拒绝各一条单测,断言 note 不含「不要提校正」;文案对照 VOICE.md。
|
||||
|
||||
### T4 普通对话的边界
|
||||
|
||||
- 在 `RECTIFICATION_OFFER_RULE`(只此一处定义)里加:不承诺收窄出生时间,不在对话里收集经历做校正;用户要做就调工具。
|
||||
- `declaredWindowInstruction` 补一句:大运、子运只说「在哪一段附近」,不写起止年月日。
|
||||
- 验收:指令内容测试;规则仍只定义一处。
|
||||
|
||||
### T5 模型实测(公开名人 golden 盘或虚构资料,至少 deepseek-flash 与产品默认模型各一遍,每场景 ≥2 次)
|
||||
|
||||
| 场景 | 期望 |
|
||||
| --- | --- |
|
||||
| 首轮直接点名:「请你帮我进行生时纠正」 | 直接打开,不写解读 |
|
||||
| 不同说法:「帮我校正出生时间」「想核对一下出生时间准不准」「我出生时间是估的,帮我对一下」 | 直接打开 |
|
||||
| 追问轮里才提出要校正 | 直接打开 |
|
||||
| 只是不认同解读:「你说我事业不顺,我觉得挺顺的」 | **不得进入**,按纠正规则改读 |
|
||||
| 过去的事对不上 + 时间是估的(未明确要校正) | 出提议卡(原行为) |
|
||||
| 家人档案明确要校正 | 一句话说明暂不支持,不开案 |
|
||||
| 服务端 `pricing_unavailable` | 一句话说明,不静默 |
|
||||
| 申报时段普通咨询 | 不出现子运起止年月日、不承诺收窄 |
|
||||
|
||||
- 验收:进度记录逐场景给次数与结果;任一「不得进入」场景进入了即不通过。无模型凭据时写成环境缺口,并补 `docs/testing/` 真机清单。
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
时间不够时依次放弃:T5 中第二个模型 → T0 取证(写缺口)。T1–T4 不可让。
|
||||
|
||||
## 7. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-explicit-entry-20261012 .worktrees/rectification-explicit-entry-20261012 origin/staging
|
||||
grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
|
||||
```
|
||||
|
||||
前端门禁:`tsc --noEmit` 0 错、`npm run lint` 0 error、`npm test` 失败名单与基线逐条相同、`next build` 后 `/` Static、首屏 gzip ±2%。改 UI 同提交更新 `frontend/DESIGN.md`;`docs/BUG_HISTORY.md` 的 BUG-1306 更新为 fixed-pending-verify 并写修复与验证。真机清单写到 `docs/testing/rectification-explicit-entry-20261012.md`。
|
||||
@@ -0,0 +1,95 @@
|
||||
# TASK · 生时校正开场:先说记录时间离最近分界多近,首轮只问这条分界两侧的不同(BUG-1307,2026-10-12)
|
||||
|
||||
- 基线:`origin/staging` @ `fddddaab`(已部署代码 `de4cfeb8`)。
|
||||
- 分支 / worktree:`codex/rectification-nearest-boundary-opening-20261012` / `.worktrees/rectification-nearest-boundary-opening-20261012`
|
||||
- 串行顺序:若本单改到 `frontend/src/app/(app)/page.tsx`、`frontend/src/hooks/use-rectification-surface.ts` 或校正开场组件,**必须等 `TASK-rectification-explicit-entry-20261012`(BUG-1306)合入 staging 后**再以新 staging 为基线开工;只改 `scripts/rectification/**` 与 `frontend/src/lib/rectification-agentic/**` 的部分可先做。与 `TASK-rectification-d12-parent-probes-research-20261012` 无文件交叉,可并行。
|
||||
- BUG 编号:本单为 BUG-1307(已建记录,状态 open)。另发现的问题从开工时 `docs/BUG_HISTORY.md` 最大号顺延。
|
||||
|
||||
## 1. 事故实证
|
||||
|
||||
产品 10-12 拿上游 Skill(占星师本机运行)的一次校正和我们比较,认为上游「步骤短很多,还能出结果」。样例是一例私人资料,本单只记相对时刻。
|
||||
|
||||
上游的做法:以记录时间为锚扫描前后 30 分钟,找到最近的分盘换段点,在记录时间前约 17 秒。然后列出两侧不同的层(父母盘、事业盘、居住盘,本命上升星座不变),只问这几层的区别,五道是非题。
|
||||
|
||||
同一样例的 PM 复算(Swiss Ephemeris,Raman,与上游两份文件的日期在一天内一致),看用户一件「某年某月」的经历落在哪个子运小段:
|
||||
|
||||
| 出生时刻(相对记录时间) | 该月所处小段 |
|
||||
| --- | --- |
|
||||
| 前 8 分钟 | 月初 4 天为金星,之后为太阳 |
|
||||
| 前 4 分钟(上游自己的点估计) | 太阳 → 月亮 |
|
||||
| 前 30 秒 | 太阳 → 月亮 |
|
||||
| 记录时间 | 太阳 → 月亮 |
|
||||
|
||||
同一输入,上游两次会话结论相反:一次「保留记录时间,没有足够依据改」;一次「记录时间是错的,真实时间在记录前 13 分钟到前 17 秒之间」。后一份的事件论据与它自己的点估计矛盾(见上表),实际只靠一条父母事实的事后解读。
|
||||
|
||||
**结论**:上游值得借的是呈现与选题顺序:用户一听就懂「为什么要答这几道题」,题也少。不借的是秒级口径和「记录时间是错的」。
|
||||
|
||||
我方现状(`fddddaab`,行号按符号定位,执行方开工先复核):
|
||||
|
||||
| 位置 | 现状 |
|
||||
| --- | --- |
|
||||
| `scripts/rectification/refinement_packet.py` `window_scan` | 逐分钟扫描 11 层(D1/D9/D10/D4/D5/D7/D12/D24/D2/D11/D30)及展示层,输出 `transitions`(层、时刻、前后星座)。**不以记录时间为锚,不算记录时间离最近换段点多远** |
|
||||
| 同文件 `nakshatra_boundary` | 只看代表分钟的上升离星宿交界多近,出性格类二选一,不是分盘换段 |
|
||||
| 同文件 `precision_stage` | 按 D1→D9→D10→D4→D5/D24 顺序给「还差什么」,与记录时间无关 |
|
||||
| `frontend/src/lib/rectification-agentic/core/segment-summary.ts` | 段汇总只对 D1/D9/D10(`TARGET_CHARTS`),按领域取盘(`targetChartsForDomain`) |
|
||||
| `frontend/src/lib/rectification-agentic/core/segment-probe-order.ts` | 按段信息增益排题,窗口 ≤61 分钟默认开(BUG-1143/1144);只排序,不限定题目范围 |
|
||||
| `frontend/src/lib/rectification-agentic/birth-time-provenance.ts` `reportedTimeOffsetCopy` | 用户能看到的「记录时间」只出现在交付时,说的是记录时间与**交付区间**差几分钟,不是与分界的距离 |
|
||||
|
||||
用户在开场看不到「记录时间离分界多近、两侧哪里不同」。
|
||||
|
||||
## 2. 根因
|
||||
|
||||
扫描数据已经有了,但从没以记录时间为锚组织过:开场不讲,选题面向整个窗口,所以用户感觉步骤多、目的不明。
|
||||
|
||||
## 3. 决策记录(产品 2026-10-12)
|
||||
|
||||
1. 采纳「最近分界开场」:开场告诉用户,记录时间离最近的分盘换段点有多近(以分钟计)、两侧哪几层不同(用人话:父母、工作、住处、婚恋……)。
|
||||
2. 记录时间离分界近时,首轮只出能区分这条分界两侧的题,问完再按原流程。「近」的阈值由执行方用 77 例回放定,写进进度记录。
|
||||
3. **只改呈现与选题顺序,不改打分。** `decision_policy`、闸门、4 件 3 域(BUG-1193)、采用门、交付卡档位全部不动。
|
||||
4. 对外不说秒级(守 `docs/research/rectification_nadi_seconds_2026_10_05.md` §8 口径);不出现「记录时间是错的 / 不准」(守 BUG-691「记录优先、分歧并列」)。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. 不改打分、`scripts/rectification/decision_policy.py`、闸门常量、4 件 3 域、算法身份;同输入同分。
|
||||
2. v5 77 例持久回放(`docs/research/varga_resolution_persisted_onoff_after_fix_2026_10_01.json` 同法):±10 / ±30 真值段保留不低于 76 / 76;D9、D10 头段命中不低于修后基线;任一格下降即停,报产品。
|
||||
3. 用户可见文案不得出现秒、「记录时间错了 / 不准」、「已确认」;距离以整分钟说,不足 1 分钟说「不到 1 分钟」。
|
||||
4. 申报方式为 `period_only` 时没有可比的记录分钟,不出「离分界多近」这一句,走原流程。
|
||||
5. 守 AGENTS §6(Home() 状态数、API server 方法数不增长);新文案对照 VOICE.md;改 UI 同提交更新 `frontend/DESIGN.md`。
|
||||
6. 测试总数不低于基线;改既有断言写三栏。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
### T1 以记录时间为锚的分界计算(Python,薄注册)
|
||||
|
||||
- 在 `scripts/rectification/` 新模块里,基于 `window_scan` 的 `transitions`,给出:记录时间前后最近的换段点(分钟)、该点两侧不同的层清单、各层前后星座。只读已有扫描,不改 `window_scan` 的输出语义。
|
||||
- 验收:Python 定向测试用公开名人 golden 盘(AA 级);记录时间正好在段内、紧贴换段、窗口内无换段三种情况各一条;快速门全量。
|
||||
|
||||
### T2 开场一句话
|
||||
|
||||
- 校正开场(BUG-1257 开场切片所在处,执行方先定位)加一句:记录时间离最近换段点多近、两侧哪几件事不同,并说明接下来先问这几件。窗口内无换段时说「你填的时间附近这些盘都不换,先从经历开始」。
|
||||
- 验收:单测覆盖三种情况与 `period_only`;文案逐条对照 VOICE.md,不含红线 3 的词。
|
||||
|
||||
### T3 首轮选题限定在该分界
|
||||
|
||||
- 记录时间离分界在阈值内时,首轮候选题只取能区分两侧的层对应的题(复用现有题库与 `segmentInformationGain`,不新增题型);问完回到原排序。
|
||||
- 阈值用 77 例回放选,候选值至少 3 档(例如 2 / 5 / 10 分钟),逐档报问题数、真值段保留、头段命中。
|
||||
- 验收:回放表进进度记录;红线 2 全过才默认开启,否则留开关默认关并报产品。
|
||||
|
||||
### T4 真机清单
|
||||
|
||||
- `docs/testing/rectification-nearest-boundary-opening-20261012.md`:用公开名人资料或虚构资料,记录时间紧贴分界与远离分界各走一遍。
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
T3 回放不过门 → 只上 T1 + T2(呈现),T3 留开关默认关。T2 不可让。
|
||||
|
||||
## 7. 开工前置命令
|
||||
|
||||
```bash
|
||||
git fetch origin --prune
|
||||
git worktree add -b codex/rectification-nearest-boundary-opening-20261012 .worktrees/rectification-nearest-boundary-opening-20261012 origin/staging
|
||||
grep -oE "^## BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1
|
||||
export PYTHONHASHSEED=0 # 回放命令见 docs/research/rectification_varga_resolution_2026_09_30.md 附录
|
||||
```
|
||||
|
||||
门禁:Python 快速门全量、前端 tsc / lint / npm test(失败名单与基线逐条相同)/ build `/` Static / gzip ±2%。完成后 BUG-1307 改 fixed-pending-verify,写修复、验证与回放数字。
|
||||
@@ -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 | 申报出生时段的本人档案,问一件普通的事,不要提校正 | 正文不写大运、子运的起止年月日,也不承诺能收窄出生时间 |
|
||||
|
||||
做不了的两句和家人那句以界面上的原句为准。点数:直接进入的那一轮不按解读扣咨询点;校正本身仍按原来的开案扣费。
|
||||
@@ -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。没有新的等待动画或输入框。
|
||||
|
||||
@@ -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(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。
|
||||
|
||||
|
||||
@@ -735,7 +735,7 @@ export default function Home() {
|
||||
startNewChat,
|
||||
continueInNewChat,
|
||||
refreshAccount,
|
||||
openAccountDialog, openRectificationSession,
|
||||
openAccountDialog, openRectificationSession, openRectificationFromChat,
|
||||
onboardingPaywallShown, setOnboardingPaywallOpen,
|
||||
});
|
||||
const jumpToLatestVisible = !rectificationSurfaceOpen
|
||||
|
||||
@@ -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<AgentSettlementResult> {
|
||||
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<AgentSettlementResult> | null = null;
|
||||
function settleResult(action: () => Promise<AgentSettlementResult>) {
|
||||
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<Usage>[] = [];
|
||||
@@ -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,
|
||||
|
||||
@@ -155,6 +155,8 @@ export type ConsultationRunParams = {
|
||||
onboardingPaywallShown?: MutableRefObject<boolean>;
|
||||
setOnboardingPaywallOpen?: (open: boolean) => void;
|
||||
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
|
||||
/** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */
|
||||
openRectificationFromChat: (sourceSessionId: string) => Promise<void> | 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<ConsultationAgentPublicEvent, { type: "run.completed" }>["rectificationDirectOpen"];
|
||||
let runCompleted = false;
|
||||
let truncatedFailure: Extract<ConsultationAgentPublicEvent, { type: "run.failed" }> | 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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。";
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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<RectificationReturnRecord, "adopted" | "clock">): 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) ?? []) {
|
||||
|
||||
@@ -313,7 +313,8 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Ch
|
||||
}
|
||||
}
|
||||
flushPlan();
|
||||
events.push({
|
||||
const directCompleted = options.state ? rectificationDirectRunCompleted(options.state) : null;
|
||||
events.push(directCompleted ?? {
|
||||
type: "run.completed",
|
||||
receipt: agentExecutionReceiptSchema.parse(options.receipt()),
|
||||
...(options.state?.rectificationOffer ? { rectificationOffer: options.state.rectificationOffer } : {}),
|
||||
@@ -387,10 +388,32 @@ type StreamAgentResponseOptions = EventOptions & {
|
||||
// the single-calculation boundary. Gating on total attempts would make any
|
||||
// transient failure permanently unrecoverable.
|
||||
function contractReady(options: StreamAgentResponseOptions) {
|
||||
// An explicit rectification turn does not need a chart calculation (BUG-1306).
|
||||
if (options.state.rectificationDirectEntry) return true;
|
||||
return options.state.jyotishSkillBound
|
||||
&& (!options.requireTool || (options.state.consultationToolCompleted && options.state.consultationToolSuccessCount === 1));
|
||||
}
|
||||
|
||||
/** Smalltalk completion for a direct rectification turn. No receipt: the schema forbids both. */
|
||||
function rectificationDirectRunCompleted(state: ConsultationRuntimeState): ConsultationAgentPublicEvent | null {
|
||||
const entry = state.rectificationDirectEntry;
|
||||
if (!entry) return null;
|
||||
return consultationAgentPublicEventSchema.parse({
|
||||
type: "run.completed",
|
||||
responseKind: "smalltalk",
|
||||
...(entry.kind === "new" && state.rectificationOffer ? { rectificationOffer: state.rectificationOffer } : {}),
|
||||
...(entry.kind === "new" || entry.kind === "resume"
|
||||
? {
|
||||
rectificationDirectOpen: {
|
||||
sourceSessionId: entry.sourceSessionId,
|
||||
mode: entry.kind,
|
||||
...(entry.resumeSessionId ? { resumeSessionId: entry.resumeSessionId } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function isSkillBindingAbortError(error: unknown) {
|
||||
if (!(error instanceof Error)) return false;
|
||||
if (error.message === SKILL_BINDING_FAILED) return true;
|
||||
@@ -654,6 +677,8 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
stepCalledTool = false;
|
||||
};
|
||||
const outputText = async (text: string) => {
|
||||
// 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.
|
||||
|
||||
@@ -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<RectificationOfferDecision>;
|
||||
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<string | null>;
|
||||
now?: () => Date;
|
||||
}>;
|
||||
|
||||
@@ -1039,40 +1063,120 @@ const evidenceLookupInputSchema = z.object({
|
||||
}).strict();
|
||||
|
||||
const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["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<RectificationOfferDecision, { ok: true }>,
|
||||
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: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
@@ -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> = {}): RectificationOfferRecord {
|
||||
return {
|
||||
status,
|
||||
creditCost: 3,
|
||||
questionEstimate: 8,
|
||||
sourceQuestion: "我和伴侣还能走下去吗",
|
||||
offeredAt: "2026-10-07T00:00:00.000Z",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function offerTool(options: {
|
||||
alreadyOffered?: boolean;
|
||||
decide?: () => Promise<OfferDecision>;
|
||||
loadResumableSessionId?: () => Promise<string | null>;
|
||||
} = {}) {
|
||||
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<string, { execute: (input: unknown, context?: unknown) => Promise<Record<string, unknown>> }>;
|
||||
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<typeof createConsultationRuntimeState>): 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" },
|
||||
});
|
||||
});
|
||||
@@ -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<string, { execute: (input: unknown, context?: unknown) => Promise<Record<string, unknown>> }>;
|
||||
|
||||
Reference in New Issue
Block a user