diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f82faac..cadf541c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 印度占星 Skill 更新日志 +## 2026-09-05 — 采用时间卡跟在最新旁白下面,单分钟不再念两遍 + +生时校正问到可以给出时间时,时间卡跟在最新旁白下面,不再留在更早的采集题底下。旁白会说明按你的经历认真分析过了,下面是这次的结果;单分钟只说一遍钟点。Skill 版本仍是 10.0.14。 + ## 2026-09-05 — 发布构建不再从网上拉 Inter 西文正文字体改成仓库里的 Inter 文件,镜像构建不再访问 Google Fonts。页面上看起来还是同一套字。Skill 版本未变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 70498524..908480b8 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8389,3 +8389,35 @@ - 复发自:BUG-430(加载方式在无 Google Fonts 网络的镜像构建里不成立) - 修复版本:待发布 +## BUG-544 | 采用卡钉在历史采集题下,最新旁白却说从下面选 + +- 状态:resolved +- 首次发现:2026-09-05 +- 最近更新:2026-09-05 +- 影响面:`attachOfferResultToTurns`、`applyLiveCandidateOffer`、`rectification-agentic-chat` 的 `candidateOffer` 锚点 +- 用户现象:最新助手说「可以从下面选一个先用着」,下面没有时间卡。往上翻,卡钉在更早的「工作上呢,还记得哪年入职…」采集题下面。 +- 触发条件:公开 `can_adopt` 曾为真时,当时最新助手消息还是未答完的采集题;之后继续问了家人/核对,再出采用旁白。 +- 根因:三层叠加,复发 BUG-497/499。(1) 客户端 `useEffect` 只要某条消息已有同一 `resultId` 就不再改锚点。(2) `messages.find` 取第一条 `candidateOffer`,旧采集题永远赢过后面的采用旁白。(3) GET 把 `offer_result_id` 写在最新回合上时,`mergeTurnQuestions` 在旧消息 `offer_result_id` 为空时仍保留客户端脏锚点。 +- 修复:未采用时卡只挂在最新已落地、且没有未答采集/区分题的助手消息上;未答采集/选择题在场则不出卡。已采用后仍不把卡挪到核对题上。GET 水合时空的 `offer_result_id` 清掉该回合的客户端脏锚点。 +- 验证:`rectification-candidate-offer-anchor.test.ts`:采集题 + 后续旁白 → 卡在旁白;只有未答采集 → 无卡;已采用后卡留在原消息。`agent-voice-copy-contract`、`rectification-agentic-entry`、`rectification-adopt-flow-20260902` 既有合同仍过。 +- 防复发:未采用不得把采用卡锚在未答 `collect_spoken` / `choice` 上。不得用 `messages.find` 取第一条 offer。GET 空 `offer_result_id` 不得保留客户端脏锚点。已采用后仍不得把卡锚到最新核对题。 +- 相关记录:BUG-312、BUG-497、BUG-499、BUG-545 +- 复发自:BUG-497、BUG-499 +- 修复版本:待发布 + +## BUG-545 | 单分钟采用旁白把同一时刻念两遍,并说「分不开当前候选」 + +- 状态:resolved +- 首次发现:2026-09-05 +- 最近更新:2026-09-05 +- 影响面:`nonConvergingRangeNarration`、`templateStopExplain`、`RECTIFICATION_USER_COPY.adoptCue` +- 用户现象:交付句为「剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。这只是代表性候选,不是已确认的唯一出生分钟。可以从下面选一个先用着。」听起来像内部状态,不像对人说话。 +- 触发条件:可信区间收成同一分钟,且 `stop_facts` 没有可并列的第二分钟。 +- 根因:模板把「范围」和「代表分钟」无条件拼在一起;`formatClockRange` 把 `05:15–05:15` 收成 `05:15` 后仍套「范围是 X,代表分钟 X」。停问原因用「当前候选」这种内部词。`adoptCue` 说「选一个」,卡片却不在这句下面(见 BUG-544)。 +- 修复:区间收成单分钟时改口「眼下更站得住的是 HH:MM」,不再重复。停问改成「再问下去也分不开 A 和 B」或「再问下去也分不出更准的时间了」。`adoptCue` 改为「我按你说的经历认真分析过了,下面是这次的结果。」不得说「先用着」。边界句「不是已确认的唯一出生分钟」保留。 +- 验证:`agent-voice-copy-contract` 锁单分钟句式与 `adoptCue`;`rectification-answer-choice` 最后一题采用旁白匹配新句式或跨分钟「代表分钟」。机器词表加入旧「当前候选 / 可以从下面选一个先用着 / 下面的时间可以先用着」。 +- 防复发:单分钟区间不得把范围和代表分钟念两遍。用户可见文案不得出现「当前候选」。交付收尾不得说「先用着」。交付/采用轮仍须出现代表性分钟边界语义。 +- 相关记录:BUG-544、VOICE.md +- 复发自:无 +- 修复版本:待发布 + diff --git a/docs/tasks/PROGRESS-rectification-adopt-card-anchor-20260905.md b/docs/tasks/PROGRESS-rectification-adopt-card-anchor-20260905.md new file mode 100644 index 00000000..9c118f1a --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-adopt-card-anchor-20260905.md @@ -0,0 +1,40 @@ +# PROGRESS · 采用卡锚点与交付旁白(2026-09-05) + +工作树:`.worktrees/rectification-quality-probe-dedupe-20260904` +分支:`codex/rectification-quality-probe-dedupe-20260904` +基线:`origin/staging` `7c776bdd`(Inter 本地字体)。未改 `.gitea/workflows/**`、未动迁移、未升级依赖、未 bump Skill(仍 10.0.14)、未改 `page.tsx`。 + +| 任务 | 状态 | BUG | +| --- | --- | --- | +| 采用卡跟最新旁白,采集题下不出卡 | 完成 | BUG-544 | +| 单分钟交付旁白去机器腔 | 完成 | BUG-545 | + +## 编号 + +开工时最大号 BUG-543。本单 **BUG-544、BUG-545**。未占用预留的 BUG-542。 + +## 实现要点 + +- `attachOfferResultToTurns` / `applyLiveCandidateOffer`:未采用时跳过未答 `collect_spoken` / `choice`;卡挂在最新已落地助手消息。已采用后不挪到核对题。 +- GET 水合:该回合 `offer_result_id` 为空则清掉客户端脏锚点。 +- 单分钟区间:`眼下更站得住的是 HH:MM`。停问:`再问下去也分不开 A 和 B` / `再问下去也分不出更准的时间了`。`adoptCue`:`我按你说的经历认真分析过了,下面是这次的结果。` + +## 断言变更 + +| 项 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `adoptCue` | 可以从下面选一个先用着。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 付费校正不是随便先用着;仍不说成已确认 | +| `rectification-answer-choice` 最后一题旁白 | `/代表分钟/` | `/眼下更站得住的是\|代表分钟/` | 单分钟区间不再念「代表分钟 05:00」 | +| `site`/`agentic-entry` 卡锚点 | `messages.find` 第一条 offer | `reverse().find` + `applyLiveCandidateOffer` | 旧采集题抢锚点 | + +## 测试 + +| 命令 | 结果 | +| --- | --- | +| `npx tsx --test tests/rectification-candidate-offer-anchor.test.ts tests/agent-voice-copy-contract.test.ts` | 15/15 | +| 上两项 + adopt-flow / adopt-narration / answer-choice / collect-stall / provisional-adopt / range-offer / agentic-entry / activity-receipt | 160/160 | +| `tsc --noEmit` | 0 错 | +| 改动文件 eslint `--quiet` | 0 error | +| `page.tsx` | 未改 | + +未做浏览器真人走查。清单:`docs/testing/rectification-adopt-card-anchor-20260905.md`。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 38dcbbea..c5f46c9e 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -51,6 +51,7 @@ | `TASK-rectification-collect-direction-fix-20260904.md` | `PROGRESS-rectification-collect-direction-fix-20260904.md` | dated 补采集分支插在方法轮转之前,新案例第二问被家人抢占感情/事业;`7ee7f825` CSS 与合同测试脱节挡住门禁 | 已验收通过(0be51e65),待 staging 部署核对 | `codex/rectification-collect-direction-fix-20260904`(BUG-531~532) | | `TASK-rectification-post-adopt-verify-20260904.md` | `PROGRESS-rectification-post-adopt-verify-20260904.md` | 采用后核对题重复已问题;核对卡「这题跳过」等于整案停止、已采用仍念"选一个先用着"、前端"没有拿到下一个问题";采用旁白承诺的 holdout/OOS 核对与实际计划不同源(原任务书 §4 错误);家庭采集题带年份前缀自相矛盾 | 待验收 | `codex/rectification-post-adopt-verify-20260904`(BUG-536~539) | | `TASK-rectification-quality-probe-dedupe-20260904.md` | `PROGRESS-rectification-quality-probe-dedupe-20260904.md` | 候选区分阶段同一道"上大学"题问两次:`pickProbe` 按种类取第一条质量探针无视 `semantic_key`(第二次答案绑到错的证据);引擎对毕业等学业 kind 也套"高考发挥"模板;同域同分组质量探针不去重 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-540~541) | +| — | `PROGRESS-rectification-adopt-card-anchor-20260905.md` | 采用卡钉在历史采集题下,最新旁白没有卡;单分钟交付旁白机器腔 | 待验收 | `codex/rectification-quality-probe-dedupe-20260904`(BUG-544~545) | | `TASK-api-not-configured-mislabel-20260904.md` | — | 16 处路由把数据库瞬断(部署切换窗口)兜底翻译成 503「服务尚未配置」;改为仅配置错误用该文案,其余 `service_unavailable`,收敛为共享 helper | 待执行 | `codex/api-not-configured-mislabel-20260904`(BUG-542 起) | | `TASK-rectification-ux-20260902.md` | `PROGRESS-rectification-ux-20260903.md` | 会话面空白假死与交互摩擦 | 已验收 | `d159f08e`(09-03 在新基线重做后合入,BUG-505~509) | diff --git a/docs/testing/rectification-adopt-card-anchor-20260905.md b/docs/testing/rectification-adopt-card-anchor-20260905.md new file mode 100644 index 00000000..987b86f7 --- /dev/null +++ b/docs/testing/rectification-adopt-card-anchor-20260905.md @@ -0,0 +1,27 @@ +# Staging 人肉复核 · 采用卡位置与交付旁白(2026-09-05) + +给产品负责人。不要把真实案例里的年份、事件或候选分钟写进任何记录。 + +对应 BUG-544、BUG-545。测之前先做第 0 条。 + +## 0. 确认测的是新版本 + +浏览器打开 `https://staging.jyotisha.chat/api/health`,看 `deployment.gitCommit` 前 8 位是否等于本单合入 staging 后的提交。不一致 = 先别测。 + +## 1. 时间卡跟在「这次的结果」那句下面(P0 · BUG-544) + +1. 把一场校正问到助手给出这次的结果。 +2. 看**最新**那条助手消息下面。 +- ✅ 预期:旁白以「我按你说的经历认真分析过了,下面是这次的结果。」收尾;`当前可能的出生时间` 卡紧挨着这句,能点「采用此时间」。 +- ❌ 失败:卡出现在更早的采集题或区分题下面,最新旁白下面没有卡。 + +## 2. 采集题还在问时不要出采用卡(P0 · BUG-544) + +若过程中还在问「工作上呢,还记得哪年入职…」这类口述题: +- ✅ 预期:这题下面没有采用卡;答完并出采用旁白后,卡才出现在最新旁白下。 + +## 3. 旁白像对人说话(P1 · BUG-545) + +采用旁白: +- ✅ 预期:不出现「当前候选」或「先用着」。收尾是「我按你说的经历认真分析过了,下面是这次的结果。」单分钟只说一遍钟点(例如「眼下更站得住的是 05:15」),不把同一时刻先当范围再当代表分钟。仍有「不是已确认的唯一出生分钟」。 +- ❌ 失败:出现「剩下的题分不开当前候选」、「范围是 05:15,代表分钟 05:15」,或「可以先用着」。 diff --git a/docs/testing/rectification-adopt-narration-20260904.md b/docs/testing/rectification-adopt-narration-20260904.md index 25497f4f..5560cded 100644 --- a/docs/testing/rectification-adopt-narration-20260904.md +++ b/docs/testing/rectification-adopt-narration-20260904.md @@ -12,7 +12,7 @@ 1. 新开一个生时校正会话,按平常走完区分题。 2. **点选最后一道区分题**(不要用采集题「没有」代替)。 -- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「可以从下面选一个先用着」收尾。 +- ✅ 预期:同一轮出现「当前可能的出生时间」采用卡;旁白说出为什么停(分不开两个相邻分钟)、现在的范围和代表分钟,并以「我按你说的经历认真分析过了,下面是这次的结果。」收尾。 - ❌ 改造前(BUG-521):点选入口旁白仍是模板句,Agent 没上场。 采集题路径仍要能走: diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 51eb000a..4b9b5a56 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -473,7 +473,7 @@ or user IDs. Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。 -校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。采用后前事核对结束走 `verified_idle`:一行收尾文案,没有 live 行、没有重载。 +校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。采用卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下;采集题还没答完时不出采用卡。采用后前事核对结束走 `verified_idle`:一行收尾文案,没有 live 行、没有重载。 首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取当前会话消息、推荐问题、今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:推荐问题未到显示安全默认问题,今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 1458a7da..80a53837 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -30,7 +30,8 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 请点选下面的选项作答。点选和一句话回答会按同一规则计入当前区分题。 | 直接点下面的选项就行,打字回答也一样算数。 | 内部计分规则不进聊天。 | | 我没能确定这句话是否在回答当前问题。请点选下面的选项,或换一种说法。 | 我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。 | 承认不确定,不训人。 | | 当前可信区间是 05:00–05:07,代表分钟 05:00。代表分钟只是代表性候选,不是已确认的唯一出生分钟。 | 已经从最初的 30 分钟收到 05:00–05:07 这 7 分钟。代表分钟是 05:00,这只是代表性候选,不是已确认的唯一出生分钟。 | 同一边界语义,带进度,少法务腔。 | -| 可以从下面的时间里选一个采用。 | 可以从下面选一个先用着。 | 采用仍是采用,不说成已确认。 | +| 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 | +| 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。卡片必须就在这句下面。 | | 采用后会拿盘外核对来验证。 | 前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 已采用且没有下一道核对题时用 `postAdoptVerifyDone`,不再念 `adoptCue`。 | | 当前问题已更新,请刷新后重新作答。 | 这一问刚换成新的,刷新后再答就行。 | 机器状态句改成下一步。 | | 好的,记下了。 | 2016 年 9 月上大学,记下了——这类有明确月份的节点对校正特别有用。 | 确认收到什么,并说清为什么有用。 | diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 15cd4fc6..282de6c3 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -95,7 +95,15 @@ import { import { ModelSelector } from "./model-selector"; import { RectificationBoard, RectificationBoardPeek } from "./rectification-board"; import { RectificationChoiceCard } from "./rectification-choice-card"; -import { copyTextForMessage, parseTurnQuestion, questionIsAnswered, type TurnQuestion } from "@/lib/rectification-agentic/v9/turn-question"; +import { + applyLiveCandidateOffer, + copyTextForMessage, + interviewQuestionBlocksAdoptOffer, + parseTurnQuestion, + persistedOfferFromTurn, + questionIsAnswered, + type TurnQuestion, +} from "@/lib/rectification-agentic/v9/turn-question"; import { Button } from "@/components/ui/button"; type PersistedTurn = Readonly<{ @@ -264,9 +272,11 @@ function mergeTurnQuestions(current: RenderMessage[], turns: readonly unknown[]) return { ...message, question: question ?? undefined, - candidateOffer: next?.offerResultId - ? { resultId: next.offerResultId } - : message.candidateOffer, + candidateOffer: persistedOfferFromTurn( + next?.offerResultId, + message.candidateOffer, + true, + ), }; }); } @@ -609,25 +619,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { if (!canOffer && !adopted) return; const resultId = candidateResult.resultId; queueMicrotask(() => { - setMessages((current) => { - if (current.some((message) => message.candidateOffer?.resultId === resultId)) return current; - if (adopted && current.some((message) => message.candidateOffer)) { - return current.map((message) => ( - message.candidateOffer ? { ...message, candidateOffer: { resultId } } : message - )); - } - if (!canOffer || adopted) return current; - const owner = [...current].reverse().find((message) => ( - message.role === "assistant" - && message.state === "settled" - && Boolean(message.text) - && !message.failed - )); - if (!owner) return current; - return current.map((message) => ( - message.renderKey === owner.renderKey ? { ...message, candidateOffer: { resultId } } : message - )); - }); + setMessages((current) => applyLiveCandidateOffer(current, { + resultId, + canOffer, + adopted, + })); }); }, [candidateResult]); @@ -1375,13 +1371,16 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { && regeneratingMessageKey === null, ); const canOfferCards = canShowRectificationSelectionCards(candidateResult); - const persistedOfferKey = messages.find((message) => message.candidateOffer)?.renderKey; + const persistedOfferKey = [...messages].reverse().find((message) => message.candidateOffer)?.renderKey; const selectionCardMessageKey = persistedOfferKey ?? (canOfferCards && !candidateResult?.selectedTime ? latestSettledAssistant?.renderKey : undefined); const showSelectionCards = Boolean( candidateResult && selectionCardMessageKey - && (canOfferCards || Boolean(candidateResult.selectedTime)), + && (canOfferCards || Boolean(candidateResult.selectedTime)) + && !messages.some((message) => ( + interviewQuestionBlocksAdoptOffer(message.question, Boolean(candidateResult.selectedTime)) + )), ); const showReadonlyRange = Boolean( canShowRectificationReadonlyRange(candidateResult) diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index 7f13e8d1..682114fc 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -71,7 +71,7 @@ export const RECTIFICATION_USER_COPY = { choicePrompt: "直接点下面的选项就行,打字回答也一样算数。", unclearFocusReply: "我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。", questionUpdated: "这一问刚换成新的,刷新后再答就行。", - adoptCue: "可以从下面选一个先用着。", + adoptCue: "我按你说的经历认真分析过了,下面是这次的结果。", hostNarrationFallback: "我按现有材料继续往下收。", continueCollectFallback: "请继续说下一件你记得比较清楚、大概带年份的经历。", collectDeclinedAck: "记下了,这方面先跳过。", @@ -170,6 +170,12 @@ export function nonConvergingRangeNarration( } if (rangeText && representative) { + const collapsed = Boolean(range && range[0] === range[1]); + if (collapsed) { + const head = progress ?? `眼下更站得住的是 ${representative}`; + const body = `${head}。${boundaryCopy}`; + return prefix ? `${prefix}${body}` : body; + } const head = progress ?? `更站得住的范围是 ${rangeText},代表分钟 ${representative}`; const representativeClause = progress ? `。代表分钟是 ${representative}。` : `。`; const body = `${head}${representativeClause}${boundaryCopy}`; @@ -189,7 +195,9 @@ export function nonConvergingRangeNarration( } export function deliveryAdoptNarration(input: RangeNarrationInput): string { - return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}`; + return `${nonConvergingRangeNarration({ ...input, variant: "delivery" })} ${RECTIFICATION_USER_COPY.adoptCue}` + .replace(/\s+/g, " ") + .trim(); } const INSTRUCTION_TONE_SENTENCE = /不得|请写成/; diff --git a/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts b/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts index a67cf645..1a3fe710 100644 --- a/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts +++ b/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts @@ -23,7 +23,7 @@ export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出 用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些前事核对。 若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。 只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。 -不要出现「确认」「精确」这两个词,不得再提问,不要写「可以从下面选一个先用着」。`; +不要出现「确认」「精确」这两个词,不得再提问,不要写「我按你说的经历认真分析过了,下面是这次的结果」,不要说「当前候选」,不要说「先用着」。`; export type AdoptNarrationOutcome = | "agent" diff --git a/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts b/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts index b74b97ff..818de19b 100644 --- a/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts +++ b/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts @@ -113,13 +113,13 @@ function stopFactsFromDropped( if (representative && runnerUp && representative !== runnerUp) { facts.push({ kind: "indistinguishable", - label: `剩下的题分不开 ${representative} 和 ${runnerUp}`, + label: `再问下去也分不开 ${representative} 和 ${runnerUp}`, count: Math.max(1, noSplit), }); } else if (noSplit > 0) { facts.push({ kind: "indistinguishable", - label: "剩下的题分不开当前候选", + label: "再问下去也分不出更准的时间了", count: noSplit, }); } @@ -224,10 +224,11 @@ export function templatePostAdoptExplain(facts: AdoptDeliveryFacts): string { export function templateStopExplain(facts: AdoptDeliveryFacts): string | null { const split = facts.stop_facts.find((item) => item.kind === "indistinguishable"); - if (split && facts.representative_minute && facts.runner_up_minute) { - return `剩下的问题分不开 ${facts.representative_minute} 和 ${facts.runner_up_minute}。`; + if (split && facts.representative_minute && facts.runner_up_minute + && facts.representative_minute !== facts.runner_up_minute) { + return `再问下去也分不开 ${facts.representative_minute} 和 ${facts.runner_up_minute}。`; } - if (split) return `${split.label}。`; + if (split) return `${split.label.replace(/。?$/, "")}。`; return null; } diff --git a/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts b/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts index 610fb8b9..aa6e6ce8 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts @@ -11,6 +11,9 @@ export const MACHINE_VOICE_LEXICON = [ "当前区分题", "接下来请点选下面这一问", "可以从下面的时间里选一个采用", + "可以从下面选一个先用着", + "下面的时间可以先用着", + "剩下的题分不开当前候选", "我没能确定这句话是否在回答当前问题", "请先说一件你记得大概时间的人生经历,比如升学、入职、搬家、结婚或生病", "有没有记得住时间的收入变化、大笔支出或欠债", diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts index dcaac2a2..7fe10129 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts @@ -120,6 +120,89 @@ export function attachQuestionsToTurns; +}; + +function isSettledAssistant(message: CandidateOfferAnchor): boolean { + if (message.role !== "assistant" || message.failed) return false; + if (message.state && message.state !== "settled") return false; + return Boolean(message.text); +} + +export function applyLiveCandidateOffer( + messages: readonly T[], + input: { resultId: string; canOffer: boolean; adopted: boolean }, +): T[] { + if (input.adopted) { + if (!messages.some((message) => message.candidateOffer)) return [...messages]; + return messages.map((message) => ( + message.candidateOffer + ? { ...message, candidateOffer: { resultId: input.resultId } } + : message + )); + } + if (!input.canOffer) return [...messages]; + const liveInterview = messages.some((message) => ( + interviewQuestionBlocksAdoptOffer(message.question, false) + )); + if (liveInterview) { + return messages.map((message) => ( + message.candidateOffer ? { ...message, candidateOffer: undefined } : message + )); + } + const owner = [...messages].reverse().find((message) => ( + isSettledAssistant(message) + && !interviewQuestionBlocksAdoptOffer(message.question, false) + )); + if (!owner) { + return messages.map((message) => ( + message.candidateOffer ? { ...message, candidateOffer: undefined } : message + )); + } + return messages.map((message) => ({ + ...message, + candidateOffer: message.renderKey === owner.renderKey + ? { resultId: input.resultId } + : undefined, + })); +} + +export function persistedOfferFromTurn( + offerResultId: string | null | undefined, + previous: Readonly<{ resultId: string }> | undefined, + turnHydrated: boolean, +): Readonly<{ resultId: string }> | undefined { + if (typeof offerResultId === "string" && offerResultId.length > 0) { + return { resultId: offerResultId }; + } + if (turnHydrated) return undefined; + return previous; +} + +function assistantCanOwnAdoptOffer( + turn: { role?: string; question?: TurnQuestion | null }, + adopted: boolean, +): boolean { + return turn.role === "assistant" + && !interviewQuestionBlocksAdoptOffer(turn.question, adopted); +} + export function attachOfferResultToTurns ({ ...turn, offer_result_id: null })); } + const adopted = Boolean(input.acceptedTime); const firstVerify = turns.findIndex((turn) => ( turn.role === "assistant" && turn.question?.kind === "reverse_verify" )); const searchUntil = firstVerify >= 0 ? firstVerify : turns.length; let ownerIndex = -1; for (let index = searchUntil - 1; index >= 0; index -= 1) { - if (turns[index]?.role === "assistant") { + if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) { ownerIndex = index; break; } } if (ownerIndex < 0) { for (let index = turns.length - 1; index >= 0; index -= 1) { - if (turns[index]?.role === "assistant") { + if (assistantCanOwnAdoptOffer(turns[index] ?? {}, adopted)) { ownerIndex = index; break; } diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index b6a86097..b8bdeba7 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -70,6 +70,18 @@ test("accident-case after copy is rebuilt from the shared module", () => { assert.ok(containsBoundarySemantics(after.deliveryAdopt)); }); +test("a single-minute range is not spoken twice as range and representative", () => { + const text = nonConvergingRangeNarration({ + credibleRange: ["05:15", "05:15"], + representativeTime: "05:15", + }); + assert.match(text, /眼下更站得住的是 05:15/); + assert.doesNotMatch(text, /代表分钟 05:15/); + assert.doesNotMatch(text, /更站得住的范围是 05:15/); + assert.equal(containsBoundarySemantics(text), true); + assert.equal(RECTIFICATION_USER_COPY.adoptCue, "我按你说的经历认真分析过了,下面是这次的结果。"); +}); + test("default range narration stays semantic even without an opening window", () => { const text = nonConvergingRangeNarration({ credibleRange: ["04:47", "04:53"], diff --git a/frontend/tests/rectification-adopt-narration-20260904.test.ts b/frontend/tests/rectification-adopt-narration-20260904.test.ts index da03d88f..162b6874 100644 --- a/frontend/tests/rectification-adopt-narration-20260904.test.ts +++ b/frontend/tests/rectification-adopt-narration-20260904.test.ts @@ -615,7 +615,7 @@ function adoptAccounting(dossier: DecisionDossier, activeFocus?: Record { const dossier = caseDossier(); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); - const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。"; + const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; const timed = await deliverAdoptNarration({ facts, fallback, @@ -951,7 +951,7 @@ test("adopt narration does not leave an active timeout after the model returns", const dossier = caseDossier(); const decision = decideFromDossier(dossier, { birthDate: "1997-08-08" }); const facts = adoptDeliveryFacts(decision, dossier); - const fallback = "剩下的问题分不开 05:00 和 05:06。可以从下面选一个先用着。"; + const fallback = "剩下的问题分不开 05:00 和 05:06。我按你说的经历认真分析过了,下面是这次的结果。"; const kept = "剩下的问题分不开 05:00 和 05:06。范围是 05:00 到 05:06。采用后会用 2016 年学业核对。"; const source = readFileSync( new URL("../src/lib/rectification-agentic/v9/adopt-narration-agent.ts", import.meta.url), diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 9884b511..989efd00 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -565,9 +565,12 @@ test("time-selection cards use server adoption state and stay mutually exclusive // 锚在 latestSettledAssistant。采集题没有 A–D 卡,所以 collect_evidence 会出采用卡; // 采用后 busy 把卡藏掉,新消息又把卡带走。 // 新:canShowRectificationSelectionCards(含 session_outcome 门)+ candidateOffer 所有权; - // 已采用后不再把卡锚到最新消息。核对题可与原消息上的结算卡并存。 + // 未采用时卡跟着最新采用旁白走,采集题还在问则不出卡;已采用后不再把卡锚到最新消息。 // 保留语义:卡片仍由服务端 can_adopt / selection_allowed / receipt 授权,前端不自造候选。 assert.match(chat, /canShowRectificationSelectionCards/); + assert.match(chat, /applyLiveCandidateOffer/); + assert.match(chat, /persistedOfferFromTurn/); + assert.match(chat, /\.reverse\(\)\.find\(\(message\) => message\.candidateOffer\)/); assert.match(chat, /showLiveChoiceCard = Boolean\([\s\S]*latestLiveQuestion[\s\S]*options\?\.length === 4[\s\S]*!busy/); assert.match(chat, /showSelectionCards = Boolean\(\s*candidateResult\s*&& selectionCardMessageKey/); assert.doesNotMatch( diff --git a/frontend/tests/rectification-answer-choice.test.ts b/frontend/tests/rectification-answer-choice.test.ts index 73a54671..42393cbe 100644 --- a/frontend/tests/rectification-answer-choice.test.ts +++ b/frontend/tests/rectification-answer-choice.test.ts @@ -608,11 +608,13 @@ test("last structured choice emits the adoption range and persists the same narr assert.equal(applied.nextAction.type, "ready_to_adopt"); assert.equal(applied.nextAction.can_adopt, true); assert.match(applied.narration, /05:\d{2}/); - assert.match(applied.narration, /代表分钟/); + // 旧:/代表分钟/ 锁「范围是 X,代表分钟 X」。单分钟区间会把同一时刻念两遍。 + // 新:单分钟说「眼下更站得住的是 HH:MM」;跨分钟范围仍可说代表分钟。 + assert.match(applied.narration, /眼下更站得住的是|代表分钟/); assert.equal(containsBoundarySemantics(applied.narration), true); - assert.match(applied.narration, /可以从下面选一个先用着/); + assert.match(applied.narration, /我按你说的经历认真分析过了/); const turn = accounting.calls.find((call) => call.fn === "append_agentic_rectification_turn"); - assert.match(String(turn?.args.p_assistant_message ?? ""), /可以从下面选一个先用着/); + assert.match(String(turn?.args.p_assistant_message ?? ""), /我按你说的经历认真分析过了/); }); test("clicking A applies the choice without invoking a language model", async () => { diff --git a/frontend/tests/rectification-candidate-offer-anchor.test.ts b/frontend/tests/rectification-candidate-offer-anchor.test.ts new file mode 100644 index 00000000..a0bfd014 --- /dev/null +++ b/frontend/tests/rectification-candidate-offer-anchor.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applyLiveCandidateOffer, + attachOfferResultToTurns, + persistedOfferFromTurn, + type TurnQuestion, +} from "../src/lib/rectification-agentic/v9/turn-question.ts"; + +function collect(status: "active" | "resolved"): TurnQuestion { + return { + focus_id: "focus-career", + question_id: "collect:career:collect_method_evidence", + kind: "collect_spoken", + prompt: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?", + options: null, + status, + answer_option: null, + probe_id: null, + }; +} + +test("adopt offer skips an unanswered collect question and lands on the later narration", () => { + const attached = attachOfferResultToTurns([ + { id: "collect-turn", role: "assistant", question: collect("active") }, + { id: "user-turn", role: "user", question: null }, + { id: "adopt-turn", role: "assistant", question: null }, + ], { + resultId: "result-1", + canAdopt: true, + acceptedTime: null, + }); + assert.equal(attached[0]?.offer_result_id, null); + assert.equal(attached[2]?.offer_result_id, "result-1"); +}); + +test("adopt offer is not pinned onto a live collect question even if it is the only assistant turn", () => { + const attached = attachOfferResultToTurns([ + { id: "collect-turn", role: "assistant", question: collect("active") }, + ], { + resultId: "result-1", + canAdopt: true, + acceptedTime: null, + }); + assert.equal(attached[0]?.offer_result_id, null); +}); + +test("live offer moves off a collect message once the adopt narration exists", () => { + const next = applyLiveCandidateOffer([ + { + renderKey: "collect", + role: "assistant", + state: "settled", + text: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?", + question: collect("resolved"), + candidateOffer: { resultId: "result-1" }, + }, + { + renderKey: "user", + role: "user", + state: "settled", + text: "2020 年 4 月实习然后 10 月离职", + }, + { + renderKey: "adopt", + role: "assistant", + state: "settled", + text: "再问下去也分不出更准的时间了。眼下更站得住的是 05:15。这只是代表性候选,不是已确认的唯一出生分钟。我按你说的经历认真分析过了,下面是这次的结果。", + }, + ], { resultId: "result-1", canOffer: true, adopted: false }); + assert.equal(next[0]?.candidateOffer, undefined); + assert.deepEqual(next[2]?.candidateOffer, { resultId: "result-1" }); +}); + +test("live offer stays hidden while an unanswered collect question is still on screen", () => { + const next = applyLiveCandidateOffer([ + { + renderKey: "collect", + role: "assistant", + state: "settled", + text: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?", + question: collect("active"), + candidateOffer: { resultId: "result-1" }, + }, + ], { resultId: "result-1", canOffer: true, adopted: false }); + assert.equal(next[0]?.candidateOffer, undefined); +}); + +test("after adopt the offer stays on the original message", () => { + const next = applyLiveCandidateOffer([ + { + renderKey: "offer", + role: "assistant", + state: "settled", + text: "我按你说的经历认真分析过了,下面是这次的结果。", + candidateOffer: { resultId: "result-1" }, + }, + { + renderKey: "verify", + role: "assistant", + state: "settled", + text: "2021 年前后,有没有家人结婚、添丁或住院?", + question: { + focus_id: "verify", + question_id: "reverse_verify:family:score", + kind: "reverse_verify", + prompt: "2021 年前后,有没有家人结婚、添丁或住院?", + options: null, + status: "active", + answer_option: null, + probe_id: null, + }, + }, + ], { resultId: "result-2", canOffer: true, adopted: true }); + assert.deepEqual(next[0]?.candidateOffer, { resultId: "result-2" }); + assert.equal(next[1]?.candidateOffer, undefined); +}); + +test("hydrated turns drop a stale client offer when GET says this turn is not the owner", () => { + assert.equal( + persistedOfferFromTurn(null, { resultId: "result-1" }, true), + undefined, + ); + assert.deepEqual( + persistedOfferFromTurn("result-2", { resultId: "result-1" }, true), + { resultId: "result-2" }, + ); + assert.deepEqual( + persistedOfferFromTurn(null, { resultId: "result-1" }, false), + { resultId: "result-1" }, + ); +}); diff --git a/frontend/tests/rectification-collect-stall.test.ts b/frontend/tests/rectification-collect-stall.test.ts index f5292bef..4a7dd5b4 100644 --- a/frontend/tests/rectification-collect-stall.test.ts +++ b/frontend/tests/rectification-collect-stall.test.ts @@ -1124,7 +1124,7 @@ test("live five-evidence case opens adopt instead of occupation collect", async birthDate: "1997-08-08", }); assert.equal(persisted.persisted, false); - assert.match(persisted.hostNarration, /可以从下面选一个先用着/); + assert.match(persisted.hostNarration, /我按你说的经历认真分析过了/); assert.equal( accounting.calls.some((item) => item.fn === "set_agentic_rectification_conversation_focus"), false, diff --git a/frontend/tests/rectification-provisional-adopt.test.ts b/frontend/tests/rectification-provisional-adopt.test.ts index 0bbf89ae..2195e1e1 100644 --- a/frontend/tests/rectification-provisional-adopt.test.ts +++ b/frontend/tests/rectification-provisional-adopt.test.ts @@ -425,7 +425,7 @@ test("incident shape 1c: uncovered occupation still opens provisional adopt", as }); assert.equal(persisted.persisted, false); assert.equal(activeFocus, null); - assert.match(persisted.hostNarration ?? "", /可以从下面选一个先用着/); + assert.match(persisted.hostNarration ?? "", /我按你说的经历认真分析过了/); assert.equal(decision.canConfirmExactMinute, false); assert.equal(canRenderRectificationSelectionCards(parseRectificationCandidateResult({ resultId: dossier.latestResult?.resultId, @@ -463,7 +463,7 @@ test("after-choice persist does not open another collect once dated coverage can }); assert.equal(next.persisted, false); assert.equal(writes, 0); - assert.match(next.hostNarration, /可以从下面选一个先用着/); + assert.match(next.hostNarration, /我按你说的经历认真分析过了/); }); test("incident shape 2: evidence floor still blocks adopt", () => { @@ -610,7 +610,7 @@ test("delivery B2: adoptable offer produces adopt_representative with zero follo assert.match(idle.hostNarration ?? "", /05:00–05:07/); assert.match(idle.hostNarration ?? "", /05:00/); assert.equal(containsBoundarySemantics(idle.hostNarration ?? ""), true); - assert.match(idle.hostNarration ?? "", /可以从下面选一个先用着/); + assert.match(idle.hostNarration ?? "", /我按你说的经历认真分析过了/); }); test("delivery B3: accept projection stays open from engine receipt, not exact-minute confirm", () => { diff --git a/frontend/tests/rectification-range-offer-deadend.test.ts b/frontend/tests/rectification-range-offer-deadend.test.ts index d7be0321..236c3c10 100644 --- a/frontend/tests/rectification-range-offer-deadend.test.ts +++ b/frontend/tests/rectification-range-offer-deadend.test.ts @@ -557,7 +557,7 @@ test("adoptable range offer does not persist another spoken collect", async () = }); assert.equal(idle.persisted, false); assert.equal(idle.choiceReady, false); - assert.match(idle.hostNarration ?? "", /可以从下面选一个先用着/); + assert.match(idle.hostNarration ?? "", /我按你说的经历认真分析过了/); assert.match(idle.hostNarration ?? "", /04:47–04:53/); assert.match(idle.hostNarration ?? "", /04:51/); assert.doesNotMatch(idle.hostNarration ?? "", /升学|转学|考试/);