From 68f0759eef89692cdeb38cc1b87fa8c8785307fe Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Fri, 28 Aug 2026 16:30:09 +0800 Subject: [PATCH] fix(rectification): persist the next interview after a choice tap Closing a discriminator used to leave GET without a card after refresh. Write the next dated question in the same request, skip childhood career and move probes, and do not continue a read-only turn when that question is already persisted. Co-authored-by: Cursor --- docs/BUG_HISTORY.md | 132 ++++- .../src/app/api/rectification/agent/route.ts | 11 + frontend/src/app/globals.css | 10 + .../components/rectification-agentic-chat.tsx | 142 ++++-- .../components/rectification-choice-card.tsx | 41 +- .../lib/rectification-agentic/v9/agent-run.ts | 85 +++- .../rectification-agentic/v9/answer-choice.ts | 138 ++++- .../rectification-agentic/v9/choice-action.ts | 25 +- .../rectification-agentic/v9/choice-card.ts | 113 ++++- .../v9/method-followup.ts | 379 +++++++++++--- .../rectification-agentic/v9/step-answer.ts | 122 ++++- .../v9/stream-mapping.ts | 4 + .../v9/turn-narration.ts | 35 +- frontend/src/mastra/agentic-rectification.ts | 2 +- frontend/src/mastra/rectification-v9-tools.ts | 13 +- .../rectification-activity-receipt.test.ts | 1 + .../tests/rectification-agentic-entry.test.ts | 35 +- .../tests/rectification-answer-choice.test.ts | 470 +++++++++++++++++- .../tests/rectification-choice-card.test.ts | 299 ++++++++++- .../rectification-decision-authority.test.ts | 86 +++- .../tests/rectification-eight-method.test.ts | 285 ++++++++++- .../tests/rectification-server-focus.test.ts | 2 +- .../tests/rectification-step-answer.test.ts | 82 ++- .../tests/rectification-v9-stream.test.ts | 116 ++++- scripts/rectification/event_probes.py | 48 +- tests/test_rectification_event_probes.py | 90 ++++ 26 files changed, 2455 insertions(+), 311 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3a5c25a6..96983f1f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -6080,7 +6080,7 @@ - 状态:resolved(本地修复,待发布) - 首次发现:2026-08-26 -- 最近更新:2026-08-26 +- 最近更新:2026-08-28 - 影响面:`scripts/rectification/event_probes.py`、relationship `dasha_boundary` 探针 - 用户现象:感情卡可能询问出生后很早、明显不符合“认真关系进入或结束”语义的年份。 - 触发条件:候选在未成年年份附近存在高区分度 Vimshottari 或 Narayana 边界,且该年份没有已记录事件挡住。 @@ -6088,7 +6088,7 @@ - 修复:relationship 的 dasha boundary 从 `birth_year + age_lo` 开始;不使用 `age_hi` 作硬上限,避免排除成年后的真实关系事件。其他允许童年事件的领域保持原范围。 - 验证:`/opt/anaconda3/bin/python3.12 -m pytest tests/test_rectification_event_probes.py -q`,15/15 通过;回归锁定未成年 relationship 年份被排除且 family 童年边界行为不回退。 - 防复发:relationship boundary 不得早于 catalog `age_lo`;不得把 `age_hi` 当事实有效期。 -- 相关记录:BUG-379 +- 相关记录:BUG-379、BUG-417 - 复发自:无 - 修复版本:待发布 @@ -6221,6 +6221,134 @@ - 复发自:BUG-405(排序公式对,目录被投影饿死,已打开低分卡锁题) - 修复版本:待发布 +## BUG-411 | 已持久化的 D24 区分卡在 GET 时被 active_focus 重建丢掉 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`buildMethodFollowupPlan` 已打开区分焦点、GET `choice_card`、生时纠正点选卡 +- 用户现象:训练事件已齐,助手记下感情经历后只说“对候选的区分有帮助”,不再追问,界面也没有点选卡。服务端其实已经创建了 D24 区分焦点。 +- 触发条件:目录赢家是推理层 `varga_contrast`(不在 Python `discriminating_event_probes` 里),该焦点已持久化,随后 GET 刷新卡片。公开分数字段可因 `credible_range` 与全窗候选不一致而被清空。 +- 根因:已打开的区分焦点只要还没被更高分探针替换,计划就改写成 `source=active_focus`。重建时只在 Python 事件探针里按 `targetDomain` 找 live probe。D24 只存在于对比包,education 域又没有 dasha 事件探针,于是 `semantic_key` 丢失、`choice_frame` 对不上已持久化的 `questionId`,GET `choice_card` 变成 null。Agent 被禁止在没有公开卡片时口述区分题,正文只剩确认句。 +- 修复:打开的区分卡若已经是当前目录赢家,继续用该赢家的 ranked discriminator 出题,不要改写成 active_focus。 +- 验证:`rectification-choice-card` 锁定「Python 事件探针没有教育行、D24 只在推理探针、焦点已持久化」时 GET 仍返回点选卡。 +- 防复发:不得把已打开且仍是目录赢家的分盘区分题改写成没有 `semantic_key` 的 `active_focus`。GET 有合法持久化区分焦点时不得把 `choice_card` 置空。 +- 相关记录:BUG-407、BUG-410 +- 复发自:BUG-410(决策已进入区分并持久化焦点,GET 仍把卡片藏掉) +- 修复版本:待发布 + +## BUG-412 | 反推点选卡丢掉已记下的年份月份,后两个选项收成一排小按钮 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`choice-card` `period` / `prompt`、GET `choice_card`、`rectification-choice-card` 点选样式 +- 用户现象:区分卡出现后题干写成「当前这几个候选 · 学业…」,已记下的入学年份和月份精度出不来。A/B/C 是整行主按钮,D「这段记不清楚」和「先这样」挤在下一排、更小、居中、颜色变淡。 +- 触发条件:目录赢家是没有 `year` 的 D24 `varga_contrast`,账本里已有同学业带日期经历;点选卡把 `unsure` 和停止键标成 `secondary`。 +- 根因:`periodFor` 只要找到探针就采用 `year_label`,对比包探针年份为 0 时标签是占位「当前这几个候选」,不再回落到同学业已确认经历。GET 又优先用已持久化的占位题干。选项按 `unsure=secondary` 拆到 `special-choices`,和停止键共用 flex 小按钮。 +- 修复:探针没有具体年份时,题干锁定同学业已确认经历的「YYYY 年前后」或月份精度的「YYYY 年 M 月前后」。GET 用更具体的服务端题干覆盖占位题干。A/B/C/D 和停止键同一列主按钮。 +- 验证:`rectification-choice-card` 锁定 D24 占位探针 + 2016 年学业经历 → 题干带「2016 年前后」;月份精度经历带出「M 月前后」;GET 仍把占位题干升级成年份锁。`rectification-eight-method` 锁定高分 D24 的 period 是已记学业年。`rectification-agentic-entry` 锁定点选卡不再拆 `special-choices`。 +- 防复发:不得在对比包年份为 0 时把「当前这几个候选」压过账本已确认年份。点选卡四个答案不得因为 `unsure` 改成另一套小按钮。 +- 相关记录:BUG-406、BUG-408、BUG-411 +- 复发自:BUG-411(卡片能投影后,占位年份和 secondary 拆行才被看见) +- 修复版本:待发布 + +## BUG-413 | 点选后卡片消失,只留下一条自动发出的选项气泡 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:生时纠正点选卡停留与选中态、`choice_click` 用户气泡、D24/发挥质量题干 +- 用户现象:点 A/B/C/D 或「先这样」后卡片立刻从对话里消失,底下多出一条用户自己点出来的 `A. 发挥明显失常…`。同一张卡不能再看见选中项。题干写成「2016 年前后 · 学业、考试发挥或学习压力出现明显变化」,像目录锁标签。 +- 触发条件:GET 已出示点选卡;用户点击任一选项。`showChoiceCards` 在 `busy` 时卸载卡片,并把 `choiceCardUserMessage` 追加成用户消息。 +- 根因:点选被做成「发一条用户消息 + 等下一轮」,卡片只挂在最新未 busy 的助手气泡上。题干走 `period · family` 锁标签;对比包发挥质量又不分领域,一律套学业考试目录句。 +- 修复:点选后把卡片钉在出题的那条助手消息上,保留 `data-selected`,禁用再点;不再插入自动用户气泡,刷新时也藏掉 `A. …` / 「先这样」这类合成行。发挥质量题干改成年份锁定的口语问句,并按领域区分家族,不再用「当前这几个候选 · 学业…」目录锁。 +- 验证:`rectification-choice-card` 锁定 D24 题干是「2016 年前后,有没有学业或考试发挥失常、压力特别大的时候?」;GET 把已持久化的目录锁升级成这句。`rectification-agentic-entry` 锁定 `choiceAttachment` 停留、不再创建 `v9-choice-user-` 气泡。`rectification-answer-choice` 锁定合成点选行不是聊天用户句。 +- 防复发:点选卡不得因为 `busy` 从已回答的助手气泡上卸掉。点选不得再追加 `A. 标签` 用户气泡。题干不得再把 `period · family` 目录锁直接展示给用户。 +- 相关记录:BUG-405、BUG-406、BUG-412 +- 复发自:BUG-412(卡片能看清后,点选消失和目录题干才被看见) +- 修复版本:待发布 + +## BUG-414 | 无年份家人反推仍出点选卡 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:生时纠正点选卡 `eventQuestionPrompt`、`buildChoiceFrame`、`rankRenderableDiscriminators`、家人存在性家族、GET 题干覆盖 +- 用户现象:D24 点选之后出现「那段时间,有没有家人相关的明显变化?」。没有具体年份,事件也不是一件能点是/否的家事。改成「有没有家人结婚、添丁或住院?」后仍缺时间,无法反推。 +- 触发条件:家人域没有已记带日期经历;区分探针年份为 0(D12 分盘对比);或目录家族仍是「家人相关的明显变化」。 +- 根因:口语题干用 `period + 有没有 + event_family` 拼接。D12 对比探针没有年份,同域也没有带日期证据,`period` 落成占位「那段时间」。第一轮误修成去掉年份、只问「有没有发生过」。反推点选必须锁在具体年/月上,没有时间就不能计分。 +- 修复:存在性 / 发挥质量点选在锁不住具体年份时失败关闭,不出卡。目录跳过无年份 D12,改问下一条有年份的区分题;若没有,用自然语言采集一件记得住时间的家事。已记家人年份时题干为「2018 年前后,有没有家人结婚、添丁或住院?」。各域存在性家族改成可核对的口语事件。GET 把已持久化的「那段时间 / 目录锁」升级成带年份的新句。 +- 验证:`rectification-choice-card` 锁定无年份家人帧为 `null`;有引擎年份的家事探针才带年。`rectification-eight-method` 锁定无年份 D12 让位给有年份的事业探针,或退回家人自然语言采集;已开的无年份家人卡不再保持评分帧。无年份 D24 不得借用账本学业年。方法层齐后剩下无年份 D4 时,先用自然语言问记得住时间的搬家,不出无年份点选卡。 +- 防复发:评分用 A/B/C/D 反推不得用「那段时间」或无年份的「有没有发生过」顶时间。不得用年龄带中点发明年份。家人存在性家族不得再展示「家人相关的明显变化」。 +- 相关记录:BUG-412、BUG-413 +- 复发自:BUG-413(锁标签改成「有没有 + 家族」后,空年份仍被当成可问的点选卡) +- 修复版本:待发布 + +## BUG-415 | 反推点选借用账本年份,点选后又多一条确认气泡 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:生时纠正点选卡 `periodFor`、冲突探针去重、`answer_choice` 续跑、`bindSpokenToOpenQuestion` +- 用户现象:点选卡问「2016 年前后有没有学业或考试发挥失常」。该年不是候选分钟反推出来的,而是账本里已记的学业年。点选 A/B/C/D 后先出现「已记录你的选择,并更新了候选比较。」,再出现工具「读取校正记录」和第二条确认/下一问。 +- 触发条件:D24 `event_quality` 探针年份为 0,同领域已有带日期证据;同时 Python 已给出另一领域带月份的 dasha 冲突探针。点选成功且 `nextAction` 仍要继续提问。 +- 根因:评分反推把账本同领域年份当成锁年。冲突探针按整个领域去重,已有事业经历后不再问 2012 年 11 月事业边界。点选成功会持久化确认 turn,客户端再把它落成助手气泡,随后 `read_only` 续跑又写第二条。 +- 修复:存在性 / 发挥质量点选只认引擎探针年或月,不再回落到账本同领域年份。冲突探针按年去重,同年跳过、不同年继续问。无年份 D24 让位给带年份的 dasha 题。点选后续跑时不写确认 turn,客户端丢掉点选占位气泡,只保留续跑那一条;续跑正文去掉「已记录选择 / 请看下方选项」。 +- 验证:`rectification-choice-card` 锁定无年份 D24 不得借用 2016 学业年,GET 展示 2012 年 11 月事业题。`rectification-eight-method` 锁定同年事业探针跳过、不同年仍问,无年份 D24 让位给 2023 事业 dasha。`rectification-answer-choice` 锁定续跑不写 `append_agentic_rectification_turn`,「先这样」仍写。`rectification-v9-stream` 锁定点选确认句被剥掉。 +- 防复发:评分 A/B/C/D 反推不得用账本同领域已记年份顶引擎时间。冲突探针不得按领域整段跳过。点选后续跑不得再展示独立确认气泡。 +- 相关记录:BUG-402、BUG-411、BUG-412、BUG-414 +- 复发自:BUG-414(无年份家人卡修好后,无年份 D24 仍借用学业年);BUG-402(续跑补上后多写了一条确认) +- 修复版本:待发布 + +## BUG-416 | 生时纠正口语被攒到整轮结束,点选后续跑不再逐字出现 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`runV9AgentTurn`、`step-answer`、公开 `answer.delta`、生时纠正聊天气泡 +- 用户现象:工具活动仍会更新,但助手正文不再边生成边出现;点选卡回合尤其明显,进度走完后整段或占位句一次性弹出。 +- 触发条件:生时纠正对话里模型在工具之后写口语;当前有 `open_question` / 点选卡时更重。 +- 根因:BUG-373 把含工具调用的 step 的 `text-delta` 整段丢掉,只在 `step-finish` 发布终端步,口语无法按 token 直播。随后 `open_question` 把已写出口语放进 `heldSpoken`,等到流结束再 `bindSpokenToOpenQuestion` 一次性发出。点选续跑还会把确认句剥掉,用户先看到长时间空白。 +- 修复:终端中文在比对/结果类工具之后按 `text-delta` 直播;未解锁时等到中文句末再开始直播。规划英文和工具步正文仍丢弃。有点选锁时边生成边绑定,缩短则发 `replace` 覆盖,不再等到 flush。客户端按 `replace` 重写气泡。 +- 验证:`rectification-step-answer` 锁定比对后分片直播、工具步英文不进 `answer.delta`、直播后若再调工具则 retract。`rectification-v9-stream` 锁定比对后两段 `answer.delta` 按序到达,点选锁下确认句直播且不问句、timeout 仍用占位句。`rectification-activity-receipt` / `rectification-agentic-entry` 锁定客户端 `replace`。 +- 防复发:有 `open_question` 时不得把口语攒到 `flushPersistedPrompt`。禁止把含工具调用的 step 的规划句留给浏览器。`answer.delta` 若会缩短必须带 `replace`,客户端不得只做 `raw +=`。 +- 相关记录:BUG-373、BUG-377、BUG-415 +- 复发自:BUG-373(隔离终端步后不再按 token 发布);BUG-415(点选绑定把已推迟的口语又剥成空白) +- 修复版本:待发布 + +## BUG-417 | 未成年事业/搬家点选,点选后续跑复述旧经历并因截断报未完成 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`event_probes.py` dasha boundary、`method-followup` 年龄下限、`bindSpokenToOpenQuestion`、点选后续跑 `answer_truncated` +- 用户现象:1997 年出生后被问「2012 年 11 月有没有入职升职」「2005 年 5 月有没有搬家离乡」。点选后续跑正文反复说「实习和离职都记下了」。选 C 后出现「回答未完成,已保留现有内容;本次不会扣点。」 +- 触发条件:候选在童年/少年有高区分度大运边界;账本已有事业或感情经历;点选后续跑模型复述旧确认并写满长度。 +- 根因:BUG-399 只给感情加了 catalog `age_lo`,事业/搬家/学业仍从出生后 5 年起算。邻近年去重只覆盖学业。点选锁只剥问句,不剥跨领域确认。`finish_reason=length` 即使已有 `open_question` 也走失败,超时则不会。 +- 修复:成人语义领域(学业/搬家/感情/事业/财务/健康)的 dasha 边界都从 `birth_year + age_lo` 起算,仍不用 `age_hi` 作硬上限。家人事件仍从出生后 5 年起算。感情/事业/搬家存在性探针跳过邻近年。点选锁下跨领域确认句丢掉。有点选锁时长度截断与超时一样收成成功占位句,不报未完成。 +- 验证:`tests/test_rectification_event_probes.py` 锁定 1997 年出生不得出 2012 事业、2005 搬家,已记 2024 感情不得再问 2023。`rectification-eight-method` 锁定出生日期过滤童年事业探针、邻近年感情探针。`rectification-v9-stream` 锁定搬家/感情锁下丢掉实习确认,以及 `open_question` + `length` 仍 `run.completed`。 +- 防复发:成人语义事件家族不得用出生后 5 年当地板;家人事件仍允许童年年。点选续跑不得把上一件经历的确认句带到另一领域卡片。有 `open_question` 时 `answer_truncated` 不得再把整轮标失败。 +- 相关记录:BUG-399、BUG-415、BUG-416 +- 复发自:BUG-399(只修了感情未成年年) +- 修复版本:待发布 + +## BUG-418 | 点选后刷新,助手说请点选但 GET 没有卡片 + +- 状态:resolved +- 首次发现:2026-08-28 +- 最近更新:2026-08-28 +- 影响面:`applyRectificationChoice`、GET `choice_card`、点选后续跑 +- 用户现象:点完区分题后助手仍写「接下来有一个问题需要您点选一下」,刷新后这条还在,下方没有点选卡。`GET` 的 `choice_card` 为 `null`。账本里该问已经答过。 +- 触发条件:训练门已开、当前区分焦点被点选关闭;同一请求没有写下一条焦点。客户端按 `nextAction` 再开一轮只读续跑。刷新发生在续跑写完新焦点之前。 +- 根因:GET 投影卡片必须有已持久化的焦点 UUID。点选只关闭当前焦点,下一条区分题或带年份的采集问要等后续 Agent 轮。续跑失败、截断或用户刷新时,界面只剩「请点选」占位,没有可点的卡。无年份 D24 也不能顶成评分卡。 +- 修复:点选成功后在同一请求里按更新后的推断状态排出下一问。下一条是带年份的区分题就写入新焦点,正文改成「接下来请点选下面这一问。」;下一条是家人等采集就写入带年份的口语问,不出无年份 D24 卡。这两种都不再自动续跑。 +- 验证:`rectification-answer-choice` 锁定答完搬家区分题后持久化 2014 学业卡,刷新 GET 仍有 `focus_id`。只剩已答区分题时写入 2021 年家人采集口语,不调用 set-focus。`rectification-eight-method` 锁定家人采集带上 collection-probe 年份。`shouldContinueAfterStructuredChoice` 在 `nextInterviewPersisted` 时为 false。 +- 防复发:点选关闭焦点后,同一请求必须留下下一问(新焦点或口语采集)。不得在 GET `choice_card` 为空时继续展示「请点选」。无年份 D24 不得在点选后变成评分卡。 +- 相关记录:BUG-411、BUG-410、BUG-413、BUG-414、BUG-415、BUG-417 +- 复发自:BUG-411(已持久化的卡会被丢掉;这里是下一问根本没持久化) +- 修复版本:待发布 + ## BUG-410 | 训练已齐仍因家人/职业方法层停在采集,Agent 只确认后截断 - 状态:resolved diff --git a/frontend/src/app/api/rectification/agent/route.ts b/frontend/src/app/api/rectification/agent/route.ts index fe81a81e..283c80a0 100644 --- a/frontend/src/app/api/rectification/agent/route.ts +++ b/frontend/src/app/api/rectification/agent/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getRectificationV9Agent, type RectificationAgentAction } from "@/mastra/agentic-rectification"; import { + loadV9CaseCompute, loadV9CaseDossier, persistV9DeterministicTurn, RectificationToolServiceError, @@ -280,6 +281,8 @@ export async function POST(request: Request) { sourceQuote: applied.sourceQuote, derivedContext: applied.derivedContext, nextAction: applied.nextAction, + nextInterviewPersisted: applied.nextInterviewPersisted, + nextChoiceReady: applied.nextChoiceReady, }); } catch (error) { if (error instanceof RectificationToolServiceError) { @@ -386,11 +389,19 @@ export async function POST(request: Request) { dossier.latestResult, dossier.evidence, ); + let birthDate: string | null = null; + try { + const compute = await loadV9CaseCompute(accounting, userId, caseId); + birthDate = String(compute.baselineBirthSnapshot.birth_date ?? "") || null; + } catch { + // Ranking stays fail-open when compute is unavailable. + } const plan = buildMethodFollowupPlan({ evidence: dossier.evidence, declinedTopics: dossier.conversationSummary.declinedSkippedTopics, sessionOutcome: "discriminate_candidates", ...catalog, + birthDate, candidatesSeparated: false, }); const persisted = await persistServerOwnedFocus({ diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 815dbb51..e86f560f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -2707,6 +2707,16 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class border-radius: var(--radius-lg); background: var(--color-canvas-soft); } +.rectification-choice-card .birth-time-choice-question:disabled .birth-time-choice-option { + cursor: default; + opacity: .48; +} +.rectification-choice-card .birth-time-choice-question:disabled .birth-time-choice-option[data-selected="true"] { + opacity: 1; +} +.rectification-choice-card.is-pending .birth-time-choice-question:disabled .birth-time-choice-option:not([data-selected="true"]) { + cursor: wait; +} .rectification-message-wrap .rectification-choice-card { width: calc(100% - var(--assistant-content-inset)); margin: var(--space-3) 0 var(--space-4); diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 42ac98bd..2b536590 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -50,14 +50,13 @@ import { isNearBottom, shouldShowJumpToLatest } from "@/lib/rectification-sticky import { CHOICE_ACTION, STOP_ACTION, + isStructuredChoiceUserText, shouldContinueAfterStructuredChoice, stableChoiceActionKey, type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; import { finalizeRectificationSpokenAndThinking } from "@/lib/rectification-agentic/v9/spoken-answer"; import { - CHOICE_STOP_MESSAGE, - choiceCardUserMessage, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, @@ -164,12 +163,18 @@ type RectificationAgenticChatProps = Readonly<{ headerSlot: HTMLElement | null; }>; +type SettledChoiceAttachment = Readonly<{ + card: ChoiceCardModel; + selectedKey: ChoiceKey | "stop"; +}>; + type RenderMessage = ChatMessageView & { renderKey: string; completedReceipt?: CompletedActivityReceiptView; failed?: boolean; turnId?: string; activityTrace?: readonly AgentActivityTraceItem[]; + choiceAttachment?: SettledChoiceAttachment; }; function turnOfferedSelection(message: RenderMessage): boolean { @@ -241,6 +246,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag }]; } if (isIncompleteRunBanner(turn.text ?? "")) return []; + if (isStructuredChoiceUserText(turn.text)) return []; return [{ role: "user", text: turn.text ?? "", @@ -517,6 +523,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { type?: unknown; status?: unknown; text?: unknown; + replace?: unknown; message?: unknown; tool?: unknown; methods?: unknown; @@ -531,7 +538,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } if (typeof event.type !== "string") continue; if (event.type === "answer.delta" && typeof event.text === "string") { - raw += event.text; + raw = event.replace === true ? event.text : raw + event.text; activityTrace = freezeLiveThink(activityTrace); setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey ? { @@ -747,18 +754,26 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } const questionId = choiceCard.question_id; const actionId = actionIdForChoice(focusId, optionId); - const userText = optionId === "stop" - ? (choiceCard.stop_message || CHOICE_STOP_MESSAGE) - : choiceCardUserMessage(choiceCard, optionId); + const answeredCard = choiceCard; + const offeringRenderKey = [...messages].reverse().find((message) => ( + message.role === "assistant" + && message.state === "settled" + && Boolean(message.text) + && !message.choiceAttachment + ))?.renderKey ?? null; setError(""); - setPending(true); keyCounter.current += 1; const turnKey = keyCounter.current; - const userRenderKey = `v9-choice-user-${turnKey}`; const assistantRenderKey = `v9-choice-assistant-${turnKey}`; setMessages((current) => [ - ...current, - { role: "user", text: userText, renderKey: userRenderKey, state: "settled" }, + ...current.map((message) => ( + offeringRenderKey && message.renderKey === offeringRenderKey + ? { + ...message, + choiceAttachment: { card: answeredCard, selectedKey: optionId }, + } + : message + )), { role: "assistant", text: "", @@ -772,6 +787,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }, }, ]); + setPending(true); try { const response = await fetch("/api/rectification/agent", { method: "POST", @@ -793,9 +809,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }); const payload = await response.json().catch(() => null); if (!response.ok) { - setMessages((current) => current.filter((message) => ( - message.renderKey !== userRenderKey && message.renderKey !== assistantRenderKey - ))); + setMessages((current) => current.flatMap((message) => { + if (message.renderKey === assistantRenderKey) return []; + if (offeringRenderKey && message.renderKey === offeringRenderKey) { + return [{ ...message, choiceAttachment: undefined }]; + } + return [message]; + })); setChoiceNonce((current) => current + 1); if (payload?.code === "profile_incomplete") { onProfileIncomplete?.(); @@ -804,30 +824,36 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { setError(payload?.message || payload?.error || `请求失败(${response.status})`); return; } - const narration = typeof payload?.narration === "string" && payload.narration.trim() - ? payload.narration.trim() - : "已记录你的选择。"; - setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey - ? { - ...message, - text: narration, - state: "settled" as const, - activity: undefined, - } - : message)); - onMessagesChange?.([ - { role: "user", text: userText }, - { role: "assistant", text: narration }, - ]); + const willContinue = shouldContinueAfterStructuredChoice(payload?.nextAction, payload); onCompleted?.(); await loadCaseSnapshot(); - if (shouldContinueAfterStructuredChoice(payload?.nextAction)) { + if (willContinue) { + setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey)); choiceContinuationPending.current = true; + } else { + const narration = typeof payload?.narration === "string" && payload.narration.trim() + ? payload.narration.trim() + : "已记录你的选择。"; + setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey + ? { + ...message, + text: narration, + state: "settled" as const, + activity: undefined, + } + : message)); + onMessagesChange?.([ + { role: "assistant", text: narration }, + ]); } } catch { - setMessages((current) => current.filter((message) => ( - message.renderKey !== userRenderKey && message.renderKey !== assistantRenderKey - ))); + setMessages((current) => current.flatMap((message) => { + if (message.renderKey === assistantRenderKey) return []; + if (offeringRenderKey && message.renderKey === offeringRenderKey) { + return [{ ...message, choiceAttachment: undefined }]; + } + return [message]; + })); setChoiceNonce((current) => current + 1); setError("选择题处理失败,请稍后重试。"); } finally { @@ -839,6 +865,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { caseId, choiceCard, loadCaseSnapshot, + messages, onCompleted, onMessagesChange, onProfileIncomplete, @@ -994,32 +1021,45 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { && Boolean(message.text) )); const offeredSelectionOnce = messages.some(turnOfferedSelection); - const offeredThisTurn = Boolean(latestSettledAssistant && turnOfferedSelection(latestSettledAssistant)); - const showChoiceCards = Boolean( + const answeredQuestionIds = new Set( + messages.flatMap((message) => message.choiceAttachment + ? [message.choiceAttachment.card.question_id] + : []), + ); + const liveChoiceHost = [...messages] + .reverse() + .find((message) => ( + message.role === "assistant" + && message.state === "settled" + && Boolean(message.text) + && !message.choiceAttachment + )); + const showLiveChoiceCard = Boolean( choiceCard - && latestSettledAssistant - && !offeredThisTurn + && liveChoiceHost + && !answeredQuestionIds.has(choiceCard.question_id) + && !turnOfferedSelection(liveChoiceHost) && !busy && !readonly && regeneratingMessageKey === null, ); useLayoutEffect(() => { - choiceCardsOpen.current = showChoiceCards; + choiceCardsOpen.current = showLiveChoiceCard; updateFollowState(); - }, [showChoiceCards, updateFollowState]); + }, [showLiveChoiceCard, updateFollowState]); const showSelectionCards = Boolean( candidateResult?.selectionAllowed && offeredSelectionOnce && latestSettledAssistant - && !showChoiceCards + && !showLiveChoiceCard && !busy && regeneratingMessageKey === null, ); const selectionCardMessageKey = showSelectionCards && latestSettledAssistant ? latestSettledAssistant.renderKey : undefined; - const choiceCardMessageKey = showChoiceCards && latestSettledAssistant - ? latestSettledAssistant.renderKey + const liveChoiceMessageKey = showLiveChoiceCard && liveChoiceHost + ? liveChoiceHost.renderKey : undefined; const canSend = !busy && !readonly && !regeneratingMessageKey; @@ -1084,6 +1124,11 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { }), } : message; + const settledChoice = message.choiceAttachment; + const liveChoice = showLiveChoiceCard && message.renderKey === liveChoiceMessageKey + ? choiceCard + : null; + const visibleChoiceCard = settledChoice?.card ?? liveChoice; const vargaSentence = !message.failed ? vargaSentenceFromMethods(message.completedReceipt?.methods) : null; @@ -1122,12 +1167,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { onAccept={(candidateId) => void acceptCandidate(candidateId)} /> )} - {showChoiceCards && message.renderKey === choiceCardMessageKey && choiceCard && ( + {visibleChoiceCard && ( @@ -1177,7 +1225,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { disabled={!canSend} placeholder={readonly ? "该校正已结束,只能查看历史;需要再次校正请新建。" - : showChoiceCards + : showLiveChoiceCard ? "点上面的选项即可;想补一句细节再写" : "继续说你记得的人生经历,或回答刚才的问题…"} onChange={(event) => setDraft(event.target.value)} diff --git a/frontend/src/components/rectification-choice-card.tsx b/frontend/src/components/rectification-choice-card.tsx index f4666159..eb334394 100644 --- a/frontend/src/components/rectification-choice-card.tsx +++ b/frontend/src/components/rectification-choice-card.tsx @@ -10,42 +10,48 @@ type RectificationChoiceCardProps = Readonly<{ card: ChoiceCard; pending: boolean; disabled: boolean; + selectedKey?: ChoiceKey | "stop" | ""; onSelect: (key: ChoiceKey) => void; onStop: () => void; }>; export function RectificationChoiceCard(props: RectificationChoiceCardProps) { - const [selectedKey, setSelectedKey] = useState(""); + const [localSelected, setLocalSelected] = useState(""); + const selectedKey = props.selectedKey || localSelected; const firstChoiceRef = useRef(null); - const primary = props.card.options.filter((option) => option.role === "primary"); - const secondary = props.card.options.filter((option) => option.role === "secondary"); + const answered = Boolean(selectedKey); useEffect(() => { - if (!props.pending) firstChoiceRef.current?.focus(); - }, [props.card.question_id, props.pending]); + if (props.pending || answered) return; + firstChoiceRef.current?.focus(); + }, [answered, props.card.question_id, props.pending]); function select(key: ChoiceKey) { if (props.pending || props.disabled || selectedKey) return; - setSelectedKey(key); + setLocalSelected(key); props.onSelect(key); } function stop() { if (props.pending || props.disabled || selectedKey) return; - setSelectedKey("stop"); + setLocalSelected("stop"); props.onStop(); } return (
-
+
{props.card.prompt} {props.card.why ?

{props.card.why}

: null}
- {primary.map((option, index) => ( + {props.card.options.map((option, index) => ( ))} -
-
- {secondary.map((option) => ( - - ))}