diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1b68a7..ba5352e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-10-07 — 生时校正改从对话里开始,做完回到原来的对话(未上线) + +- 首页不再有「生时校正」按钮(BUG-1247)。在对话里说「你说的那年其实没发生」「我出生时间是我妈估的」这类话时,agent 会在回答下面放一张卡,写着大约几题、几点;只是不认同某个解释时不会提。给家人档案问时会说暂不支持(BUG-1248)。 +- 点卡进入校正,系统记住是从哪条对话来的(BUG-1249)。采用了时间、核对完,或者校正结束,会自动回到原对话;校正面也有「回到刚才的对话」按钮。原对话里多一行「出生时间改为 HH:MM」,之前的回答标「按校正前的时间」,下面的「按新时间重新看」点了才回答、才扣点(BUG-1250)。 +- 之后的回答不会沿用旧时间下的盘面判断(BUG-1251)。 +- 校正流程、打分、采用规则、计费方式都不变;Skill 版本不变;不改数据库结构。 + ## 2026-10-07 — 生时校正里的固定提示语也换成温和口气(未上线) - 选择题回执、记不清时的回复、采用后的提示等约 30 句改写:不再是「已记录。……」这种回执腔,不用「请……」,不说「候选」这类内部词,采用后不再叫你「新建对话」(BUG-1258)。 diff --git a/CONTEXT.md b/CONTEXT.md index 152fa34c..5b5aacc9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,14 @@ 用户与某一种 Jyotisha Agent 持续交互的容器,例如普通咨询或出生时间校正。 _避免使用_:聊天记录、咨询(用于泛指所有 Agent 场景时) +**来源对话**: +用户从中开始一次生时校正的那条普通咨询会话。校正做完回到它;关联记在该会话回答上的提议卡里(`rectificationOffer.caseId`),不另建表(2026-10-07,BUG-1249)。 +_避免使用_:原会话、父会话 + +**提议卡**: +普通咨询回答下面、由服务端决定能否出现的「开始核对出生时间」卡片;生时校正唯一的新入口(2026-10-07,BUG-1248)。 +_避免使用_:校正入口按钮、首页卡片 + **Agent 对话轮次**: 在 Agent 会话中,从用户输入触发 Agent 生成一条回复开始,到 Agent 完整回复或该次回复失败为止的一次交互。 _避免使用_:单条消息、一轮对话 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 30c05b8a..34723b95 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -16852,3 +16852,52 @@ - 防复发:上述新测试;VOICE.md 新增本节规则与对照表。 - 相关记录:BUG-1257、BUG-1153 / BUG-1241(回执与交付句防重复,确认未受影响)、TASK-rectification-in-chat-step1-20261006 C8(同样不再叫人新建对话)。 - 修复版本:staging `6ec8efd5`(2026-10-07 部署核对:health gitCommit=6ec8efd5、各项 ok,`/api/account` 401,`/login` 200;门禁 run 1759 一次通过)。真机未走,状态保持 fixed-pending-verify + +## BUG-1247 | 首页生时校正按钮与文字口令:入口在对话之外、对话里只靠固定口令跳转 + +- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved) +- 首次发现 / 最近更新:2026-10-07 / 2026-10-07 +- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。 +- 现象:首页有独立「生时校正」pill;普通对话里只有打出「生时校正 / 先完成生时校正」这类原话才会新开一条校正会话,原问题只剩一行展示。 +- 根因:入口按产品面划分,不按对话需要;对话侧用前端正则 `isRectificationHandoffQuestion` 判断意图(违反禁正则判意图口径)。 +- 修复:删首页 pill、`rectificationEntryHint`、`rectificationEntryLabels` / `resolveRectificationEntryAction`;删 `isRectificationHandoffQuestion` 及发送路径里的交接分支与 `pendingConsultationQuestion` 展示。历史里的校正会话照常打开,只读历史里的「再次校正」保留。 +- 验证 / 防复发:见 BUG-1248 汇总;断言更新 14 个测试文件(三栏注释)与 Python `test_daily_and_rectification_entrypoints.py`。 +- 相关记录:BUG-976/977(禁正则判意图)、BUG-1115(交接前保存来源会话,已无此路径)。 +- 修复版本:待发布 + +## BUG-1248 | 对话里没有「推得不准」时的校正提议 + +- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved) +- 首次发现 / 最近更新:2026-10-07 / 2026-10-07 +- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。 +- 现象:用户在普通对话里说过去的事对不上、出生时间是估的,agent 没有任何校正相关的指令或入口。 +- 修复:`RECTIFICATION_OFFER_RULE`(`consultation-thinking-plan.ts`,只定义一处)进首轮、追问与申报时段指令:先分读法 / 时间问题,时间问题才在写回答前调用 `offer-birth-time-rectification`;同会话提议过且用户没接不再主动提(`userAsked=true` 例外)。工具只记录本轮提议,能否出卡由服务端 `decideRectificationOffer` 决定(本人、`isProductEnabled("rectification")`、无进行中 case、`feature_pricing` 价格);提议随回答一起经 `complete_consultation_response` 存进消息,并在 `run.completed` 事件里带给浏览器。卡片 `RectificationOfferCard` 挂在该回答下。 +- 验证:新增 `rectification-in-chat-20261007.test.ts` 14 条(服务端决策 5 种结果、工具一轮一张卡与会话内不重复提议、事件带提议、规则进三种指令、卡片 / 分隔线 / 小字渲染、浏览器回读保留字段等)。DeepSeek(deepseek-flash)模拟 4 场景各 2 次:只是不认同解释 2/2 不调用;过去的事对不上 + 时间是估的 2/2 调用;家人档案 2/2 调用后被拒、一句话说明;已提议又被拒 2/2 不再推;成功后正文提到卡片 3/3(工具说明加强后复测)。全量见进度记录。 +- 修复版本:待发布 + +## BUG-1249 | 校正不知道是从哪条对话开始的 + +- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved) +- 首次发现 / 最近更新:2026-10-07 / 2026-10-07 +- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。 +- 修复:打开接口在 `intent=homepage` 且带 `sourceSessionId` 时,把该会话里最新一张 `offered` 提议卡改为 `opened` 并写入 `caseId` / `rectificationSessionId`(调用者自己的客户端,`messages` 为本人可更新列;best effort)。新 `GET /api/rectification/cases/[caseId]/source` 在本人最近 20 条咨询会话里找绑定该 case 的提议卡,刷新后仍能找回来源。不改表。 +- 验证:同上测试文件(绑定一次、幂等、无提议不绑定、来源查找)。真实库读写未在本机跑(无 Docker):只用了已有查询写法(eq / order / limit / maybeSingle / update),见进度记录。 +- 修复版本:待发布 + +## BUG-1250 | 校正做完让用户「新建对话」,原问题和上下文丢失 + +- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved) +- 首次发现 / 最近更新:2026-10-07 / 2026-10-07 +- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。 +- 修复:`POST /api/rectification/cases/[caseId]/source` 由服务端读 case 的 `accepted_time` 判断是否改了时间,在来源对话末尾追加一条分隔消息(`rectificationReturn`,每个 case 一次)并把提议卡标为 `returned`;浏览器重读该会话并切过去。校正面「回到刚才的对话」按钮(仅有来源时);采用后核对结束(`verified_idle`)或 case 终态时,在本视图做过操作的校正 2.5 s 后自动回去(`useRectificationAutoReturn`),历史打开的已结束 case 不自动跳。分隔线下「按新时间重新看」走 `onFollowUp`,点击才发送。分隔消息不显示操作行、不参与「重新生成」。 +- 验证:同上测试文件(追加一次、改 / 未改两种文案、标记 returned、不点不发、自动返回只跟本视图操作)。浏览器级与真实库未验,见真机清单。 +- 修复版本:待发布 + +## BUG-1251 | 改了出生时间后,模型仍读得到旧时间下的回答 + +- 状态:fixed-pending-verify(分支 `codex/rectification-in-chat-step1-20261006`;部署与真机清单完成前不标 resolved) +- 首次发现 / 最近更新:2026-10-07 / 2026-10-07 +- 来源:产品决定生时校正并入对话(任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`,P1–P3、C1–C8),产品要求直接执行。 +- 修复:`storedConsultationTurns` 跳过分隔消息;最近一次改时间的分隔线之前的助手回答,在模型历史里前缀「(以下回答按校正前的出生时间,不要沿用其中的盘面判断)」;会话摘要早于改时间时同样加这句。 +- 验证:同上测试文件(改了 / 没改两种情况、摘要)。 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-in-chat-step1-20261006.md b/docs/tasks/PROGRESS-rectification-in-chat-step1-20261006.md new file mode 100644 index 00000000..45612a3e --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-in-chat-step1-20261006.md @@ -0,0 +1,42 @@ +# PROGRESS:生时校正并入对话 · 第一步 — 2026-10-07 + +产品 2026-10-07 要求直接执行。实现:Claude fork 子代理;分支 `codex/rectification-in-chat-step1-20261006`(基线 `origin/staging` `328f919d`,代码同 `6ec8efd5`)。未推送。BUG-1247~1251 均为 fixed-pending-verify。 + +## 实现(按任务书) + +| 项 | 做法 | +| --- | --- | +| T1 / P3 / C1 | 删首页「生时校正」pill、`rectificationEntryHint`、`rectificationEntryLabels` / `resolveRectificationEntryAction`;删 `isRectificationHandoffQuestion` 与发送路径里的交接分支、`pendingConsultationQuestion` 展示与传参。`page.tsx` 1357 → 1337 行,`useState` / `useRef` 数不变;新增一个 `onStartRectification` 动作 | +| T2 / C2 / C3 | `RECTIFICATION_OFFER_RULE` 只定义在 `consultation-thinking-plan.ts`,进首轮、追问与申报时段指令。工具 `offer-birth-time-rectification`(本命与申报时段两种 agent 都有):只记录本轮提议;能否出卡由服务端 `decideRectificationOffer` 决定(本人、产品开关、无进行中 case、`feature_pricing` 价格);会话里已提议过且非用户主动要求时拒绝。提议随回答经 `complete_consultation_response` 存进 `messages`,`run.completed` 事件带给浏览器 | +| T3 / C4 | 打开接口带 `sourceSessionId` 时把该会话最新一张提议卡绑定到 case;`GET /api/rectification/cases/[caseId]/source` 在本人最近 20 条咨询里找来源;与校正水合并行读取 | +| T4 / C5 / C6 / C8 | `POST …/source` 由服务端读 `accepted_time` 判断是否改了时间,追加一次分隔消息、提议卡标 `returned`;浏览器重读来源会话并切过去。校正面「回到刚才的对话」按钮;`verified_idle` 或 case 终态时,本视图内做过操作的 case 2.5 s 后自动返回(历史打开的不跳)。分隔线、「按校正前的时间」小字、「按新时间重新看」(走普通 `onFollowUp`,点了才发)。C8 文案在 BUG-1258 已改为「之后的对话都按这个时间来看」,本单保持 | +| T5 / C7 | 模型历史跳过分隔消息;改时间前的助手回答与早于改时间的会话摘要前缀「(以下回答按校正前的出生时间,不要沿用其中的盘面判断)」 | +| T6 | DESIGN.md、VOICE.md、CONTEXT.md(来源对话、提议卡)、CHANGELOG、BUG-1247~1251、本记录、真机清单 `docs/testing/rectification-in-chat-step1-20261006.md` | + +数据库:**不改表、不加迁移**。关联存在咨询会话自己的 `messages` JSON 里(`rectificationOffer` / `rectificationReturn`),写入用调用者自己的客户端(`messages` 是本人可更新列,`20260715030000` 起即有授权)。 + +## 偏离与原因 + +- 任务书 C4 说「刷新后仍在(服务端存)」:来源不单独存,读时在本人最近 20 条咨询里找绑定了该 case 的提议卡(绑定会写该会话,所以它在最近更新里)。代价:若用户之后在 20 条以上别的对话里聊过,再打开这次校正时找不到来源(只是没有返回按钮,不报错)。 +- 来源对话的消息改写是读后整写,没有乐观锁:本地 Postgres 兼容层取回的 `updated_at` 精度不一定能做等值比较。并发风险只在用户同时在来源对话里提问时出现(此时人在校正面)。 +- 提议工具成功后的说明,经模型实测改为「正文最后一段必须有一句提到卡片」(原写法 2/2 没提,改后 3/3 提到)。 +- 首页卡片专用的 `RECTIFICATION_OPENING_LABEL` 一并删除(侧栏行的打开中文案是另一个常量,不变)。 + +## 验证(Linux + Node 22.14) + +| 项 | 基线 `6ec8efd5` | 本分支 | +| --- | --- | --- | +| `tsc --noEmit` | — | 0 错 | +| `npm run lint` | 0 error / 126 warning | 0 error / 125 warning | +| `npm test` | 4973 项 / fail 24 | 4987 项 / fail 24;失败名单逐条一致(均为无 Docker 的数据库 / 部署类);测试名逐条比对,无消失,新增 14 条 | +| `next build` | `/` ○ Static,首屏 gzip 567,201 B | `/` ○ Static,568,343 B(+0.20%) | +| Python 快速门 | — | py_compile / JSON / 审计 / pytest(329.7 s)通过;其中 `npm test` 一步因上述 24 条环境失败而红,与基线相同 | + +断言改动 14 个前端测试文件 + `tests/test_daily_and_rectification_entrypoints.py`,每处写原值 / 新值 / 原因。 + +模型实测(DeepSeek deepseek-flash,公开名人 golden 盘,脚本在会话临时目录、不入库):只是不认同解释 2/2 不调用提议工具;「过去的事对不上 + 时间是估的」2/2 调用;家人档案 2/2 调用后被服务端拒、一句话说明;已提议后再说不准 2/2 被拒且不再推;成功后正文提到卡片 3/3。 + +## 环境缺口 + +- 无 Docker:来源绑定 / 返回的真实库读写(`chat_sessions.messages` 读改写、`maybeSingle`、`order + limit`)未真查;只用了本地 Postgres 兼容层已实现的写法。部署后按真机清单第 4–8 步验。 +- 无登录态浏览器:卡片、返回、自动返回、按钮不点不发均为渲染与源码合同测试,真机清单 11 步待产品。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 9e5ac0ec..3beac31e 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -425,7 +425,7 @@ | `TASK-rectification-nadi-seconds-research-20261005.md` | `PROGRESS-rectification-nadi-seconds-research-20261005.md` | **「纳迪秒级校准」可证伪检验(离线)**:竞品宣传「问前事到天 → 秒级」。本仓主链只用三层小运,Sookshma / Prana 与 D150 从未进评价集;v5 真值 52/77 是整 5 分钟(秒级无真值可对)。N0 五层小运 + D150 底座(前三层与主链对账 0 差)、N1 拟合率 vs 安慰剂日期(核心)、N2 留一件预测、N3 六题后区间内再细分能否提头名、N4 岁差 / 坐标 / 时间扰动的噪声地板、N5 D150 结构层(原文比对 blocked)、N6 结论 + 对外口径草稿。规则先登记再跑;不改生产代码;不得重调 BUG-1091 已关的权重 | 已验收(Claude 10-05:N1/N2/N3/N5 不过门、N4 触发文案禁令;Linux 复跑内容 0 差,结果改 LF;补 Lahiri/KP/True Chitra 对照,月亮差 0.83′ 第 5 层仍换 94%)→ 不立实现单 | 分支 `codex/rectification-nadi-seconds-research-20261005`(BUG-1240 `closed_by_design`) | | `TASK-rectification-delivery-dup-adopt-20261006.md` | `PROGRESS-rectification-delivery-dup-adopt-20261006.md` | **校正交付一次两段回答 + 点「改用…盘解读」被拒 `adoption_not_allowed`**(10-06 真机):收尾补位按文字子串防重,遇采用旁白 Agent 改写版失效(BUG-1153 复发);accept/GET 调 `decideFromDossier` 不带出生日期,未成年探针复活致判定与出卡时相反(BUG-598/680 同族);卡片按钮不看公开 can_adopt、拒绝码直出英文。不放宽采用门,判定入参统一 + 结构防重 + 按钮判据 + 中文提示(BUG-1241~1243) | **已验收,待推 staging**(Claude 10-06:首版 `b977f482` 验收 T1/T3 过、T2 未过;修复单 F1 由 Claude 直接执行——防重改为「上一条包含交付句」,真实答题路径测试修前红修后绿;F2 疑似 BUG-1246 复核不成立已撤回、留守护测试。全量 npm 失败名与基线逐名相同、tsc 0、lint 0 error;PG17 重放未做,见 PROGRESS) | 分支 `codex/rectification-delivery-dup-adopt-20261006`;修复单 `TASK-rectification-delivery-dup-adopt-fix-20261006.md` | | `TASK-consult-conversational-answer-20261006.md` | `PROGRESS-consult-conversational-answer-20261006.md` | **普通对话首轮去汇报骨架,改成聊天**(10-06 产品反馈「像机器在汇报」):首轮取消全部 `##`(三个固定节 + 按对象标题,多人改分段落点名)、行动不强制(盘上有具体指向才顺口一句,问「怎么办」再展开)、首轮三到六段;示范删可抄的行动句(实测被逐字照抄);思考栏去「这周可以做什么」。推翻 10-01 D2 与 D8 首轮部分(BUG-1244~1245) | 已验收 | 28113fa0 验收未过 → 修复单 `TASK-consult-conversational-answer-fix-20261006.md` 由 Claude 直接执行(含去掉助手头像),复验通过后推 staging;真机清单 10 步待产品 | -| `TASK-rectification-in-chat-step1-20261006.md` | `PROGRESS-rectification-in-chat-step1-20261006.md` | **生时校正并入聊天 · 第一步**:删首页「生时校正」按钮与文字口令;普通对话里用户说不准时由 agent 分清读法 / 时间问题,时间问题出提议卡(本人、开关、价格服务端校验,同会话最多主动提一次);点卡带来源打开校正;采用 / 未采用 / 手动返回都回到原对话,分隔线 + 旧回答标「按校正前的时间」+ 点了才发的「按新时间重新看」;模型历史标注旧时间。须等 dup-adopt 单合入后开工;第二步(卡片嵌进同一对话)另立单(BUG-1247~1251) | 待领取 | — | +| `TASK-rectification-in-chat-step1-20261006.md` | `PROGRESS-rectification-in-chat-step1-20261006.md` | **生时校正并入聊天 · 第一步**:删首页「生时校正」按钮与文字口令;普通对话里用户说不准时由 agent 分清读法 / 时间问题,时间问题出提议卡(本人、开关、价格服务端校验,同会话最多主动提一次);点卡带来源打开校正;采用 / 未采用 / 手动返回都回到原对话,分隔线 + 旧回答标「按校正前的时间」+ 点了才发的「按新时间重新看」;模型历史标注旧时间。须等 dup-adopt 单合入后开工;第二步(卡片嵌进同一对话)另立单(BUG-1247~1251) | 待验收 | 2026-10-07 产品要求直接执行;Claude fork 子代理实现,分支 `codex/rectification-in-chat-step1-20261006`(未推);不改表;真机清单 11 步 | | —(直接执行,无任务书) | `PROGRESS-consult-readable-20261006.md` | **回答铺满一行、输出不抖、不念分析清单**(10-06 真机截图):答案正文去 `text-wrap: pretty`(WebKit 整段等长 + 逐字重排,BUG-1252);清单开头写明只用于分析、婚恋三层改生活说法、字段白话对照、括号最多两条(BUG-1253) | 已验收 | Claude 直接执行;真机清单 `docs/testing/consult-readable-20261006.md` 待产品 | | —(直接执行,无任务书) | —(记录在 BUG-1254) | **门禁偶发红:数据库测试排队等槽位超时**:46 个 fixture 文件抢 2 个槽位、固定等 5 分钟,排在后面的随机失败;改为槽位易手即续期、连续 10 分钟无进展才失败(BUG-1254) | 已验收 | Claude 直接执行;门禁实际效果待看 | | —(直接执行,无任务书) | —(记录在 BUG-1255) | **回答口吻改知心姐姐、放开读心**(10-07 产品决定):人设统一;读心守三条(先答再读、可能/多半一句、当事人要有依据且未受冲);删冲突的旧规矩。推翻 BUG-1070 读心部分 | 已验收 | Claude 直接执行;真机清单 `docs/testing/consult-voice-sister-20261007.md` 待产品 | diff --git a/docs/testing/rectification-in-chat-step1-20261006.md b/docs/testing/rectification-in-chat-step1-20261006.md new file mode 100644 index 00000000..22e2b48b --- /dev/null +++ b/docs/testing/rectification-in-chat-step1-20261006.md @@ -0,0 +1,17 @@ +# 真机清单 · 生时校正从对话进入、做完回到原对话(2026-10-07) + +对应 BUG-1247~1251,任务书 `docs/tasks/TASK-rectification-in-chat-step1-20261006.md`。在 staging 上用自己的账号(本人档案)走,deepseek-v4-flash 一遍、另一个模型一遍。回答原文与出生资料不记进仓库。 + +| 步 | 怎么做 | 通过标准 | +| --- | --- | --- | +| 1 | 打开首页 | 只有「今日星语」一枚按钮,没有「生时校正」 | +| 2 | 新对话问「我和伴侣还能走下去吗」,答完后说「你说我们磨合多,我觉得不对,我们很少吵架」 | agent 认下、改读;回答下面**没有**提议卡 | +| 3 | 接着说「你说我 2019 年换工作,其实那年什么都没发生;我出生时间是我妈估的」 | 回答最后一句提到可以核对出生时间;下面出现提议卡,写着大约几题、几点、答第一题才扣;按钮「开始核对出生时间」 | +| 4 | 刷新页面,回到这条对话 | 提议卡还在 | +| 5 | 点「开始核对出生时间」 | 进入校正;输入框上方有「回到刚才的对话」;刷新后仍在;回原对话看提议卡变成「出生时间核对已经开始」+「继续核对出生时间」 | +| 6 | 在校正里答完、采用一个时间、做完采用后核对 | 几秒后自动回到原对话;末尾一行「出生时间改为 HH:MM(生时校正)」;之前的回答上方都有「按校正前的时间」;有「按新时间重新看:「…」」按钮;**不点就不出新回答、点数不变** | +| 7 | 点「按新时间重新看」 | 正常回答一轮、扣一次点;回答按新时间,不沿用旧回答里的盘面判断 | +| 8 | 另开一条对话照第 3 步说,进入校正后中途点「回到刚才的对话」 | 回到原对话,分隔线写「出生时间没有改」,没有「按校正前的时间」;侧栏里那次校正还在、能再打开 | +| 9 | 同一条对话里第 3 步那类话再说一次(之前没点卡) | agent 不再主动出卡;明确说「我想核对出生时间」时才再出 | +| 10 | 切到一个家人档案问同样的话 | 一句「给家人校正暂时还不支持」,不出卡 | +| 11 | 侧栏打开一个 10 月 7 日之前的校正会话 | 能正常打开、看结果;没有「回到刚才的对话」按钮,也不会自动跳走 | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index f6b174c7..c7c291d7 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -1,5 +1,16 @@ # Jyotisha Web Design System +## 生时校正从对话进入、做完回到原对话(2026-10-07,BUG-1247~1251,在途) + +首页不再有「生时校正」pill,`.starter-entries` 只剩今日星语一枚(`.starter-entry-hint` 那句「生时校正暂时只支持本人。」随之删除)。入口在对话里: + +- **提议卡**(`.rectification-offer-card`):挂在提议了校正的那条回答下面,与追问 chip 同一缩进(`--assistant-content-inset`),浅底细边圆角卡,一句说明(大约几题、几点、答第一题时才扣)+ 一个实心按钮「开始核对出生时间」。校正已开始时同一张卡改成「出生时间核对已经开始。」+ 描边按钮「继续核对出生时间」;回来之后卡片消失。对话进行中(loading / 取消中)按钮不可点。 +- **回到刚才的对话**:从对话进来的校正,在校正面输入框上方一行描边小按钮(`.rectification-return-row`);没有来源对话的校正(历史里打开的、之前开的)不出这一行。采用后核对结束、或 case 结束时,在这个视图里做过操作的校正 2.5 秒后自动回去;从历史打开一个已结束的校正不会自动跳走。 +- **分隔线**(`.rectification-return-divider`):回到原对话后追加一行居中小字「出生时间改为 HH:MM(生时校正)」或「出生时间没有改」,不是一条消息、没有操作行;改了时间时下面一个描边按钮「按新时间重新看:「原问题」」,点了才发送(普通一轮,照常计费)。 +- **按校正前的时间**(`.message-before-change`):改了时间后,分隔线之前的每条回答上方一行灰色小字;原文不动。 + +不新增等待动画;打开校正沿用既有一次揭幕。 + ## 用量页分段计时(2026-10-05,在途) `/admin/usage` 在原表格上多一列「分段」。格子里四行:分类耗时、第 0 步、第 1 步、第 1 步开始到第一个正文字符。每步带推理、输出、输入、缓存输入 token。供应商没返回的数显示「—」。不新开页面,不改表,数字来自 `usage_ledger.metadata` 里已有的 JSON。没有新的等待动画或输入框。 @@ -701,12 +712,13 @@ page. Three parts now, in reading order: `--session-column-width` in both states — the empty-state composer used to be 1040px against the session's 760px, so the input visibly narrowed the moment a conversation started. -- **Entry pills** (`.starter-entry`): the two product entrances, rendered inside +- **Entry pills** (`.starter-entry`): the product entrance(s), rendered inside `.composer-wrap` *below* the composer. The visual pill is 36px tall (`min-height: 36px`). The transparent hit area is the `::after` box with `inset: -4px`, so the touch target is 44px. One word each, accent icon 15px: lucide `MoonStar` for 今日星语 (was `Sparkles`, the same glyph as the credits - button) and `Clock3` for 生时校正. The header credits button uses lucide + button). The 生时校正 pill (`Clock3`) was removed on 2026-10-07 (BUG-1247): + rectification starts from an offer card under a consultation answer. The header credits button uses lucide `Coins`; its accessible name is unchanged. Below the pills there is normally **nothing**: a hint line (`.starter-entry-hint`) appears only when the user has something to do, and an accuracy-boundary line appears when no natal minute is diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 812a6cad..906f6b45 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -23,6 +23,13 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 首页开场语下面可以有一行今日趋势(今日星语卡片的 trend,每日生成);还没生成时写「今天的星语还没写出来。」,没有出生分钟时不写。入口按钮下面平时不写字,只在用户需要动手时写一句:有没做完的校正写「上次那次校正还没完成,可以在历史对话里接着做。」;当前人物不是本人写「生时校正暂时只支持本人。」。不再写「不确定出生时间时,用记得住的经历一步步缩小范围」「上次已经校正完,可以拿最新资料再来一次」,也不要用「·」把两句不相干的提示拼成一行。 +## 生时校正从对话进入(2026-10-07,BUG-1247~1251) + +- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正;说出过去的具体事情和盘对不上、说出生时间是估的或记不准、或明确想核对出生时间,agent 才调用提议工具。同一次对话提议过、用户没接,不再主动提;规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。 +- **正文怎么提**:一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。给家人档案时说「给家人校正暂时还不支持」,不出卡。 +- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。 +- **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。 + ## 生时校正服务器固定句(2026-10-07,BUG-1258) 校正里服务器写死的回执与提示,和 Agent 一样用知心姐姐口吻(BUG-1257),产品认可样稿后改: diff --git a/frontend/scripts/home-streaming-render-benchmark.mts b/frontend/scripts/home-streaming-render-benchmark.mts index c8946ba9..305b9ffa 100644 --- a/frontend/scripts/home-streaming-render-benchmark.mts +++ b/frontend/scripts/home-streaming-render-benchmark.mts @@ -54,6 +54,7 @@ function emptyActions(): ChatTranscriptActions { onCopy() {}, onRegenerate() {}, onFollowUp() {}, + onStartRectification() {}, }; } diff --git a/frontend/src/app/(app)/page.tsx b/frontend/src/app/(app)/page.tsx index 7919095d..f854f2c5 100644 --- a/frontend/src/app/(app)/page.tsx +++ b/frontend/src/app/(app)/page.tsx @@ -14,20 +14,13 @@ import { AppLoadingIndicator } from "@/components/app-loading-indicator"; import { HomeBootstrapError } from "@/components/home-bootstrap-error"; import { HomeOnboardingShellSlot } from "@/components/home-onboarding-shell-slot"; import { BirthSkyReveal } from "@/components/birth-sky/birth-sky-reveal"; -import { - rectificationEntryLabels, - resolveRectificationEntryAction, -} from "@/lib/rectification-entry"; import { composerLocksAsRectification, ordinaryComposerPlaceholder, rectificationOrdinaryComposerReopen, } from "@/lib/rectification-session-composer-guard"; import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification"; -import { - declaredBirthTime, - RECTIFICATION_OPENING_LABEL, -} from "@/lib/rectification-surface-state"; +import { declaredBirthTime } from "@/lib/rectification-surface-state"; import { toggleChatMessageFeedback, type ChatMessageFeedback, @@ -51,7 +44,6 @@ import { StarterEntries, StarterGreeting } from "@/components/starter-home"; import { GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION, isGeneralDailyFortuneQuestion, - isRectificationHandoffQuestion, type ConsultationEntrypoint, } from "@/lib/consultation-entrypoint"; import { assistantIntentCopy } from "@/lib/birth-time-intake-model"; @@ -294,6 +286,7 @@ export default function Home() { onCopy() {}, onRegenerate() {}, onFollowUp() {}, + onStartRectification() {}, }); const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); const sessionDetailInFlight = useRef(new Set()); @@ -379,17 +372,6 @@ export default function Home() { : activeReplyOutcome?.phase ?? "idle"; const replyAnnouncement = chatReplyAnnouncement(replyPhase, activeReplyOutcome?.replyOrdinal ?? 0); const accountId = account?.user.id; - const rectificationCardAction = resolveRectificationEntryAction( - rectificationEntrySummary ?? { - hasResumableCase: false, - hasTerminalCaseWithTime: false, - latestResumable: null, - latestTerminal: null, - }, - ); - const rectificationCardLabel = rectificationLoading - ? RECTIFICATION_OPENING_LABEL - : rectificationEntryLabels[rectificationCardAction]; const rectificationDeclaration = { declaredTime: declaredBirthTime(profile), birthDate: profile.date || null }; const rectificationErrorMessage = rectificationError === "profile_incomplete" ? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。" @@ -432,7 +414,7 @@ export default function Home() { const { setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession, - openRectificationFromHomepage, openRectificationSession, reopenRectificationSession, + openRectificationFromChat, openRectificationSession, reopenRectificationSession, } = useRectificationSurface({ account, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog, persistSession, profile, rectification, setRectification, @@ -753,7 +735,7 @@ export default function Home() { startNewChat, continueInNewChat, refreshAccount, - openAccountDialog, openRectificationFromHomepage, openRectificationSession, + openAccountDialog, openRectificationSession, onboardingPaywallShown, setOnboardingPaywallOpen, }); const jumpToLatestVisible = !rectificationSurfaceOpen @@ -1035,6 +1017,9 @@ export default function Home() { onFollowUp(question) { void send(question, activeSession?.theme); }, + onStartRectification(sourceSessionId) { + void openRectificationFromChat(sourceSessionId); + }, }; }); @@ -1320,11 +1305,6 @@ export default function Home() { dailyStarlanguageQuestion={dailyStarlanguageQuestion} productEntrypointsDisabled={productEntrypointsDisabled} startDailyStarlanguageConsultation={startDailyStarlanguageConsultation} - rectificationCardLabel={rectificationCardLabel} - rectificationLoading={rectificationLoading} - rectificationMutationPending={rectificationMutationPending} - openRectificationFromHomepage={openRectificationFromHomepage} - rectificationEntrySummary={rectificationEntrySummary} /> )} } diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index e66cfb0d..7841a538 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -14,6 +14,8 @@ import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/l 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 { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata"; import { ConsultationPlanValidationError, @@ -55,6 +57,7 @@ import { natalUserTurnShape, natalConsultationThinkingPlan, type PublicThinkingSection, + RECTIFICATION_OFFER_RULE, } from "@/lib/consultation-thinking-plan"; import { AGENT_MAX_STEPS, @@ -66,6 +69,7 @@ import { consultationNatalPrepareStep, consultationWindowPrepareStep, createConsultationAgentContext, + type RectificationOfferHook, createWindowConsultationAgentContext, precomputeWindowConsultation, windowPrecomputedPacketMessage, @@ -240,7 +244,7 @@ function generalNoMinuteInstruction(hasPublicDaily: boolean) { } function declaredWindowInstruction() { - return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。这只是粗看。"; + return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。这只是粗看。" + RECTIFICATION_OFFER_RULE; } function usesPublicDailyGeneralAgent( @@ -867,6 +871,8 @@ export async function POST(request: Request) { techniqueTruth, workflowReceipt, ...(agentExecutionReceipt ? { agentExecutionReceipt } : {}), + // BUG-1248: the offer card hangs under this answer, also after a reload. + ...(latencyState?.rectificationOffer ? { rectificationOffer: latencyState.rectificationOffer } : {}), }; const actualUsage = await usagePayload(usage); const completion = await retryDetachedSettlement(async () => { @@ -950,6 +956,17 @@ export async function POST(request: Request) { // evidence of having done so. const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS }); latencyState = state; + // C2/C3 (BUG-1248): the offer tool asks the server, never decides itself. + const rectificationOffer: RectificationOfferHook = { + decide: () => decideRectificationOffer({ + accounting, + userId, + subjectRole: prepared.subject?.role === "other" ? "other" : "self", + modelId: selectedModel.id, + }), + alreadyOffered: sessionHasRectificationOffer(chatSession?.messages), + revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId), + }; const hooks = createConsultationRuntimeHooks(state); const usages: Promise[] = []; const agentStartedAt = Date.now(); @@ -1272,6 +1289,7 @@ export async function POST(request: Request) { declaredWindow: prepared.declaredWindow, abortSignal: agentAbortSignal, state, + rectificationOffer, }); const agent = getWindowJyotishAgent(selectedModel, agentContext); const retry = async () => { @@ -1409,6 +1427,7 @@ export async function POST(request: Request) { serverChart: prepared.serverChart, abortSignal: agentAbortSignal, state, + rectificationOffer, }); const agent = getJyotishAgent(selectedModel, agentContext); const result = await streamWithOverflowRetry(agent, natalStreamOptions); diff --git a/frontend/src/app/api/rectification/cases/[caseId]/source/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/source/route.ts new file mode 100644 index 00000000..ca5d43f1 --- /dev/null +++ b/frontend/src/app/api/rectification/cases/[caseId]/source/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { findRectificationSource, returnToRectificationSource } from "@/lib/rectification-in-chat-server"; + +export const runtime = "nodejs"; + +type RouteContext = { params: Promise<{ caseId: string }> }; + +/** + * GET /api/rectification/cases/[caseId]/source — the consultation this case + * was started from, or { source: null } (homepage / history cases). + * POST /api/rectification/cases/[caseId]/source — go back: appends the return + * divider to that consultation once and answers where to go. + * (TASK-rectification-in-chat-step1-20261006 C4/C5, BUG-1249/1250) + */ +async function clients(label: string) { + try { + return { supabase: await createServerSupabaseClient(), accounting: createAdminSupabaseClient() }; + } catch (error) { + return { failure: jsonForSupabaseSetupFailure(error, label) }; + } +} + +async function caller(supabase: Awaited>) { + const { data: { user }, error } = await supabase.auth.getUser(); + return error || !user ? null : user; +} + +export async function GET(_request: Request, context: RouteContext) { + const opened = await clients("GET /api/rectification/cases/[caseId]/source"); + if ("failure" in opened) return opened.failure; + const user = await caller(opened.supabase); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const { caseId } = await context.params; + if (!z.string().uuid().safeParse(caseId).success) { + return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 }); + } + const source = await findRectificationSource(opened.supabase, user.id, caseId).catch(() => null); + return NextResponse.json({ + source: source ? { sessionId: source.sessionId, question: source.question, returned: Boolean(source.returned) } : null, + }); +} + +export async function POST(_request: Request, context: RouteContext) { + const opened = await clients("POST /api/rectification/cases/[caseId]/source"); + if ("failure" in opened) return opened.failure; + const user = await caller(opened.supabase); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const { caseId } = await context.params; + if (!z.string().uuid().safeParse(caseId).success) { + return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 }); + } + try { + const result = await returnToRectificationSource({ + client: opened.supabase, + accounting: opened.accounting, + userId: user.id, + caseId, + }); + if (!result) { + return NextResponse.json({ error: "找不到这次校正对应的对话", code: "source_not_found" }, { status: 404 }); + } + return NextResponse.json({ sessionId: result.sessionId, record: result.record }); + } catch { + return NextResponse.json({ error: "暂时没能回到刚才的对话,请稍后再试", code: "return_failed" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/rectification/cases/open/route.ts b/frontend/src/app/api/rectification/cases/open/route.ts index 7e5e1008..8ab8b586 100644 --- a/frontend/src/app/api/rectification/cases/open/route.ts +++ b/frontend/src/app/api/rectification/cases/open/route.ts @@ -12,6 +12,7 @@ import { import { parseOpenRectificationCaseRequest } from "@/lib/rectification-agentic/v9/open-request"; import { prepareCaseSegmentChecks } from "@/lib/rectification-agentic/v9/segment-checks"; import { resolveRectificationProductDomain } from "@/lib/rectification-agentic/v9/product-domain"; +import { bindRectificationSource } from "@/lib/rectification-in-chat-server"; export const runtime = "nodejs"; @@ -78,6 +79,18 @@ export async function POST(request: Request) { if (response.shouldStartOpening) { await prepareCaseSegmentChecks({ accounting: accounting as never, userId: user.id, caseId: response.caseId }); } + // C4 (BUG-1249): a case started from a consultation's offer card is bound + // to that card, so the case can go back there. Best effort: the case is + // already open, and a failed bind only loses the way back. + if (parsed.intent === "homepage" && parsed.sourceSessionId) { + await bindRectificationSource({ + client: supabase, + userId: user.id, + sourceSessionId: parsed.sourceSessionId, + caseId: response.caseId, + rectificationSessionId: response.sessionId, + }).catch(() => false); + } return NextResponse.json(response); } catch (error) { return serviceErrorResponse(error); diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 2b4d75ef..eaba6f9b 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -6463,3 +6463,52 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .settings-modal { height: 100dvh; max-height: 100dvh; } } } + +/* Rectification reached from a consultation (BUG-1248/1250). The offer card + sits under the answer that offered it, at the same inset as the follow-ups; + the return divider is a quiet centred line, not a message; answers written + before the birth time changed get one caption above them. */ +.rectification-offer-card { + display: grid; + gap: var(--space-2); + justify-items: start; + margin: var(--space-2) 0 var(--space-3) var(--assistant-content-inset); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-canvas-muted); +} +.rectification-offer-card p { + margin: 0; + color: var(--color-ink-secondary); + font-size: var(--type-body-sm); + line-height: 1.6; +} +/* The divider's entry carries no actions row, so it needs no trailing gap of its own. */ +.message-entry--divider { + padding: 0; +} +.rectification-return-divider { + display: grid; + gap: var(--space-2); + justify-items: center; + margin: var(--space-4) 0; + text-align: center; +} +.rectification-return-divider__line { + margin: 0; + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + line-height: 1.5; +} +.message-before-change { + margin: 0 0 var(--space-1) var(--assistant-content-inset); + color: var(--color-ink-tertiary); + font-size: var(--type-caption); + line-height: 1.5; +} +.rectification-return-row { + display: flex; + justify-content: flex-start; + padding-bottom: var(--space-2); +} diff --git a/frontend/src/components/chat-transcript.tsx b/frontend/src/components/chat-transcript.tsx index 329311c1..30e75021 100644 --- a/frontend/src/components/chat-transcript.tsx +++ b/frontend/src/components/chat-transcript.tsx @@ -28,12 +28,20 @@ import { noteUnsplitListRender, } from "@/lib/home-streaming-render-probe"; import { memo, useEffect, useState, type MutableRefObject } from "react"; +import { + BeforeBirthTimeChangeLabel, + RectificationOfferCard, + RectificationReturnDivider, +} from "@/components/rectification-in-chat-cards"; +import { lastBirthTimeChangeIndex } from "@/lib/rectification-in-chat"; export type ChatTranscriptActions = Readonly<{ onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void; onCopy: (feedbackKey: string, text: string) => void; onRegenerate: (renderKey: string) => void; onFollowUp: (question: string) => void; + /** The offer card under an answer starts rectification for this consultation (BUG-1248). */ + onStartRectification: (sourceSessionId: string) => void; }>; export type ChatTranscriptProps = Readonly<{ @@ -89,15 +97,18 @@ function MessageEntry({ productEntrypointsDisabled, actionsRef, }: MessageEntryProps) { + // The return divider (BUG-1250) is a marker, not an answer: no row, no actions. + const returnRecord = message.rectificationReturn; const showActions = message.role === "assistant" && message.state === "settled" - && Boolean(message.text); + && Boolean(message.text) + && !returnRecord; const feedbackKey = `${sessionId}:${message.renderKey}`; // 不满意原因 opens right after a 👎 in this view; stored 👎 ratings do not reopen it. const [reasonOpen, setReasonOpen] = useState(false); const latestRegeneratableKey = !loading && !cancellationPending ? [...views].reverse().find((item) => ( - item.role === "assistant" && item.state === "settled" && Boolean(item.text) + item.role === "assistant" && item.state === "settled" && Boolean(item.text) && !item.rectificationReturn ))?.renderKey : undefined; const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : ""; @@ -112,8 +123,23 @@ function MessageEntry({ entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null, }) : []; + if (returnRecord) { + return ( +
+ actionsRef.current.onFollowUp(question)} + /> +
+ ); + } + // P2: answers written before the birth time changed keep their text and get one line above. + const beforeBirthTimeChange = message.role === "assistant" && lastBirthTimeChangeIndex(views) > index; + const offer = sessionType === "consultation" && message.state === "settled" ? message.rectificationOffer : undefined; return (
+ {beforeBirthTimeChange ? : null} {showActions && ( setReasonOpen(false)} /> )} + {offer ? ( + actionsRef.current.onStartRectification(sessionId)} + /> + ) : null} void; headerSlot: HTMLElement | null; onMessagesChange?: (messages: ChatMessage[]) => void; onCompleted?: () => void; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 3658ea49..59d2aa84 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -66,6 +66,9 @@ import { repairRectificationQuestion, } from "@/lib/rectification-chat-question-repair"; import { deriveRectificationChatView } from "@/lib/rectification-chat-view"; +import { useRectificationAutoReturn } from "@/hooks/use-rectification-return"; +import { RECTIFICATION_RETURN_BUTTON_LABEL } from "@/lib/rectification-in-chat"; +import { isTerminalRectificationStatus } from "@/lib/rectification-entry"; /** Same ceiling as the main chat composer, so both surfaces count down the same way. */ const RECTIFICATION_COMPOSER_MAX_LENGTH = 500; @@ -92,7 +95,9 @@ type RectificationAgenticChatProps = Readonly<{ onOpenBilling?: (options?: { source?: string }) => void; onSaved?: (time: string, status: "accepted" | "confirmed") => void; onOpeningConsumed?: () => void; - pendingConsultationQuestion?: string | null; + /** This case was started from a consultation that it can go back to (BUG-1250). */ + canReturnToSource?: boolean; + onReturnToSource?: () => void; onRestart?: () => void; headerSlot: HTMLElement | null; }>; @@ -124,7 +129,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onOpenBilling, onSaved, onOpeningConsumed, - pendingConsultationQuestion, + canReturnToSource = false, + onReturnToSource, onRestart, headerSlot, } = props; @@ -437,6 +443,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { questionRetryAttempts, savedTime, }); + useRectificationAutoReturn({ + enabled: canReturnToSource, + ready: questionGap === "verified_idle" || isTerminalRectificationStatus(caseStatus ?? ""), + busy, + onReturn: onReturnToSource, + }); // Same render-time store update as seededTurns: the lock must be current // before this paint (BUG-620). A ref write here trips react-hooks/refs. if (nextOfferLock !== selectionOfferLock) { @@ -552,11 +564,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { {resultNotice && canRecompare && !historyReadonly && ( )} - {pendingConsultationQuestion?.trim() && ( -

- 先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}” -

- )} {conversationState === "empty" && (

{RECTIFICATION_EMPTY_COPY}

@@ -645,6 +652,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { {conversationAnchor.latestBelowFold && ( )} + {canReturnToSource && ( +
+ +
+ )} void }>) { + if (offer.status === "returned") return null; + const opened = offer.status === "opened"; + return ( +
+

{opened ? RECTIFICATION_OFFER_OPENED_COPY : rectificationOfferCardCopy(offer)}

+ +
+ ); +} + +/** + * Where the reader came back from rectification (BUG-1250). Re-asking with the + * new time is a button and an ordinary consultation turn: nothing is sent, and + * nothing is charged, until it is clicked. + */ +export function RectificationReturnDivider({ + record, + disabled, + onRevisit, +}: Readonly<{ record: RectificationReturnRecord; disabled: boolean; onRevisit: (question: string) => void }>) { + return ( +
+

{rectificationReturnDividerText(record)}

+ {record.adopted ? ( + + ) : null} +
+ ); +} + +/** The small line above an answer written with the birth time before the change (P2). */ +export function BeforeBirthTimeChangeLabel() { + return

{RECTIFICATION_BEFORE_CHANGE_LABEL}

; +} diff --git a/frontend/src/components/starter-home.tsx b/frontend/src/components/starter-home.tsx index 0bc8a228..4e9ab6dc 100644 --- a/frontend/src/components/starter-home.tsx +++ b/frontend/src/components/starter-home.tsx @@ -1,8 +1,6 @@ "use client"; -import { Clock3, MoonStar } from "lucide-react"; -import { useCurrentSubject } from "@/lib/current-subject"; -import type { RectificationEntrySummary } from "@/lib/rectification-entry"; +import { MoonStar } from "lucide-react"; /** * The empty state is split across two slots on purpose. @@ -46,26 +44,14 @@ export function StarterGreeting({ starterGreeting, dailyTrend = null, dailyTrend ); } -/* The line under the pills only appears when there is something to do about - it: a non-self subject cannot start a rectification. A resumable case says - nothing here any more (2026-09-30, product: 「这个提示可以去掉」); the case - is still in the history list. */ -export function rectificationEntryHint(rectificationForSelf: boolean): string | null { - if (!rectificationForSelf) return "生时校正暂时只支持本人。"; - return null; -} - +/* The homepage has no 生时校正 pill any more (2026-10-07, BUG-1247): a + rectification starts from a consultation, when the agent offers it under an + answer. Old cases stay in the history list. */ export type StarterEntriesProps = { readonly natalMinuteAvailable: boolean; readonly dailyStarlanguageQuestion: string; readonly productEntrypointsDisabled: boolean; readonly startDailyStarlanguageConsultation: () => void; - readonly rectificationCardLabel: string; - readonly rectificationLoading: boolean; - readonly rectificationMutationPending: boolean; - readonly openRectificationFromHomepage: () => void; - /** Still passed by the page; the hint no longer reads it (2026-09-30). */ - readonly rectificationEntrySummary: RectificationEntrySummary | null; }; export function StarterEntries({ @@ -73,15 +59,8 @@ export function StarterEntries({ dailyStarlanguageQuestion, productEntrypointsDisabled, startDailyStarlanguageConsultation, - rectificationCardLabel, - rectificationLoading, - rectificationMutationPending, - openRectificationFromHomepage, }: StarterEntriesProps) { - const subjectId = useCurrentSubject(); - const rectificationForSelf = subjectId === "self"; const dailyLabel = natalMinuteAvailable ? "今日星语" : "每日运势"; - const rectificationHint = rectificationEntryHint(rectificationForSelf); return (
@@ -96,19 +75,7 @@ export function StarterEntries({
- {rectificationHint &&

{rectificationHint}

} {!natalMinuteAvailable && (

出生时间不足以支持的判断我会说明,不会补造具体分钟。

)} diff --git a/frontend/src/hooks/use-consultation-run.ts b/frontend/src/hooks/use-consultation-run.ts index 67de5def..b3c1ebcd 100644 --- a/frontend/src/hooks/use-consultation-run.ts +++ b/frontend/src/hooks/use-consultation-run.ts @@ -15,7 +15,6 @@ import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mo import { GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION, isGeneralDailyFortuneQuestion, - isRectificationHandoffQuestion, type ConsultationEntrypoint, } from "@/lib/consultation-entrypoint"; import { @@ -72,6 +71,7 @@ import { SESSION_NOT_CONSULTATION_CODE, } from "@/lib/rectification-session-composer-guard"; import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter"; +import type { RectificationOfferRecord } from "@/lib/rectification-in-chat"; import type { Account, AccountDialog, @@ -154,7 +154,6 @@ export type ConsultationRunParams = { /** Set once the redeem paywall has been shown on this page (TASK-birth-sky-polish-20260928 P4). */ onboardingPaywallShown?: MutableRefObject; setOnboardingPaywallOpen?: (open: boolean) => void; - openRectificationFromHomepage: (pendingConsultationQuestion?: string | null, sourceSessionId?: string | null) => Promise; openRectificationSession: (exactSessionId: string) => Promise | void; }; @@ -215,7 +214,6 @@ export function useConsultationRun(params: ConsultationRunParams) { openAccountDialog, onboardingPaywallShown, setOnboardingPaywallOpen, - openRectificationFromHomepage, openRectificationSession, } = params; // First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080). @@ -523,12 +521,12 @@ export function useConsultationRun(params: ConsultationRunParams) { ): Promise { const originalQuestion = text; const question = text.trim(); + // No text decides rectification any more (BUG-1247, C1): the agent offers + // it under an answer and the user starts it from that card. const consultEntrypoint = entrypoint - ?? (isRectificationHandoffQuestion(question) - ? "birth_time_rectification" as const - : isGeneralDailyFortuneQuestion(question) - ? "daily_starlanguage" as const - : null); + ?? (isGeneralDailyFortuneQuestion(question) + ? "daily_starlanguage" as const + : null); const resumeRequestId = options.resumeRequestId; const resuming = Boolean(resumeRequestId); if (!resuming && subjectDeleted) { @@ -588,33 +586,6 @@ export function useConsultationRun(params: ConsultationRunParams) { return false; } - if (consultEntrypoint === "birth_time_rectification") { - const previousUserQuestion = [...currentSession.messages] - .reverse() - .find((message) => message.role === "user") - ?.text - .trim() ?? null; - const pendingQuestion = previousUserQuestion && !isRectificationHandoffQuestion(previousUserQuestion) - ? previousUserQuestion - : null; - if (isUnsavedEmptyConsultation(currentSession)) { - try { - await persistSession(currentSession, "create"); - } catch (caught) { - setRequestError({ - sessionId: currentSession.id, - message: caught instanceof Error ? caught.message : "来源对话未能保存到云端。", - }); - return false; - } - } - await openRectificationFromHomepage(pendingQuestion, currentSession.id); - setDraft(""); - setDraftTheme(null); - setDraftEntrypoint(null); - return false; - } - const birthPlace = selectedBirthPlace(profile); if (!birthPlace) return false; @@ -902,6 +873,7 @@ export function useConsultationRun(params: ConsultationRunParams) { .filter((item) => item && item !== "none"), }; let agentExecutionReceipt: AgentExecutionReceipt | undefined; + let rectificationOffer: RectificationOfferRecord | undefined; let runCompleted = false; let truncatedFailure: Extract | undefined; const reader = response.body.getReader(); @@ -980,6 +952,7 @@ export function useConsultationRun(params: ConsultationRunParams) { } if (event.type === "run.completed") { runCompleted = true; + rectificationOffer = event.rectificationOffer; if (event.responseKind === "smalltalk") { responseKind = "smalltalk"; } else if (event.receipt) { @@ -1087,6 +1060,7 @@ export function useConsultationRun(params: ConsultationRunParams) { techniqueTruth, workflowReceipt, agentExecutionReceipt, + ...(rectificationOffer ? { rectificationOffer } : {}), }), }], updatedAt: timestamp(), diff --git a/frontend/src/hooks/use-rectification-return.ts b/frontend/src/hooks/use-rectification-return.ts new file mode 100644 index 00000000..b1c17f3d --- /dev/null +++ b/frontend/src/hooks/use-rectification-return.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +/** How long the last line stays on screen before going back (BUG-1250). */ +export const RECTIFICATION_AUTO_RETURN_DELAY_MS = 2_500; + +/** + * C5 (TASK-rectification-in-chat-step1-20261006, BUG-1250): a case started + * from a consultation goes back there by itself once it is done — the + * post-adopt check finished, or the case ended. Only a case the reader worked + * on in this view returns by itself: opening a finished case from history + * keeps it on screen (the reader came to look at it), and the manual + * 「回到刚才的对话」 button is always there while a source exists. + */ +export function useRectificationAutoReturn(input: { + enabled: boolean; + ready: boolean; + busy: boolean; + onReturn?: () => void; +}) { + const actedInView = useRef(false); + const fired = useRef(false); + const onReturn = useRef(input.onReturn); + useEffect(() => { + onReturn.current = input.onReturn; + }); + useEffect(() => { + if (input.busy) actedInView.current = true; + }, [input.busy]); + useEffect(() => { + if (!input.enabled || !input.ready || input.busy || !actedInView.current || fired.current) return; + const timer = window.setTimeout(() => { + fired.current = true; + onReturn.current?.(); + }, RECTIFICATION_AUTO_RETURN_DELAY_MS); + return () => window.clearTimeout(timer); + }, [input.enabled, input.ready, input.busy]); +} diff --git a/frontend/src/hooks/use-rectification-surface.ts b/frontend/src/hooks/use-rectification-surface.ts index 7ef0a15d..cbed7c63 100644 --- a/frontend/src/hooks/use-rectification-surface.ts +++ b/frontend/src/hooks/use-rectification-surface.ts @@ -3,7 +3,7 @@ import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react"; import { resolveSessionTitle } from "@/lib/agent-reply"; -import { fetchSessionDetail } from "@/lib/home-cloud-sync"; +import { fetchSessionDetail, mergeHydratedSession } from "@/lib/home-cloud-sync"; import { rectificationOpenIdentity } from "@/lib/rectification-session-open"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeSessionUrl } from "@/lib/chat-session-url"; @@ -44,6 +44,7 @@ import { type RectificationCaseSnapshotPayload, } from "@/lib/rectification-surface-state"; import { notifyError } from "@/lib/notify"; +import { bindOfferToCase } from "@/lib/rectification-in-chat"; export type RectificationShellState = { sessionId: string | null; @@ -167,7 +168,9 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { const setRectificationErrorSessionId = fieldSetter(setRectification, "errorSessionId"); const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState(null); - const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); + // The consultation this case was started from (BUG-1249/1250); null for + // cases started from history or before 2026-10-07. + const [rectificationSource, setRectificationSource] = useState<{ caseId: string; sessionId: string } | null>(null); const [rectificationReadonly, setRectificationReadonly] = useState(false); const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false); const [rectificationTurns, setRectificationTurns] = useState([]); @@ -204,7 +207,6 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { async function openRectificationCase( intent: "homepage" | "session" | "new", exactSessionId: string | null, - pendingConsultationQuestion: string | null, sourceSessionId: string | null = null, ) { if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current @@ -217,7 +219,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { sessionSelectionSource.current = "user"; setRectificationSessionId(null); setRectificationCaseId(null); - setRectificationPendingQuestion(null); + setRectificationSource(null); setOnboardingStep(missingStep); setComposerNotice("请先完成出生资料,再开始生时校正。"); return null; @@ -312,11 +314,22 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { // the reader never sees an empty transcript or an empty question area // while they load. Past the deadline the surface opens with what arrived // and its own retry path fills in the rest. + // The source lookup (BUG-1249) runs alongside the hydration, not after it. + const sourceRead = readRectificationSource(opened.caseId); const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, { timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS, }); + const source = await sourceRead; + if (sourceSessionId) { + // The server bound the offer card to this case; mirror it so the card + // under that answer stops offering a new start. + updateSession(sourceSessionId, (session) => { + const bound = bindOfferToCase(session.messages, { caseId: opened.caseId, rectificationSessionId: opened.sessionId }); + return bound ? { ...session, messages: bound as Message[] } : session; + }); + } - setRectificationPendingQuestion(pendingConsultationQuestion); + setRectificationSource(source ?? (sourceSessionId ? { caseId: opened.caseId, sessionId: sourceSessionId } : null)); setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); @@ -354,18 +367,55 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { return true; } - async function openRectificationFromHomepage( - pendingConsultationQuestion: string | null = null, - sourceSessionId: string | null = null, - ) { + /** C4: the offer card under a consultation answer starts (or resumes) the case. */ + async function openRectificationFromChat(sourceSessionId: string) { if (rectificationLimitedToSelf()) return; - await openRectificationCase("homepage", null, pendingConsultationQuestion, sourceSessionId); + await openRectificationCase("homepage", null, sourceSessionId); } async function openRectificationSession(exactSessionId: string) { await openRectificationCase("session", exactSessionId, null); } + async function readRectificationSource(caseId: string): Promise<{ caseId: string; sessionId: string } | null> { + try { + const response = await fetch(`/api/rectification/cases/${caseId}/source`, { cache: "no-store" }); + if (!response.ok) return null; + const payload = await response.json().catch(() => null) as { source?: { sessionId?: unknown } | null } | null; + const sessionId = payload?.source?.sessionId; + return typeof sessionId === "string" && sessionId ? { caseId, sessionId } : null; + } catch { + return null; + } + } + + /** + * C5/C6 (BUG-1250): the server appends the return divider to the source + * consultation once; then that consultation is reloaded from the server and + * made active, which closes the rectification surface. + */ + async function returnToRectificationSource() { + const source = rectificationSource; + if (!source || !modelCatalog) return; + try { + const response = await fetch(`/api/rectification/cases/${source.caseId}/source`, { method: "POST" }); + const payload = await response.json().catch(() => null) as { sessionId?: unknown; error?: unknown } | null; + if (!response.ok || typeof payload?.sessionId !== "string") { + notifyError(typeof payload?.error === "string" ? payload.error : "暂时没能回到刚才的对话,请稍后再试"); + return; + } + const sessionId = payload.sessionId; + const detailed = await fetchSessionDetail(sessionId, modelCatalog).catch(() => null); + if (detailed) setSessions((current) => mergeHydratedSession(current, detailed)); + void refreshAccount(); + activeSessionIdRef.current = sessionId; + setActiveSessionId(sessionId); + if (!uiPreview.current) writeSessionUrl(sessionId, "push"); + } catch { + notifyError("暂时没能回到刚才的对话,请稍后再试"); + } + } + async function reopenRectificationSession(exactSessionId: string) { setRectificationError(""); setRectificationErrorSessionId(null); @@ -381,7 +431,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { setRectificationError("profile_incomplete"); setRectificationSessionId(null); setRectificationCaseId(null); - setRectificationPendingQuestion(null); + setRectificationSource(null); const missingStep = missingProfileStep(profile); if (missingStep) { setOnboardingStep(missingStep); @@ -418,7 +468,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { shouldStartOpening: rectificationShouldStartOpening, initialTurns: rectificationTurns, initialSnapshot: rectificationSnapshot, - pendingConsultationQuestion: rectificationPendingQuestion, + canReturnToSource: Boolean(rectificationSource && rectificationSource.caseId === rectificationCaseId), + onReturnToSource: () => void returnToRectificationSource(), headerSlot: rectificationHeaderSlot, onMessagesChange: handleRectificationMessagesChange, onCompleted: () => { @@ -441,7 +492,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) { refreshRectificationEntrySummary, refreshRectificationCase, openRectificationCase, - openRectificationFromHomepage, + openRectificationFromChat, openRectificationSession, reopenRectificationSession, startNewRectification, diff --git a/frontend/src/lib/agent-reply.ts b/frontend/src/lib/agent-reply.ts index f82b509a..99ef3b8a 100644 --- a/frontend/src/lib/agent-reply.ts +++ b/frontend/src/lib/agent-reply.ts @@ -3,7 +3,6 @@ import { consultationDomainDefinition } from "./consultation-domain-registry.ts" import type { ConsultationEntrypoint } from "./consultation-entrypoint.ts"; import { isGeneralDailyFortuneQuestion, - isRectificationHandoffQuestion, } from "./consultation-entrypoint.ts"; import { shanghaiDateParts } from "./session-shanghai-clock.ts"; @@ -100,7 +99,7 @@ export function resolveSessionTitle( if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) { return datedSessionTitle(at, "今日节奏"); } - if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) { + if (options.entrypoint === "birth_time_rectification") { return "生时校正"; } const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, ""); diff --git a/frontend/src/lib/chat-message-view.ts b/frontend/src/lib/chat-message-view.ts index 1a7b3ddf..105b30b6 100644 --- a/frontend/src/lib/chat-message-view.ts +++ b/frontend/src/lib/chat-message-view.ts @@ -1,6 +1,7 @@ import type { AgentActivityTraceItem } from "./agent-activity-trace.ts"; import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts"; import type { PublicThinkingSection } from "./consultation-thinking-plan.ts"; +import type { RectificationOfferRecord, RectificationReturnRecord } from "./rectification-in-chat.ts"; import { consultationTimelineFromSettled, type ConsultationTimelineRow, @@ -62,6 +63,10 @@ export type ChatMessage = { readonly techniqueTruth?: string; readonly agentExecutionReceipt?: AgentExecutionReceipt; readonly workflowReceipt?: WorkflowReceipt; + /** This answer offered birth-time rectification; the card hangs under it (BUG-1248). */ + readonly rectificationOffer?: RectificationOfferRecord; + /** A divider written when the user came back from rectification (BUG-1250); never a model turn. */ + readonly rectificationReturn?: RectificationReturnRecord; }; export type ChatMessageView = ChatMessage & { @@ -85,7 +90,7 @@ export function settledChatMessageViews( ...message, renderKey: `message-${index}`, state: "settled" as const, - ...(message.role === "assistant" && message.responseKind !== "smalltalk" + ...(message.role === "assistant" && message.responseKind !== "smalltalk" && !message.rectificationReturn ? { timeline: consultationTimelineFromSettled(message) } : {}), })); diff --git a/frontend/src/lib/consultation-agent-events.ts b/frontend/src/lib/consultation-agent-events.ts index 79cc8581..4b3d4ce0 100644 --- a/frontend/src/lib/consultation-agent-events.ts +++ b/frontend/src/lib/consultation-agent-events.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts"; import { publicThinkingSectionSchema } from "./consultation-thinking-plan.ts"; +import { rectificationOfferRecordSchema } from "./rectification-in-chat.ts"; export const publicActivityPhaseSchema = z.enum([ // The first byte of a consultation stream, before the turn is classified or @@ -142,6 +143,8 @@ 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(), }).strict(); // A failure is the case the receipt is most needed for, so it carries the same // allowlisted receipt a completed run does. It stays optional because the diff --git a/frontend/src/lib/consultation-entrypoint.ts b/frontend/src/lib/consultation-entrypoint.ts index 063ff2a2..6638e6b5 100644 --- a/frontend/src/lib/consultation-entrypoint.ts +++ b/frontend/src/lib/consultation-entrypoint.ts @@ -35,11 +35,6 @@ export function isGeneralDailyFortuneQuestion(question: string): boolean { return /^(?:请帮我看一下)?今天的运势/.test(text) || /^今日运势/.test(text); } -export function isRectificationHandoffQuestion(question: string): boolean { - const text = compactQuestion(question); - return /^(?:先完成)?(?:出生时间校正|生时校正|生时矫正)(?:\s*[((]生时(?:校正|矫正)[))])?$/.test(text); -} - export function shouldLoadGeneralDailyPanchanga(input: Readonly<{ consultationMode?: ConsultationQuestionInput["consultationMode"]; entrypoint?: ConsultationEntrypoint; diff --git a/frontend/src/lib/consultation-session-history.ts b/frontend/src/lib/consultation-session-history.ts index 9ec9c2dd..474e2cce 100644 --- a/frontend/src/lib/consultation-session-history.ts +++ b/frontend/src/lib/consultation-session-history.ts @@ -1,3 +1,5 @@ +import { BIRTH_TIME_CHANGED_HISTORY_NOTE, isRectificationReturnMessage, lastBirthTimeChangeIndex } from "./rectification-in-chat.ts"; + export const CONSULTATION_HISTORY_LIMIT = 12; export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000; export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000; @@ -108,8 +110,12 @@ export function storedConsultationTurns( ): StoredTurn[] { if (!Array.isArray(messages)) return []; const rows: StoredTurn[] = []; + // C7 (BUG-1251): answers written before the birth time changed are read + // with a note, and the return divider itself is never a model turn. + const changedAt = lastBirthTimeChangeIndex(messages); messages.forEach((message, index) => { if (!message || typeof message !== "object") return; + if (isRectificationReturnMessage(message)) return; const stored = message as { role?: unknown; text?: unknown; requestId?: unknown; responseKind?: unknown }; if (options.excludeRequestId && stored.requestId === options.excludeRequestId) return; if (stored.role !== "user" && stored.role !== "assistant") return; @@ -117,7 +123,9 @@ export function storedConsultationTurns( rows.push({ index, role: stored.role, - text: stored.text, + text: stored.role === "assistant" && index < changedAt + ? `${BIRTH_TIME_CHANGED_HISTORY_NOTE}\n${stored.text}` + : stored.text, requestId: typeof stored.requestId === "string" ? stored.requestId : null, ...(stored.responseKind === "smalltalk" ? { responseKind: "smalltalk" as const } : {}), }); @@ -170,7 +178,15 @@ export function consultationHistoryWindow( droppedCount += 1; } const summaryText = summary?.text.trim() || null; - return { tail: kept, summaryText, droppedCount }; + // The summary only covers turns up to its index; when the birth time changed + // after that, its conclusions came from the earlier time too (C7). + const summaryBeforeChange = summary && Array.isArray(messages) + && summary.throughMessageIndex < lastBirthTimeChangeIndex(messages); + return { + tail: kept, + summaryText: summaryText && summaryBeforeChange ? `${BIRTH_TIME_CHANGED_HISTORY_NOTE}\n${summaryText}` : summaryText, + droppedCount, + }; } export function consultationHistoryFromStoredMessages( diff --git a/frontend/src/lib/consultation-thinking-plan.ts b/frontend/src/lib/consultation-thinking-plan.ts index 6413724a..ced5192a 100644 --- a/frontend/src/lib/consultation-thinking-plan.ts +++ b/frontend/src/lib/consultation-thinking-plan.ts @@ -444,6 +444,7 @@ export function natalAnswerShapeBody(): string { "正文写成段落,段落之间空一行(单个换行在界面上不分段)。只有用户要求列举,或确有三个以上候选时间窗时才用列表,且不用加粗标签加一句的格式。", "首轮正文不写 ##,不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。", PLAIN_SPEECH_RULE, + RECTIFICATION_OFFER_RULE, "把每句话括号里的内容删掉,不懂占星的人也要能看懂:不起「外X内Y」这类格局名,不把大运写成推着或收尾的角色,不写「去当某颗星的样子」,不把星名当形容词的主人;空宫不单独下结论,要说它的宫主落在哪。名词只用中文、全篇一个叫法(罗睺、计都;大运、子运、小运),不写英文或梵文音译(sade sati、vargottama、rupas 之类),力量只说强、中、弱。", "不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。", ].join(""); @@ -453,6 +454,13 @@ export function natalAnswerShapeInstruction(): string { return `${ANSWER_FROM_EVIDENCE}${natalAnswerShapeBody()}`; } +/** + * 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. + */ +export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、或明确想核对出生时间,才在写回答之前调用 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 = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。"; @@ -470,6 +478,7 @@ export function followUpAnswerShapeInstruction(): string { "这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;名词只用中文、不写英文或梵文音译;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;读心按人设里的读心三条:答完再说、用「可能」「多半」、一句话,不把用户的问题改写成需求,不替用户总结他想要什么。", PLAIN_SPEECH_RULE, FOLLOW_UP_CORRECTION_RULE, + RECTIFICATION_OFFER_RULE, CAREER_FOLLOW_UP_RULE, "只有用户明确换了领域或要求完整看一下,才回到首轮形状:几段人话,多人就分段落、段首点名,不写二级标题。", "不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。", diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index a630da23..9b1d70e3 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -1,3 +1,4 @@ +import { parseRectificationOfferRecord, parseRectificationReturnRecord } from "@/lib/rectification-in-chat"; import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry"; import { BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_READ_INIT, withBootFallback } from "@/lib/boot-early-reads"; import { writeChatSession } from "@/lib/chat-session-write-contract"; @@ -254,6 +255,17 @@ export function readSessionListPage(value: unknown): SessionListPage { }; } +/** The offer card / return divider of rectification reached from a consultation (BUG-1248/1250). */ +function rectificationLinkFields(stored: Message): Partial> { + if (stored.role !== "assistant") return {}; + const offer = parseRectificationOfferRecord(stored.rectificationOffer); + const returned = parseRectificationReturnRecord(stored.rectificationReturn); + return { + ...(offer ? { rectificationOffer: offer } : {}), + ...(returned ? { rectificationReturn: returned } : {}), + }; +} + export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult { if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] }; const fallbackSessionIds: string[] = []; @@ -291,6 +303,7 @@ export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog ...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}), ...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}), ...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}), + ...rectificationLinkFields(stored), }]; }) : []; diff --git a/frontend/src/lib/rectification-entry.ts b/frontend/src/lib/rectification-entry.ts index fc135d8e..8df0a855 100644 --- a/frontend/src/lib/rectification-entry.ts +++ b/frontend/src/lib/rectification-entry.ts @@ -20,20 +20,6 @@ export type RectificationEntrySummary = Readonly<{ }> | null; }>; -export type RectificationCardAction = "start" | "restart"; - -export const rectificationEntryLabels: Readonly> = { - start: "开始新的生时校正", - restart: "再次校正", -}; - -export function resolveRectificationEntryAction( - summary: RectificationEntrySummary, -): RectificationCardAction { - if (summary.hasTerminalCaseWithTime) return "restart"; - return "start"; -} - export type OpenRectificationDisposition = "created" | "resumed" | "readonly"; export type OpenRectificationCaseResponse = Readonly<{ diff --git a/frontend/src/lib/rectification-in-chat-server.ts b/frontend/src/lib/rectification-in-chat-server.ts new file mode 100644 index 00000000..bcf34326 --- /dev/null +++ b/frontend/src/lib/rectification-in-chat-server.ts @@ -0,0 +1,134 @@ +import "server-only"; + +import type { SupabaseClient } from "@supabase/supabase-js"; +import { resolveFeaturePricing } from "@/lib/feature-pricing"; +import { isProductEnabled } from "@/lib/product-access"; +import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service"; +import { loadV9CaseDossier } from "@/lib/rectification-agentic/v9/tool-service"; +import { + appendRectificationReturn, + bindOfferToCase, + decideRectificationOfferWith, + offerForCase, + sourceFromSessionRows, + type RectificationOfferDecision, + type RectificationReturnRecord, + type RectificationSource, +} from "@/lib/rectification-in-chat"; + +/** + * Server side of rectification reached from a consultation (BUG-1247~1251). + * Reads and writes the consultation session's messages with the caller's own + * client (owner RLS; `messages` is an owner-updatable column), never the + * admin client. + */ + +/** C3: whether the offer card may be shown, decided by the server and never by the model. */ +export async function decideRectificationOffer(input: { + accounting: SupabaseClient; + userId: string; + subjectRole: "self" | "other"; + modelId: string; +}): Promise { + return decideRectificationOfferWith({ + subjectRole: input.subjectRole, + productEnabled: () => isProductEnabled("rectification"), + hasResumableCase: async () => (await getRectificationEntrySummary(input.accounting, input.userId)).hasResumableCase, + creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost, + }); +} + +type SessionRow = { id: string; messages: unknown; session_type?: string | null }; + +async function readConsultationSession(client: SupabaseClient, userId: string, sessionId: string): Promise { + const { data, error } = await client + .from("chat_sessions") + .select("id,messages,session_type") + .eq("id", sessionId) + .eq("user_id", userId) + .maybeSingle(); + if (error || !data) return null; + const row = data as SessionRow; + return (row.session_type ?? "consultation") === "consultation" ? row : null; +} + +async function writeMessages(client: SupabaseClient, userId: string, sessionId: string, messages: unknown[]): Promise { + const { error } = await client + .from("chat_sessions") + .update({ messages }) + .eq("id", sessionId) + .eq("user_id", userId); + return !error; +} + +/** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */ +export async function bindRectificationSource(input: { + client: SupabaseClient; + userId: string; + sourceSessionId: string; + caseId: string; + rectificationSessionId: string; +}): Promise { + const row = await readConsultationSession(input.client, input.userId, input.sourceSessionId); + if (!row) return false; + const next = bindOfferToCase(row.messages, { caseId: input.caseId, rectificationSessionId: input.rectificationSessionId }); + if (!next) return Boolean(offerForCase(row.messages, input.caseId)); + return writeMessages(input.client, input.userId, row.id, next); +} + +/** How many recent consultations are searched for the session that started a case. */ +const SOURCE_SEARCH_LIMIT = 20; + +/** + * The consultation that started this case. Binding writes that session, so it + * is among the most recently updated ones; a case started from the homepage or + * from history has none. + */ +export async function findRectificationSource( + client: SupabaseClient, + userId: string, + caseId: string, +): Promise { + const { data, error } = await client + .from("chat_sessions") + .select("id,messages,session_type") + .eq("user_id", userId) + .eq("session_type", "consultation") + .order("updated_at", { ascending: false }) + .limit(SOURCE_SEARCH_LIMIT); + if (error || !Array.isArray(data)) return null; + return sourceFromSessionRows(data as SessionRow[], caseId); +} + +/** + * C5/C6: going back appends one divider to the consultation (idempotent) and + * marks the offer returned. Whether the time changed is read from the case, + * never from the browser. + */ +export async function returnToRectificationSource(input: { + client: SupabaseClient; + accounting: SupabaseClient; + userId: string; + caseId: string; + now?: () => Date; +}): Promise<(RectificationSource & { record: RectificationReturnRecord }) | null> { + const source = await findRectificationSource(input.client, input.userId, input.caseId); + if (!source) return null; + if (source.returned) return { ...source, record: source.returned }; + const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + const accepted = dossier.case.acceptedTime?.slice(0, 5) ?? null; + const row = await readConsultationSession(input.client, input.userId, source.sessionId); + if (!row) return null; + const appended = appendRectificationReturn(row.messages, { + caseId: input.caseId, + adopted: Boolean(accepted), + clock: accepted, + returnedAt: (input.now?.() ?? new Date()).toISOString(), + }); + if (!appended) { + const again = await findRectificationSource(input.client, input.userId, input.caseId); + return again?.returned ? { ...again, record: again.returned } : null; + } + if (!await writeMessages(input.client, input.userId, row.id, appended.messages)) return null; + return { ...source, returned: appended.record, record: appended.record }; +} diff --git a/frontend/src/lib/rectification-in-chat.ts b/frontend/src/lib/rectification-in-chat.ts new file mode 100644 index 00000000..eb851371 --- /dev/null +++ b/frontend/src/lib/rectification-in-chat.ts @@ -0,0 +1,253 @@ +/** + * Birth-time rectification reached from an ordinary consultation + * (TASK-rectification-in-chat-step1-20261006, BUG-1247~1251). + * + * The link between a consultation and the rectification it started lives in + * the consultation session's own messages, so no table changes: + * - the assistant answer that offered rectification carries `rectificationOffer` + * (written with the answer by `complete_consultation_response`), which the + * server moves to `opened` (with the case id) when the user starts it and to + * `returned` when the user comes back; + * - coming back appends one divider message carrying `rectificationReturn`. + * The divider is never a model turn; earlier answers get a note when the birth + * time changed (C7). Shared by the server and the browser; no server imports. + */ +import { z } from "zod"; + +export const RECTIFICATION_OFFER_TOOL_ID = "offer-birth-time-rectification"; + +/** Rough number of questions a case asks before it can deliver (offline replay averaged 7.8). */ +export const RECTIFICATION_OFFER_QUESTION_ESTIMATE = 8; + +export const rectificationOfferRecordSchema = z.object({ + status: z.enum(["offered", "opened", "returned"]), + creditCost: z.number().int().positive().max(1000), + questionEstimate: z.number().int().positive().max(50), + /** The question to look at again with the new time. */ + sourceQuestion: z.string().trim().min(1).max(500), + offeredAt: z.string().min(1).max(40), + caseId: z.string().uuid().optional(), + rectificationSessionId: z.string().uuid().optional(), +}).strict(); + +export type RectificationOfferRecord = z.infer; + +export const rectificationReturnRecordSchema = z.object({ + caseId: z.string().uuid(), + adopted: z.boolean(), + /** HH:MM when adopted, otherwise null. */ + clock: z.string().regex(/^\d{2}:\d{2}$/).nullable(), + sourceQuestion: z.string().trim().min(1).max(500), + returnedAt: z.string().min(1).max(40), +}).strict(); + +export type RectificationReturnRecord = z.infer; + +export function parseRectificationOfferRecord(value: unknown): RectificationOfferRecord | null { + const parsed = rectificationOfferRecordSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +export function parseRectificationReturnRecord(value: unknown): RectificationReturnRecord | null { + const parsed = rectificationReturnRecordSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +/** C3: the server's answer to "may the offer card be shown". */ +export type RectificationOfferDecision = + | Readonly<{ ok: true; creditCost: number; questionEstimate: number }> + | Readonly<{ ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }>; + +// ---- copy (VOICE.md「生时校正并入对话」) ---- + +export function rectificationOfferCardCopy(offer: Pick): string { + return `可以用几道过去经历的题,核对一下你的出生时间。大约 ${offer.questionEstimate} 题,会用掉 ${offer.creditCost} 点;开始答第一题时才扣。`; +} + +export const RECTIFICATION_OFFER_START_LABEL = "开始核对出生时间"; +export const RECTIFICATION_OFFER_CONTINUE_LABEL = "继续核对出生时间"; +export const RECTIFICATION_OFFER_OPENED_COPY = "出生时间核对已经开始。"; +export const RECTIFICATION_RETURN_BUTTON_LABEL = "回到刚才的对话"; +export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间"; + +export function rectificationReturnDividerText(record: Pick): string { + return record.adopted && record.clock + ? `出生时间改为 ${record.clock}(生时校正)` + : "出生时间没有改"; +} + +export function rectificationRevisitLabel(question: string): string { + const clipped = question.length > 24 ? `${question.slice(0, 24)}…` : question; + return `按新时间重新看:「${clipped}」`; +} + +/** C7: the note the model reads in front of answers written with the earlier birth time. */ +export const BIRTH_TIME_CHANGED_HISTORY_NOTE = "(以下回答按校正前的出生时间,不要沿用其中的盘面判断)"; + +// ---- pure helpers over a stored messages array ---- + +type StoredMessage = Record; + +function asMessages(value: unknown): StoredMessage[] | null { + if (!Array.isArray(value)) return null; + return value.map((item) => (item && typeof item === "object" && !Array.isArray(item) ? item as StoredMessage : {})); +} + +export function isRectificationReturnMessage(message: unknown): boolean { + return Boolean(message && typeof message === "object" + && parseRectificationReturnRecord((message as StoredMessage).rectificationReturn)); +} + +/** Any offer, in any state, in this session (C2: offer at most once unless the user asks). */ +export function sessionHasRectificationOffer(messages: unknown): boolean { + return (asMessages(messages) ?? []).some((message) => parseRectificationOfferRecord(message.rectificationOffer)); +} + +/** + * The question to look at again: the latest user question before the turn + * that offered rectification, or that turn's own question when it is the + * first one. `excludeRequestId` skips the in-flight turn's stored question. + */ +export function revisitQuestionForOffer(messages: unknown, currentQuestion: string, excludeRequestId?: string): string { + const stored = asMessages(messages) ?? []; + for (let index = stored.length - 1; index >= 0; index -= 1) { + const message = stored[index]!; + if (excludeRequestId && message.requestId === excludeRequestId) continue; + if (message.role === "user" && typeof message.text === "string" && message.text.trim()) { + return message.text.trim().slice(0, 500); + } + } + return currentQuestion.trim().slice(0, 500); +} + +/** + * Marks the latest still-offered card as opened for this case. Returns the + * new array, or null when there is nothing to bind (no offer, or this case is + * already bound). + */ +export function bindOfferToCase( + messages: unknown, + binding: { caseId: string; rectificationSessionId: string }, +): StoredMessage[] | null { + const stored = asMessages(messages); + if (!stored) return null; + if (stored.some((message) => parseRectificationOfferRecord(message.rectificationOffer)?.caseId === binding.caseId)) { + return null; + } + for (let index = stored.length - 1; index >= 0; index -= 1) { + const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer); + if (!offer || offer.status !== "offered") continue; + const next = [...stored]; + next[index] = { + ...stored[index], + rectificationOffer: { ...offer, status: "opened", caseId: binding.caseId, rectificationSessionId: binding.rectificationSessionId }, + }; + return next; + } + return null; +} + +/** 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) ?? []) { + const offer = parseRectificationOfferRecord(message.rectificationOffer); + if (offer?.caseId === caseId) return offer; + } + return null; +} + +/** + * Appends the return divider once per case and marks its offer returned. + * Returns null when the divider is already there (idempotent) or the session + * does not hold an offer for this case. + */ +export function appendRectificationReturn( + messages: unknown, + input: { caseId: string; adopted: boolean; clock: string | null; returnedAt: string }, +): { messages: StoredMessage[]; record: RectificationReturnRecord } | null { + const stored = asMessages(messages); + if (!stored) return null; + const offer = offerForCase(stored, input.caseId); + if (!offer) return null; + if (stored.some((message) => parseRectificationReturnRecord(message.rectificationReturn)?.caseId === input.caseId)) { + return null; + } + const record: RectificationReturnRecord = { + caseId: input.caseId, + adopted: input.adopted && Boolean(input.clock), + clock: input.adopted ? input.clock : null, + sourceQuestion: offer.sourceQuestion, + returnedAt: input.returnedAt, + }; + const marked = stored.map((message) => { + const current = parseRectificationOfferRecord(message.rectificationOffer); + return current?.caseId === input.caseId + ? { ...message, rectificationOffer: { ...current, status: "returned" } } + : message; + }); + return { + messages: [...marked, { role: "assistant", text: rectificationReturnDividerText(record), rectificationReturn: record }], + record, + }; +} + +/** Index of the last divider that changed the birth time, or -1. */ +export function lastBirthTimeChangeIndex(messages: readonly unknown[]): number { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + const record = message && typeof message === "object" + ? parseRectificationReturnRecord((message as StoredMessage).rectificationReturn) + : null; + if (record?.adopted) return index; + } + return -1; +} + +/** + * C3: the offer decision with its reads injected (the server wires the real + * product switch, entry summary and feature pricing). Order matters: a + * non-self subject never reaches the database. + */ +export async function decideRectificationOfferWith(input: { + subjectRole: "self" | "other"; + productEnabled: () => Promise; + hasResumableCase: () => Promise; + creditCost: () => Promise; + questionEstimate?: number; +}): Promise { + if (input.subjectRole !== "self") return { ok: false, reason: "not_self" }; + if (!await input.productEnabled().catch(() => false)) return { ok: false, reason: "product_disabled" }; + // An unreadable summary does not block the offer: opening the case re-checks + // for a conflict and says so. + if (await input.hasResumableCase().catch(() => false)) return { ok: false, reason: "case_in_progress" }; + try { + const creditCost = await input.creditCost(); + if (!Number.isInteger(creditCost) || creditCost <= 0) return { ok: false, reason: "pricing_unavailable" }; + return { ok: true, creditCost, questionEstimate: input.questionEstimate ?? RECTIFICATION_OFFER_QUESTION_ESTIMATE }; + } catch { + return { ok: false, reason: "pricing_unavailable" }; + } +} + +export type RectificationSource = Readonly<{ + sessionId: string; + question: string; + returned: RectificationReturnRecord | null; +}>; + +/** The consultation (among the given rows) whose offer is bound to this case. */ +export function sourceFromSessionRows( + rows: ReadonlyArray<{ id: string; messages: unknown }>, + caseId: string, +): RectificationSource | null { + for (const row of rows) { + const offer = offerForCase(row.messages, caseId); + if (!offer) continue; + const returned = (asMessages(row.messages) ?? []) + .map((message) => parseRectificationReturnRecord(message.rectificationReturn)) + .find((record) => record?.caseId === caseId) ?? null; + return { sessionId: row.id, question: offer.sourceQuestion, returned }; + } + return null; +} + diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 4260ad9b..f056cd70 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -27,7 +27,6 @@ import { export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS; /** Static entry copy while `/cases/open` and hydration are in flight. No spinner. */ -export const RECTIFICATION_OPENING_LABEL = "正在打开…"; export const RECTIFICATION_SIDEBAR_OPENING_NOTE = "打开中"; /** Question recovery: one immediate refetch after a turn, then this many timed retries. */ diff --git a/frontend/src/lib/stream-agent-response.ts b/frontend/src/lib/stream-agent-response.ts index 0713cc3d..7a423dae 100644 --- a/frontend/src/lib/stream-agent-response.ts +++ b/frontend/src/lib/stream-agent-response.ts @@ -313,7 +313,11 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable consultationAgentPublicEventSchema.parse(event)); } @@ -1106,7 +1110,11 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) { settled = true; settling = false; await sideEvent; - send(controller, { type: "run.completed", receipt }); + send(controller, { + type: "run.completed", + receipt, + ...(options.state.rectificationOffer ? { rectificationOffer: options.state.rectificationOffer } : {}), + }); if (!disconnected) controller.close(); } catch (error) { if (settled) return; diff --git a/frontend/src/mastra/consultation-tools.ts b/frontend/src/mastra/consultation-tools.ts index fd526756..0f018e92 100644 --- a/frontend/src/mastra/consultation-tools.ts +++ b/frontend/src/mastra/consultation-tools.ts @@ -1,3 +1,8 @@ +import { + RECTIFICATION_OFFER_TOOL_ID, + type RectificationOfferDecision, + type RectificationOfferRecord, +} from "../lib/rectification-in-chat.ts"; import { isDeepStrictEqual } from "node:util"; import { createTool } from "@mastra/core/tools"; import { z } from "zod"; @@ -269,6 +274,8 @@ export type ConsultationRuntimeState = { * still has to state which method a receipt describes. */ jyotishSkillBound: boolean; + /** Set when this turn's answer offered birth-time rectification (BUG-1248); persisted with the answer. */ + rectificationOffer?: RectificationOfferRecord; skillReferenceReadCount: number; methodologySectionCount: number; consultationToolStarted: boolean; @@ -439,6 +446,20 @@ export function consultationStepBudgetReceipt(state: ConsultationRuntimeState) { }; } +/** + * C2/C3 (TASK-rectification-in-chat-step1-20261006, BUG-1248): what the offer + * tool needs from the route. `decide` is the server check (self, product on, + * no case in progress, price); `alreadyOffered` comes from this session's + * stored messages; `revisitQuestion` is the question to look at again with + * the new time. + */ +export type RectificationOfferHook = Readonly<{ + decide: () => Promise; + alreadyOffered: boolean; + revisitQuestion: string; + now?: () => Date; +}>; + export type ConsultationAgentContext = Readonly<{ userId: string; sessionId: string; @@ -455,6 +476,7 @@ export type ConsultationAgentContext = Readonly<{ serverChart: ServerChartConsultation; abortSignal?: AbortSignal; state: ConsultationRuntimeState; + rectificationOffer?: RectificationOfferHook; runWorkflow?: typeof runConsultationWorkflow; // The domain loop budgets itself against this clock, so tests can drive the // deadline without waiting for it. @@ -475,6 +497,7 @@ export type WindowConsultationAgentContext = Readonly<{ declaredWindow: DeclaredBirthWindowConsultation; abortSignal?: AbortSignal; state: ConsultationRuntimeState; + rectificationOffer?: RectificationOfferHook; now?: () => number; fetchWindowChart?: typeof fetchDeclaredWindowChart; runRangeReading?: typeof runV9RangeReading; @@ -1015,6 +1038,46 @@ const evidenceLookupInputSchema = z.object({ section: z.enum(EVIDENCE_LOOKUP_SECTIONS), }).strict(); +const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly["reason"], string>> = { + not_self: "问的不是用户本人的盘,生时校正暂时只支持本人。正文用一句话照实说明(例:「给家人校正暂时还不支持」),不出卡。", + product_disabled: "生时校正暂时没有开放。正文不要提校正。", + case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。", + pricing_unavailable: "生时校正这会儿提供不了。正文不要提校正。", +}; + +/** + * 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. + */ +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(), + 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 (hook.alreadyOffered && input.userAsked !== true) { + return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" }; + } + const decision = await hook.decide(); + 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(), + }; + return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" }; + }, + }); +} + export function createConsultationTools(ctx: ConsultationAgentContext) { let calculation: Promise | null = null; // Request-scoped: filled when this request's calculation finishes, read by @@ -1216,6 +1279,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) { return { "run-jyotish-consultation": consultationTool, [CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID]: lookupTool, + ...(ctx.rectificationOffer ? { [RECTIFICATION_OFFER_TOOL_ID]: createRectificationOfferTool(ctx) } : {}), }; } @@ -1375,7 +1439,10 @@ export function createWindowConsultationTools(ctx: WindowConsultationAgentContex }).strict(), execute: async (input, context) => executeWindowConsultation(ctx, input, context as WindowToolExecuteContext), }); - return { "run-jyotish-window-consultation": windowTool }; + return { + "run-jyotish-window-consultation": windowTool, + ...(ctx.rectificationOffer ? { [RECTIFICATION_OFFER_TOOL_ID]: createRectificationOfferTool(ctx) } : {}), + }; } /** diff --git a/frontend/tests/composer-isolation-contract.test.ts b/frontend/tests/composer-isolation-contract.test.ts index 0ae71e58..5691ec7d 100644 --- a/frontend/tests/composer-isolation-contract.test.ts +++ b/frontend/tests/composer-isolation-contract.test.ts @@ -130,7 +130,8 @@ test("every external draft writer keeps working through the page-owned setters", assert.match(pageSource, /void send\(composerDraftSnapshot\(\), draftTheme\.current \?\? undefined, draftEntrypoint\.current\)/); // And: opening a rectification case still empties the composer it replaces. - const openRectification = sourceBetween(pageSource, "async function openRectificationCase", "async function openRectificationFromHomepage"); + // 原值: 截到 openRectificationFromHomepage 新值: 截到 openRectificationFromChat 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + const openRectification = sourceBetween(pageSource, "async function openRectificationCase", "async function openRectificationFromChat"); assert.match(openRectification, /setDraft\(""\)/); }); diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 5ce39cb2..80592438 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -5,7 +5,6 @@ import { GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION, consultationEntrypointSchema, isGeneralDailyFortuneQuestion, - isRectificationHandoffQuestion, pinsConsultationDomains, resolveConsultationQuestion, shouldLoadGeneralDailyPanchanga, @@ -94,12 +93,14 @@ test("ordinary-session daily fortune without an entrypoint still uses the public assert.match(resolved.modelQuestion, /公共 Panchanga/); }); -test("rectification labels in ordinary sessions are handoffs, not consult questions", () => { - assert.equal(isRectificationHandoffQuestion("出生时间校正 (生时校正)"), true); - assert.equal(isRectificationHandoffQuestion("出生时间校正 (生时矫正)"), true); - assert.equal(isRectificationHandoffQuestion("先完成生时校正"), true); - assert.equal(isRectificationHandoffQuestion("生时校正是什么"), false); - assert.equal(isRectificationHandoffQuestion("未来半年是否适合换工作?"), false); +test("rectification labels in ordinary sessions are handoffs, not consult questions", async () => { + // 原值: isRectificationHandoffQuestion 按文字把「生时校正」「先完成生时校正」判成交接 + // 新值: 文字口令删除;普通对话里的「生时校正」由 agent 判断、经提议卡进入(C1,不用正则) + // 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + const entrypoint = await import("../src/lib/consultation-entrypoint.ts"); + assert.equal("isRectificationHandoffQuestion" in entrypoint, false); + const consultRun = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8"); + assert.doesNotMatch(consultRun, /isRectificationHandoffQuestion|openRectificationFromHomepage/); }); test("birth-time entrypoint selects a private server expansion", () => { @@ -153,10 +154,11 @@ test("ordinary product drafts keep the public question and clear hidden routing assert.match(source, /从今日问起/); assert.match(source, /深入看今日/); assert.match(source, /startSuggestedConsultation\(\s*dailyStarlanguageQuestion,\s*"timing",\s*"daily_starlanguage"/); - assert.match(source, /consultEntrypoint === "birth_time_rectification"/); - assert.match(source, /isRectificationHandoffQuestion\(question\)/); - // 原值:仅 pendingQuestion;新值:附 currentSession.id;原因:服务端读取 owned 持久主题,浏览器不传领域。 - assert.match(source, /openRectificationFromHomepage\(pendingQuestion, currentSession\.id\)/); + // 原值: 发送时 consultEntrypoint === "birth_time_rectification"(文字口令)→ openRectificationFromHomepage(pendingQuestion, currentSession.id) + // 新值: 发送路径不再开校正;提议卡 → openRectificationFromChat(sourceSessionId) + // 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + assert.doesNotMatch(source, /isRectificationHandoffQuestion\(question\)/); + assert.match(source, /openRectificationFromChat\(sourceSessionId\)/); assert.doesNotMatch(source, /personalChartAvailable \? "daily_starlanguage" : null/); assert.match(source, /messages: questionAlreadyPresent \? preservedMessages : \[\.\.\.preservedMessages, \{ role: "user", text: question \}\]/); assert.match(source, /body:\s*JSON\.stringify\(\{[\s\S]*?entrypoint:\s*consultEntrypoint \?\? undefined,[\s\S]*?question,/); @@ -172,13 +174,14 @@ test("homepage birth-time card opens the V9 Agentic surface via the server case const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); assert.match(source, /function openRectificationCase/); - assert.match(source, /openRectificationFromHomepage/); + // 原值(2): /openRectificationFromHomepage/ 与 panel 里的 pendingConsultationQuestion: rectificationPendingQuestion + // 新值(2): openRectificationFromChat;panel 带 canReturnToSource / onReturnToSource(回到来源对话,C5) + // 原因(2): BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入;原问题改由来源对话里的分隔线和「按新时间重新看」承接(BUG-1250) + assert.match(source, /openRectificationFromChat/); assert.match(source, //); - // 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX - // 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel - // 原因:校正面子树状态收到 panel,Home 不再逐个传 - assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/); + assert.match(source, /canReturnToSource: Boolean\(rectificationSource/); + assert.doesNotMatch(source, /pendingConsultationQuestion/); assert.doesNotMatch(source, /chooseSuggestedQuestion\([\s\S]{0,180}"birth_time_rectification"/); assert.doesNotMatch(source, /draftBirthTimeRectificationQuestion/); }); @@ -197,7 +200,8 @@ test("homepage mounts the Agentic surface without invoking retired rectification test("homepage opens through the server Case API and merges the returned session", () => { const source = homeSurface; const start = source.indexOf("async function openRectificationCase"); - const end = source.indexOf("async function openRectificationFromHomepage", start); + // 原值: 截到 openRectificationFromHomepage 新值: 截到 openRectificationFromChat 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + const end = source.indexOf("async function openRectificationFromChat", start); const handler = source.slice(start, end); const open = handler.indexOf('/api/rectification/cases/open'); const merge = handler.indexOf('setSessions((current) => [merged,'); @@ -222,7 +226,8 @@ test("the page never creates the session shell locally; the server owns session const page = homeSurface; const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8"); const start = page.indexOf("async function openRectificationCase"); - const end = page.indexOf("async function openRectificationFromHomepage", start); + // 原值: 截到 openRectificationFromHomepage 新值: 截到 openRectificationFromChat 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + const end = page.indexOf("async function openRectificationFromChat", start); const handler = page.slice(start, end); assert.doesNotMatch(handler, /persistSession\(rectificationSession, "create"\)/); @@ -264,7 +269,10 @@ test("homepage creation and sidebar selection resolve through distinct server in assert.match(page, /openRectificationCase\(\"session\", exactSessionId, null\)/); // 原值:homepage只传pendingQuestion;新值:附sourceSessionId;原因:owned来源主题由服务端读取,session/new意图不变。 - assert.match(page, /openRectificationCase\(\"homepage\", null, pendingConsultationQuestion, sourceSessionId\)/); + // 原值(2): openRectificationCase("homepage", null, pendingConsultationQuestion, sourceSessionId) + // 新值(2): openRectificationCase("homepage", null, sourceSessionId) + // 原因(2): BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + assert.match(page, /openRectificationCase\(\"homepage\", null, sourceSessionId\)/); assert.match(page, /openRectificationCase\(\"new\", null, null\)/); assert.doesNotMatch(page, /sourceSession\.sessionType === "birth_time_rectification"/); assert.doesNotMatch(page, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/); @@ -289,7 +297,10 @@ test("an answered conversation offers no suggested follow-up questions", () => { assert.doesNotMatch(source, /readSuggestions/); // And: rectification is still reachable without the chip that used to hand off to it. - assert.match(source, /onClick=\{\(\) => void openRectificationFromHomepage\(\)\}/); + // 原值: 首页 pill onClick={() => void openRectificationFromHomepage()} + // 新值: 对话里的提议卡 → actionsRef.current.onStartRectification(sessionId) + // 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + assert.match(transcript, /actionsRef\.current\.onStartRectification\(sessionId\)/); }); test("rectify-first handoffs stay as Agent context", () => { @@ -297,13 +308,12 @@ test("rectify-first handoffs stay as Agent context", () => { // 原值: 读 rectification-agentic-chat.tsx 单文件。新值: rectificationChatSurface(容器 + 拆出的 hook / 参数函数 / 块,见 rectification-chat-surface.ts)。原因: TASK-rectification-code-split-20260926 只搬不改,整文件断言跟着代码走;切片断言另行改为调用函数。 const chat = rectificationChatSurface; - // 原值:pendingConsultationQuestion={rectificationPendingQuestion} 写在 page.tsx JSX - // 新值:pendingConsultationQuestion: rectificationPendingQuestion 写在 hook 的 panel - // 原因:校正面子树状态收到 panel,Home 不再逐个传 - assert.match(source, /pendingConsultationQuestion: rectificationPendingQuestion/); - assert.match(chat, /pendingConsultationQuestion\?\.trim\(\)/); - // 旧 横幅承诺按钮会带回原问题 → 新 按钮已删,改成结束后新建对话再问 → 保留 pendingConsultationQuestion 传递链 - assert.match(chat, /结束后新建对话,按采用的时间再问/); + // 原值: pendingConsultationQuestion 传到校正面并显示「结束后新建对话,按采用的时间再问」 + // 新值: 不再显示;做完回到来源对话,分隔线下「按新时间重新看:原问题」点了才发(C5/C6) + // 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入;BUG-1250 + assert.doesNotMatch(source, /pendingConsultationQuestion/); + assert.doesNotMatch(chat, /结束后新建对话,按采用的时间再问/); + assert.match(chat, /RECTIFICATION_RETURN_BUTTON_LABEL/); }); test("ordinary consultation uses current birth data without a rectification notice", () => { @@ -325,7 +335,10 @@ test("rectification mutations report pending state while session-level return co // 新值:onPendingChange: setRectificationMutationPending 写在 hook 的 panel // 原因:mutationPending 仍由外壳对象持有,但 setter 经 panel 交给子树 assert.match(source, /onPendingChange: setRectificationMutationPending/); - assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending \|\| !rectificationForSelf\}/); + // 原值: 首页校正 pill 的 disabled 读 rectificationLoading / rectificationMutationPending + // 新值: pill 已删;提议卡在对话进行中(loading / 取消中)同样不可点 + // 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + assert.doesNotMatch(source, /rectificationForSelf/); assert.doesNotMatch(source, /重试恢复/); assert.doesNotMatch(source, /返回并恢复原问题|返回首页/); }); @@ -384,7 +397,8 @@ test("homepage entry points are exactly two native buttons with nothing nested i const source = homeSurface; const entries = source.match(/className="starter-entry"/g) ?? []; - assert.equal(entries.length, 2); + // 原值: 2(今日星语 + 生时校正) 新值: 1(只剩今日星语) 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + assert.equal(entries.length, 1); assert.doesNotMatch(source, /product-entrypoint-hitarea/); const row = source.slice( source.indexOf('className="starter-entry-row"'), diff --git a/frontend/tests/consultation-workflow-contract.test.ts b/frontend/tests/consultation-workflow-contract.test.ts index 01200524..afcea20f 100644 --- a/frontend/tests/consultation-workflow-contract.test.ts +++ b/frontend/tests/consultation-workflow-contract.test.ts @@ -306,7 +306,7 @@ test("personal consultation lets the Agent invoke the server-bound workflow tool // 原值: /return \{ "run-jyotish-consultation": consultationTool \};/ // 新值: 同一个工厂再返回只读补取工具(同请求缓存,每轮一次) // 原因: TASK-consult-evidence-card-20260927 T5(D8) - assert.match(tools, /return \{\s*"run-jyotish-consultation": consultationTool,\s*\[CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID\]: lookupTool,\s*\};/); + assert.match(tools, /return \{\s*"run-jyotish-consultation": consultationTool,\s*\[CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID\]: lookupTool,\s*\.\.\.\(ctx\.rectificationOffer \? \{ \[RECTIFICATION_OFFER_TOOL_ID\]: createRectificationOfferTool\(ctx\) \} : \{\}\),\s*\};/); // 原值:transformText 读 workflowReceipt.preciseTiming === "allowed" 再套恒等壳 // 新值:Pass 4 只看 consultationMode,不再读 preciseTiming 开关去挖日期 // 原因:BUG-948,恒等壳下线;日期观察按模式,不按分钟敏感开关。 diff --git a/frontend/tests/home-streaming-render-split.test.ts b/frontend/tests/home-streaming-render-split.test.ts index 239218f3..8bd325a5 100644 --- a/frontend/tests/home-streaming-render-split.test.ts +++ b/frontend/tests/home-streaming-render-split.test.ts @@ -27,6 +27,7 @@ function propsFor(messages: readonly ChatMessage[]): Omit api.requests.filter((line) => line.includes("/api/rectification/cases/entry-summary")).length; + const before = summaryCalls(); await doc.followLink("新建对话"); assert.equal(doc.firstCommits[1].loading, false); - await doc.settle(() => doc!.view().rectificationCard === "开始新的生时校正", "the refreshed summary"); + await doc.settle(() => summaryCalls() > before, "the summary refresh"); + assert.equal(doc.view().rectificationCard, null); assert.equal(doc.view().loading, false); assert.deepEqual(doc.errors, []); } finally { @@ -671,7 +678,8 @@ for (const path of ["/chart", "/reports"] as const) { assert.equal(first.loading, false, "no loading ring"); assert.equal(first.title, "新对话"); assert.equal(first.requests, before, "ready before any request was sent"); - assert.equal(doc.view().rectificationCard, "再次校正", "the prefetched summary drives the card"); + // 原值: "再次校正"(预取摘要驱动卡片) 新值: null(首页无校正卡) 原因: BUG-1247(TASK-rectification-in-chat-step1-20261006 P3/C1):首页生时校正按钮与文字口令删除,校正只从对话里的提议卡进入 + assert.equal(doc.view().rectificationCard, null, "the home has no rectification card"); assert.ok(first.text.includes("你的表现更容易被人看到"), "today's line is in the very first frame"); assert.ok(!first.text.includes("今天的星语还没写出来"), "no placeholder that later swaps (BUG-1110)"); assert.deepEqual(doc.errors, []); diff --git a/frontend/tests/icon-motion-audit.test.ts b/frontend/tests/icon-motion-audit.test.ts index 38be0e7a..1cde9f92 100644 --- a/frontend/tests/icon-motion-audit.test.ts +++ b/frontend/tests/icon-motion-audit.test.ts @@ -10,9 +10,11 @@ test("the 2026-09-22 icon audit keeps the existing entry icons and adds no loop" // 新值:`import { Clock3, MoonStar }`、`