diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index bbde5ab2..e108cada 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -328,7 +328,7 @@ - 防复发:出生声明与 `birth_time_status` 必须由同一次服务端写入建立,不允许依赖后续校正流程补齐状态。 - 相关记录:BUG-009、BUG-017 - 复发自:无 -- 修复版本:待提交 +- 修复版本:本次自动滚动修复提交 ## BUG-019 | 原问题交接租约过期后永久显示处理中 @@ -473,3 +473,259 @@ - 相关记录:BUG-017、BUG-019、BUG-020 - 复发自:无 - 修复版本:待提交(production gate) + +## BUG-028 | 生时校正收到具体经历后仍重复泛问 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正自然语言追问、事件补充信息合并、会话可见回复 +- 用户现象:用户已经描述自己的具体经历,助理没有针对该内容继续追问或反馈,而是重复上一轮的泛化问题。 +- 触发条件:用户提供的事件缺少年月并在下一轮单独补时间;或已经回答技术包当前首选领域后,下一轮仍沿用同一领域提示。 +- 根因:存在三处断链:会话组件忽略服务端已经生成的针对性 `turn.narrative`,转而根据公开 evidence request 重组固定问题;用户先说事件、下一轮只补年月时,两轮分别保存为“无日期事件”和“无内容日期”,未形成可评分事实;服务端下一问直接采用技术包首选领域,没有排除本轮已经回答的领域。 +- 修复:会话组件改用独立的可见叙事函数,优先承接用户最近一条具体但不完整的经历并只追问缺失项;编排器把下一轮仅含日期或仅含内容的补充合并回最近一条待澄清事件,同时用 `correctsEvidenceIds` 保留 append-only 修订链;下一问从技术包允许领域中优先选择尚未回答的领域,并同步覆盖公开 evidence request。 +- 验证:相关自然语言抽取、叙事、编排、路由、公开案例回放与端到端测试共 111 条全部通过;回归覆盖“先说离家工作、再补 2023 年 3 月”后合并为 `2023-03 · 离开家去北京开始工作`、具体事件缺日期时回复必须复述该事件并询问年月、关系领域回答后下一问切换到事业领域。目标文件 ESLint 与补丁检查通过。 +- 防复发:保持“服务端叙事是用户可见回复真相源”的纯函数测试;每条待澄清证据必须测试跨轮补全与修订链;下一领域必须测试排除有效 recap 已覆盖领域;端到端断言不得只绑定泛化提示词。 +- 相关记录:BUG-018、BUG-019 +- 复发自:BUG-019 +- 修复版本:待提交(本地可测) + +## BUG-029 | 生时校正仍有未回答区分领域时提前结束 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正多领域追问、候选范围停滞终止策略 +- 用户现象:用户只回答两轮后,系统仍有财务或关系等可区分领域未询问,却直接保存宽候选范围并结束。 +- 触发条件:自然语言单轮抽取出多条可评分经历,使累计数量达到最低门槛;候选范围连续两轮未变化,同时技术包仍建议尚未回答的领域。 +- 根因:停滞终止只检查可评分经历数量和候选范围是否连续不变,没有检查当前技术包是否仍存在未回答的区分领域。 +- 修复:停滞结束前增加未回答建议领域门禁;仍有可区分领域时继续自然追问,全部建议领域已覆盖后才允许按范围停滞安全结束。证据数量上限和无可区分问题终止保持不变。 +- 验证:编排器回归锁定学业、搬迁、事业三轮后必须继续询问尚未覆盖的关系领域,补充关系事件后才保存未确认范围;本地真实流程修复前复现为两轮即结束且范围仍为 `04:30–06:30`。 +- 防复发:停滞终止测试必须同时断言候选范围未变化和技术包建议领域已全部回答,不能只按事件条数结束。 +- 相关记录:BUG-019、BUG-028 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-030 | 职位和管理职责变化未识别为事业证据 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正自然语言证据分类、事业领域覆盖判断、下一轮追问 +- 用户现象:用户已经说明“开始承担管理职责”或“职位发生明显变化”,系统记录事件后仍继续要求事业经历。 +- 触发条件:事业变化使用“职位”“任职”或“管理职责”描述,但没有出现“工作”“升职”“职业”等原有关键词。 +- 根因:事业领域分类词表缺少常见的岗位和职责变化表达,导致可评分事件被归入 `other`,无法计入事业领域覆盖。 +- 修复:将“职位”“任职”“管理职责”加入事业领域分类规则,保留既有日期、评分和持久化契约。 +- 验证:抽取器回归覆盖三种带年月的岗位与职责变化表达,均分类为 `career`、保留月份并可参与评分;本地真实流程已复现修复前的漏判行为。 +- 防复发:事业领域分类测试必须覆盖入离职、晋升以及不含“工作/职业”字样的职责变化表达。 +- 相关记录:BUG-028、BUG-029 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-031 | 生时纠正未收敛仍永久扣费且事件事实未进入评分契约 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时纠正计费终态、事件评分输入、候选计算指纹、用户结果说明 +- 用户现象:用户完成多领域问答后只得到宽候选范围,当前排盘时间没有更新,但已永久扣除点数;同时用户描述的具体经历虽然出现在对话记录中,传给分钟候选评分器时只剩领域和日期。 +- 触发条件:公开分钟盲测门禁尚未通过,流程按范围结束或用户主动放弃;以及可评分经历带有具体事件摘要时进入生产评分路径。 +- 根因:启动结算在候选计算完成后立即把预留点数标记为 `charged`,范围终态和放弃终态没有对应退款;前端评分载荷与 Python 输入规范只序列化 `id/domain/date/precision`,丢弃 `eventSummary`,导致不同事实可能共享同一规范输入。 +- 修复:新增向前迁移,在范围完成或主动放弃的同一数据库事务中将已收费状态幂等转换为 `released`、退回点数并更新动作回执;只有通过分钟门禁且用户明确确认的分钟保留收费。评分载荷新增可选 `summary`,Python API 规范化并限制长度,候选输入契约升级为 `rectification-candidate-input-v2`,把具体事件摘要纳入 canonical hash。用户终态文案明确说明未纠正成功、本次不计费、候选代表时间不会替换当前排盘时间。 +- 验证:前端生时纠正路由、编排器、存储和旅程引擎聚焦测试 77 条通过;Python 对话数据库契约、事件 API 与候选评分测试 67 条通过。回归明确断言事件摘要变化会改变 canonical input hash,但 `can_apply`、`confirmation_allowed` 和 `can_narrow_to_minute` 仍保持关闭;SQL 契约断言范围结束与放弃均在终态事务内退款,且迁移不写入 `active_birth_time`。 +- 防复发:任何新增事件评分字段都必须进入前端载荷、Python 规范化输入和 canonical hash;任何非 `confirmed` 终态都必须有幂等计费回收断言;不得用候选范围中点或代表时间替换用户的当前排盘分钟。真正分钟确认仍依赖独立来源审计、sealed blind replay、准确率/误确认率及 ±1/2/5 分钟稳定性门禁。 +- 相关记录:BUG-019、BUG-028、BUG-029 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-032 | 生时校正覆盖历史回答且完成后无法返回原问题 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正逐轮问答、Agent 可见叙事、完成态原问题交接 +- 用户现象:多轮回答后,用户经历全部连续显示在右侧,页面只保留最新一条 Agent 回复;Agent 收到具体事项后仍重复模板式说明;完成后点击“返回原问题”没有可见跳转。 +- 触发条件:在同一个生时校正 case 中连续提交两轮以上经历;或从普通咨询问题进入校正并走到完成态。 +- 根因:聊天区直接遍历 `evidenceRecap` 生成全部用户气泡,却只渲染当前 `turn.narrative`,因此旧 Agent 回复天然被覆盖;可见叙事层和编排器在 Agent 生成回答后又用固定进度文案覆盖,且 Agent prompt 没有收到最新具体事件;完成态页面会提前自动领取 continuation,handoff 缺失时又错误地把当前校正 session 当作来源 session。 +- 修复:controller 为当前 case 维护交错的 `assistant/user` 消息序列,成功提交后原子追加用户原话与新 Agent 回复,聊天区只按该序列渲染;可见层优先采用 Agent 原始 narrative,中间轮 prompt 带入最新事件并要求先具体回应再追问,编排器保留通过校验的 Agent 文本;删除完成态自动 continuation,只在用户点击时领取,并按本地 handoff、持久化 return session、最近普通咨询 session 的顺序寻找真实返回目标。 +- 验证:聚焦 controller、Agent narrative、visible narrative、orchestrator 和首页 handoff 测试 89 条通过,覆盖 `assistant → user → assistant` 历史、具体事件进入 prompt、旧模板不覆盖 Agent 回答、仅点击后返回来源 consultation session。组件 SSR 测试在当前 Node 环境仍被仓库既有 GSAP ESM `registerPlugin` 加载错误阻断,尚未进入业务断言。 +- 防复发:主聊天区不得从 evidence recap 反推当前会话消息;任何 Agent narrative 后处理不得抹掉已校验的具体回应;完成态 continuation 不得自动领取,也不得以当前 rectification session 作为来源会话兜底。若需要刷新后永久恢复每轮 Agent 原文,必须扩展服务端 turn/RPC 持久化契约,不能用当前 narrative 冒充完整历史。 +- 相关记录:BUG-018、BUG-019、BUG-028 +- 复发自:BUG-018、BUG-028 +- 修复版本:待提交(本地可测) + +## BUG-033 | 生时校正缺少连续事件语义导致模板式跳问 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正中间轮 Agent prompt、事件纠错与追问顺序、聊天区可见回答 +- 用户现象:用户已经补充某件经历的原因、主动或被动、结果或后续转折,Agent 没有继续处理当前事件,反而重复要求泛化领域事件;通过校验的自然回答后还会追加“累计条数”“下一领域”“本轮区分重点”等模板。 +- 触发条件:同一事件需要两轮以上补齐,历史事件与最新表述存在日期矛盾,或某领域已有证据但当前事件仍缺关键细节。 +- 根因:narrative context 只携带本轮抽取结果,没有完整事件账本、原始表述、纠错状态和未决事实;编排器又无条件把确定性进度模板拼到 Agent narrative 后面,因此模型既无法发现跨轮矛盾,也无法决定应先补完当前事件还是切换领域。 +- 修复:中间轮 narrative context 新增最新用户原话、最近事件账本、有效/失效纠错状态和未决证据;prompt 明确要求先完成当前事件、核对日期冲突、合并同一事件的原因与结果且不得重复计分,每轮只问一个信息量最高的问题;通过 grounding 校验的 Agent narrative 直接作为可见回答,仅在模型 fallback 时保留确定性进度文案。 +- 验证:聚焦测试覆盖完整事件账本进入 prompt、历史日期与最新原话同时可见、连续事件规则进入 output contract、有效 Agent 回答不再追加进度模板,以及 fallback 仍保持安全的一问式文案。测试数据全部为虚构案例,不写入真实用户经历。 +- 防复发:不得把“领域是否出现过”当作切换话题的唯一条件;任何新增对话规划信息必须先进入受限 narrative context,并保持 `minute_holdout_not_ready`、`confirmation_allowed: false`、`can_narrow_to_minute: false` 等分钟安全门禁不变。 +- 相关记录:BUG-028、BUG-029、BUG-032 +- 复发自:BUG-028、BUG-032 +- 修复版本:待提交(本地可测) + +## BUG-034 | 生时校正 Agent 降级只记录 200 导致模板回退不可诊断 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正中间轮自然语言回答、服务端可观测性、网页端响应时延 +- 用户现象:用户提交明确事件后等待约 53 秒,网页仍显示“当前累计”“下一步”“本轮区分重点”等固定进度模板;接口日志只有成功的 200,无法看出 Agent 已经连续两次生成或校验失败。 +- 触发条件:叙事模型调用抛错、输出不是契约 JSON,或输出未通过 packet grounding 校验并在第二次重试后降级。 +- 根因:`generateRectificationNarrative` 会把所有失败收敛为安全 fallback,但此前仅把失败类型写入持久化 validation receipt,没有输出脱敏运行日志;补充日志后真实网页请求确认了两类误杀:其一,校验器机械要求中文必须逐字包含“已经发生/过去、年、月”,导致语义正确的“后来在什么时候”“年份和月份”等自然问法被丢弃;其二,只要一段自然叙事同时提到多个既有年份并出现“还是”,就会被误判为禁止的宽年份选项问卷,即使“还是”实际在询问毕业方式或职业转折类型。 +- 修复:fallback 边界保留结构化脱敏日志;日期请求改为识别“发生、后来、开始、毕业、入职、离职”等历史语义和“什么时候、年份、月份”等日期语义,缺少硬边界时只修补内部请求,不再用固定模板覆盖 Agent 正文;宽年份问卷仅拦截明确的年份二选一、A/B 年份选项或年份区间选择,不再因正文包含多个已知事件年份而误杀;当 Agent 的确认段只有说明、没有面向用户的具体问题时,将内部 `evidenceRequest.prompt` 投影到聊天气泡,且避免重复追加制度化提示。 +- 验证:聚焦 narrative、orchestrator、route 测试 76/76 通过;新增“多个已知年份 + 非年份还是问句”与“说明需要更多信息但没有直接问题”的回归覆盖;真实网页账号完整保留历史气泡,2017 年入职和 2020 年主动离职均得到针对事件内容的自然确认,后者明确追问“离职后下一份工作或职业转向在何年何月开始”,没有再次落入“当前累计/下一步/本轮区分重点”模板。测试前先释放未确认校正费用并清理校正数据,点数从 207 恢复到 208;新测试正常收取 1 点后为 207。 +- 防复发:叙事 fallback 必须同时保留安全用户文案、持久化回执和不含个人数据的运行时诊断;HTTP 200 不得作为自然语言 Agent 正常工作的唯一证据;禁止年份选项问卷的规则必须判断年份之间的选择结构,不能使用全文“出现多个年份 + 任意还是”作为替代。 +- 相关记录:BUG-032、BUG-033 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-035 | 生时校正发送错误内容后无法撤回修改 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正文字输入、事件证据提交与对话历史 +- 用户现象:用户发现刚发送的经历写错后只能等待 Agent 完成本轮,再通过下一轮更正;输入框在生成期间被锁定,错误内容可能直接进入事件账本。 +- 触发条件:在生时校正中提交自由文本回答后立即发现日期或事实写错。 +- 根因:校正回答会立即调用持久化命令,界面只有发送和等待状态,没有普通 session 已有的发送撤回窗口;仅中止浏览器请求也不能保证服务端停止保存。 +- 修复:复用普通 session 的安全语义,在真正发起校正命令前提供 2.5 秒撤回窗口;发送后立即显示用户气泡和停止按钮,撤回时取消定时任务、移除临时气泡、把原文恢复到输入框并重新聚焦。只有窗口结束后才调用校正接口,因此成功撤回的本轮不会生成、不会写入证据历史,也不计入校正任务。 +- 验证:新增真实 Chromium 回归断言,覆盖发送、停止、草稿恢复和延时后未调用 `answer`;本地登录账号手测中,唯一测试文本撤回后仍保留在输入框,等待超过窗口后未进入“正在核对”、未出现在用户历史消息中。目标文件 ESLint 与 `git diff --check` 通过;独立 Node 组件套件当前仍被既有 GSAP Node 导入错误阻断。 +- 防复发:撤回必须发生在业务命令发出前;不得把客户端 `fetch.abort()` 误认为服务端事务已取消。 +- 相关记录:BUG-018、BUG-032 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-036 | Agent 化生时校正上线前被旧模板组件断言阻断 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生产发布门禁、生时校正前端组件测试 +- 用户现象:最新 `main` 已保留 Agent 自然语言回复与历史气泡,但生产测试门禁失败,无法进入部署。 +- 触发条件:运行生产 `Jyotish Skill Tests` 的完整前端测试套件。 +- 根因:组件测试仍断言旧版确定性模板会覆盖 Agent 叙事、经历摘要带固定“已记录”前缀,并直接调用无撤回窗口的提交表达式;真实 Chromium 等待条件也仍匹配旧文案。 +- 修复:更新 5 条过期断言,使其验证 Agent 原始叙事、折叠进度中的经历摘要、2.5 秒撤回后提交路径和当前移动端文案;不回退现有业务实现。 +- 验证:生时校正组件测试、完整前端测试、生产质量门禁与生产部署工作流;以对应提交 SHA 的线上健康检查为最终验收。 +- 防复发:对话呈现测试应验证用户可见契约,不再把旧模板句式或内部调用参数写成不必要的固定实现约束。 +- 相关记录:BUG-034、BUG-035 +- 复发自:无 +- 修复版本:待提交 + +## BUG-037 | 生时校正刷新后把真实对话重建成“已记录”模板 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正历史持久化、刷新/重新进入恢复、Agent 一问一答呈现 +- 用户现象:同一轮实时回答时 Agent 会针对经历自然追问,但刷新页面或重新进入生时校正后,旧回复全部变成“已记录这段经历:……”;用户原话也被日期和事件摘要替代,看起来像 Agent 又退回固定模板。 +- 触发条件:已有两轮以上生时校正回答后刷新网页、从首页重新进入,或在当前页面同步一个更新的持久化案例。 +- 根因:数据库已保存每轮 Agent `narrative`,但恢复 RPC 只返回 `latest_turn`;前端为了补齐历史,使用 `evidenceRecap` 机械合成用户气泡和“已记录”助手气泡。实时链路使用内存中的原话与真实 narrative,恢复链路却使用另一套有损数据源,导致刷新前后表现不一致。 +- 修复:为校正 turn 向前新增受约束的 nullable `user_message`,answer 保存事务同时持久化用户原话;新增仅限 `service_role` 的 history load/save/completion wrapper RPC,按轮次返回最近 200 轮 `userMessage + narrative`;客户端响应契约支持恢复历史,首次加载和同案例重新同步均直接渲染原始一问一答。旧记录只在有明确原始 evidence 时回填用户文本;无法恢复的旧轮次只显示真实最新 Agent narrative,不再伪造模板。 +- 验证:控制器回归覆盖首次恢复、旧数据 fallback、同案例重新同步和“不得出现已记录这段经历”;聚焦校正测试 90/90 通过;本地 PostgreSQL 从头应用全部迁移成功,并验证新列与 history RPC 的 `service_role`/`authenticated` 权限边界。测试内容均为虚构经历,不写入真实用户资料。 +- 防复发:任何对话历史必须从持久化的原始 user/assistant turn 恢复;事件摘要只能用于进度和评分,不得反向伪造聊天内容。网页验收必须同时检查实时回答和刷新后的同一历史。 +- 相关记录:BUG-032、BUG-034、BUG-036 +- 复发自:BUG-032 +- 修复版本:待提交 + +## BUG-038 | 老案例摘要被误标成原始对话且第七条事件后无法继续 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生产生时校正老案例恢复、累计 7–8 条可评分事件后的继续问答 +- 用户现象:部署历史恢复修复后,老案例刷新仍显示“已记录这段经历”;继续回答一条明确事件后,页面提示暂时无法继续并把文本退回草稿。 +- 触发条件:案例来自原始消息持久化上线之前,且旧事件 evidence 能提供规范化 `raw_text`;或累计可评分事件超过 6 条。 +- 根因:首版迁移把旧 evidence 的规范化摘要回填到 `user_message`,却没有标记它不是逐字捕获的聊天原文,恢复 RPC 因而把合成摘要当成真实历史;产品收敛上限允许 8 条事件,但 Python 事件评分 API 仍只接受最多 6 条,前端第 7 条后稳定收到 400。 +- 修复:为 turn 增加 `user_message_captured` 来源标记,只有 answer 事务当场保存的逐字用户文本才进入可见历史;旧案例没有可靠原文时只显示真实最新 Agent narrative,不再展示伪造气泡。事件评分 API 上限与产品常量统一为 8,并增加八事件回归。 +- 验证:迁移从零应用并检查来源标记与 RPC 权限;八事件 API 测试、聚焦对话测试、生产构建与真实生产浏览器继续问答/刷新/重新进入 smoke。 +- 防复发:消息历史必须携带来源可信度,事件 evidence 不得默认等价于聊天原文;跨服务的事件数量上限必须由同一契约测试锁定。 +- 相关记录:BUG-034、BUG-037 +- 复发自:BUG-037 +- 修复版本:0850619eaf5002736542463394720b0ab1949ce9 + +## BUG-039 | 生时校正 Agent 回答等待结束后一次性出现 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正首次进入、回答传输、Agent 生成状态、移动端对话可读性 +- 用户现象:首次进入时只显示“正在建立校正记录”,看不到后台生成的第一条 Agent 引导;用户发送经历后也只能看到“正在核对星盘信息”,待模型、校验和保存全部完成后,Agent 整段回答一次性出现,与普通 session 的逐段生成体验不一致。 +- 触发条件:任意生时校正 `start`、`resume` 或 `answer` 命令成功返回自然语言 narrative。 +- 根因:`/api/birth-time-conversation` 固定使用 `Response.json(turn)`,客户端也完整读取并校验 JSON 后才更新控制器;即使 narrative 已生成,传输层和聊天渲染层都没有增量事件契约。首次入口另由首页直接执行 `start/resume`,没有把生成中的首条 narrative 投影给尚未初始化的校正组件。 +- 修复:成功响应在客户端声明支持时改用 `application/x-ndjson`;服务端只对已经完成技法校验并持久化的 narrative 分块发送 `delta`,最后发送完整 durable turn。客户端逐行解析并即时投影到同一个 assistant 气泡,首次 `start/resume` 也把增量引导传入空白校正界面;最终 turn 到达后无缝转为 settled 历史。错误响应继续使用既有安全 JSON,已输出 delta 的中断不自动重放,避免重复文字。代理技法校验、事件事务和分钟安全门禁均保持在流输出之前。 +- 验证:新增 route 分块顺序与禁用代理缓冲回归、client NDJSON 增量解析回归、controller 在 durable turn 未到达前发布流式文本回归、组件 streaming 气泡回归;聚焦校正测试、真实 Chromium 390px 组件测试、ESLint、TypeScript 与 production build 通过。 +- 防复发:生时校正成功响应不得退回一次性 JSON 作为唯一前端路径;可见增量内容必须来自已校验 narrative,不得直接透传未完成或未通过约束的模型 token。 +- 相关记录:BUG-034、BUG-037、BUG-038 +- 复发自:无 +- 修复版本:本次流式修复提交 + +## BUG-040 | Agent 活动状态与回答被错误渲染为互斥状态 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:普通 session 与生时校正共用的 Agent 消息行、流式回答反馈、完成态识别 +- 用户现象:Agent 尚未产出正文时只显示 orb 活动状态;开始出现正文后状态与答案的关系不连续,完成后活动状态直接消失,用户无法从同一消息位置判断回答仍在生成还是已经结束。 +- 触发条件:任意 assistant 消息在 `thinking`、`streaming`、`settled` 三种视图状态之间切换。 +- 根因:共享 `ChatMessageRow` 使用条件分支把 `thinking` 渲染为仅有 `AgentActivityStatus`,又只在 `streaming` 时显示 composing 状态;`settled` 分支只保留正文,因此活动状态和正文被实现成了互斥内容,而不是同一消息的连续生命周期。 +- 修复:assistant 消息始终在同一气泡内先渲染活动状态,再渲染当前已有正文;thinking/streaming 继续使用 working/composing orb,settled 只把状态文案切换为“回答已完成”,并把 orb 替换为 Lucide 完成图标。普通 session 与生时校正共享该行为,React 消息 identity 与正文内容保持不变。 +- 验证:消息视图回归覆盖 working、composing、completed 三态、正文与状态同时存在、完成态 Lucide 图标;相关普通 session 与生时校正契约测试通过,并执行真实浏览器检查。 +- 防复发:Agent 活动状态只能描述消息生命周期,不得控制正文是否渲染;完成态必须保留稳定的视觉反馈,不能以卸载整个状态区域代替状态转换。 +- 相关记录:BUG-039 +- 复发自:无 +- 修复版本:`4a9b1dc` + +## BUG-041 | 生时校正生成回答时消息列表不自动跟随到底部 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正消息历史、用户发送后的撤回窗口、Agent thinking / streaming / settled 回答阶段 +- 用户现象:用户发送经历或 Agent 开始生成回答后,新内容出现在消息列表下方,但列表停留在旧位置;用户必须手动向下滚动才能看到生成状态和最新回答。 +- 触发条件:生时校正已有足够内容使独立消息容器产生纵向滚动,然后发送新经历或接收流式 Agent 回答。 +- 根因:普通 session 有独立的底部跟随 effect,生时校正虽然使用可滚动的 `.rectification-message-list`,但组件没有保存该容器的 ref,也没有在消息、提交状态、流式文本或最终 turn 更新时调整容器滚动位置。 +- 修复:为生时校正消息容器增加专用 ref,并在用户消息、生成状态、流式增量、错误及最终 turn 生命周期变化后滚动该容器到底部;生成过程中直接跟随,完成后平滑定位,且尊重 reduced-motion,不调用会牵动外层页面的 `scrollIntoView`。 +- 验证:真实 Chromium 390px 回归制造独立消息容器 overflow,并分别断言初始历史、乐观用户消息、流式 Agent 增量和完成回答都保持在底部;相关聚焦测试、ESLint、TypeScript 与生产 smoke。 +- 防复发:生时校正的消息生命周期新增阶段必须进入底部跟随依赖;真实浏览器测试必须验证容器自身的 `scrollTop`,不能只检查正文是否出现在 DOM。 +- 相关记录:BUG-039、BUG-040 +- 复发自:无 +- 修复版本:本次自动滚动修复提交 + +## BUG-042 | 生时校正沿用系统滚动条导致视觉割裂 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正独立消息列表的桌面端滚动反馈 +- 用户现象:生时校正右侧直接显示浏览器或操作系统默认滚动条,与站内温和、低对比的编辑式界面不一致。 +- 触发条件:生时校正历史内容超过消息区高度,在桌面浏览器出现纵向滚动条。 +- 根因:`.rectification-message-list` 只声明了 `overflow-y: auto`,没有提供跨浏览器的产品内滚动条颜色、宽度和交互状态。 +- 修复:为生时校正消息区增加细宽、透明轨道、低对比圆角拇指;默认隐藏拇指,仅在指针移动、列表滚动或键盘焦点进入消息区时短暂显示,停止操作后自动隐藏。Firefox 使用标准 scrollbar 属性,Chromium / Safari 使用 WebKit 伪元素,颜色继续取现有设计 token。 +- 验证:CSS 契约锁定默认透明、交互显现及标准与 WebKit 两套样式,聚焦组件测试、目标 ESLint、production build 与生产浏览器 smoke。 +- 防复发:独立滚动容器必须复用产品 token,并同时覆盖标准 scrollbar 属性和 WebKit 伪元素;默认态不得持续抢占视觉注意力,也不得引入渐变、重阴影或高饱和装饰。 +- 相关记录:BUG-041 +- 复发自:无 +- 修复版本:本次简约滚动条修复提交 + +## BUG-043 | 生时校正把后台证据状态重复渲染成可展开管理面板 + +- 状态:resolved +- 首次发现:2026-07-23 +- 最近更新:2026-07-23 +- 影响面:生时校正消息流、候选进度、历史经历与更正入口 +- 用户现象:Agent 已经在自然对话中确认和追问经历,消息区底部仍额外显示“当前候选 · 待验证”、候选范围、历史经历列表和“更正”按钮;用户需要理解并操作第二套记录界面,破坏一问一答的连续性。 +- 触发条件:任何已有候选或至少一条 evidence recap 的生时校正案例。 +- 根因:语言交互改版后仍保留旧产品流程的 `
` 进度与证据管理面板,把本应仅供后台评分和恢复使用的结构化状态再次暴露给用户。 +- 修复:从生时校正消息流移除整块候选进度与历史经历管理面板,不再显示候选状态、范围、经历列表或逐条更正按钮;后台 evidence、评分与持久化保持不变,最终可确认状态仍通过明确确认动作呈现。 +- 验证:组件与真实 Chromium 回归锁定页面不包含候选进度、历史经历面板或更正按钮,同时保留自然对话、流式回答、撤回窗口、自动贴底和最终确认能力。 +- 防复发:结构化 evidence 是 Agent 的后台推理与持久化输入,不得在语言优先界面重复渲染成需要用户管理的卡片或表单;需要纠正时继续通过自然语言表达。 +- 相关记录:BUG-020、BUG-034、BUG-042 +- 复发自:BUG-020 +- 修复版本:待提交 diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index 0b549734..c5084c54 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { conversationalRectificationCommandSchema, type ConversationalRectificationCommand, + type ConversationalRectificationResponse, type ConversationalRectificationTurn, } from "../../../lib/conversational-rectification/contracts.ts"; import { @@ -21,7 +22,10 @@ import { type DeclaredBirthInput, type LifeEventEvidence, } from "../../../lib/conversational-rectification/persistence-contracts.ts"; -import type { RectificationNarrativeGenerator } from "../../../lib/conversational-rectification/narrative-agent.ts"; +import { + rectificationNarrativeOutputSchema, + type RectificationNarrativeGenerator, +} from "../../../lib/conversational-rectification/narrative-agent.ts"; import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts"; import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts"; import type { CandidateDifferenceBuild } from "../../../lib/birth-time-dynamic-choice-internal.ts"; @@ -39,6 +43,53 @@ import { export const runtime = "nodejs"; export const maxDuration = 60; +const RECTIFICATION_STREAM_CONTENT_TYPE = "application/x-ndjson; charset=utf-8"; +const RECTIFICATION_STREAM_CHUNK_SIZE = 10; +const RECTIFICATION_STREAM_CHUNK_DELAY_MS = 18; + +function rectificationNarrativeChunks(narrative: string): string[] { + const characters = Array.from(narrative); + const chunks: string[] = []; + for (let index = 0; index < characters.length; index += RECTIFICATION_STREAM_CHUNK_SIZE) { + chunks.push(characters.slice(index, index + RECTIFICATION_STREAM_CHUNK_SIZE).join("")); + } + return chunks; +} + +export function streamConversationalRectificationResponse( + turn: ConversationalRectificationResponse, + chunkDelayMs = RECTIFICATION_STREAM_CHUNK_DELAY_MS, +): Response { + const encoder = new TextEncoder(); + let cancelled = false; + const body = new ReadableStream({ + async start(controller) { + try { + for (const text of rectificationNarrativeChunks(turn.narrative)) { + if (cancelled) return; + controller.enqueue(encoder.encode(`${JSON.stringify({ type: "delta", text })}\n`)); + if (chunkDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, chunkDelayMs)); + } + if (cancelled) return; + controller.enqueue(encoder.encode(`${JSON.stringify({ type: "turn", turn })}\n`)); + controller.close(); + } catch (error) { + if (!cancelled) controller.error(error); + } + }, + cancel() { + cancelled = true; + }, + }); + return new Response(body, { + headers: { + "Cache-Control": "no-cache, no-transform", + "Content-Type": RECTIFICATION_STREAM_CONTENT_TYPE, + "X-Accel-Buffering": "no", + }, + }); +} + const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology"); @@ -353,6 +404,7 @@ function scoreableLifeEvents( domain: item.domain, precision: item.datePrecision as "day" | "month" | "year", date: item.dateValue, + summary: item.eventSummary, } as LifeEvent]; }).slice(-MAXIMUM_SCOREABLE_EVENTS); } @@ -671,13 +723,21 @@ async function productionNarrativeGenerator(): Promise(null); const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); - const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); + const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); + const [rectificationOpeningAssistantText, setRectificationOpeningAssistantText] = useState(""); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); const [rectificationMutationPending, setRectificationMutationPending] = useState(false); @@ -859,8 +863,6 @@ export default function Home() { const activeOnboardingRequestIdentity = useRef(""); const accountRefreshGuard = useRef(createLatestAccountRequestGuard()); const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator()); - const continueRectificationQuestion = useRef<(question: string) => void>(() => undefined); - const automaticRectificationContinuation = useRef(""); const durableRectificationQuestionHandoff = useRef( createDurableRectificationQuestionHandoffClient(), ); @@ -1950,6 +1952,7 @@ export default function Home() { setDraftEntrypoint(null); setRectificationPendingQuestion(requestedQuestion); setRectificationInitialTurn(null); + setRectificationOpeningAssistantText(""); setRectificationError(""); setRectificationLoading(true); if (!reusingRectificationSession) { @@ -1965,7 +1968,7 @@ export default function Home() { activeSessionIdRef.current = rectificationSession.id; setActiveSessionId(rectificationSession.id); try { - let turn: ConversationalRectificationTurn; + let turn: ConversationalRectificationResponse; if (!resumeTarget) { const durable = await durableRectificationQuestionHandoff.current.load(); turn = durable && durable.status !== "consumed" @@ -1974,6 +1977,10 @@ export default function Home() { type: "start", actionId: globalThis.crypto.randomUUID(), pendingConsultationQuestion: requestedQuestion, + }, { + onNarrativeDelta(text) { + setRectificationOpeningAssistantText((current) => current + text); + }, }); } else { let current = resumeTarget; @@ -2006,6 +2013,10 @@ export default function Home() { caseId: current.caseId, actionId: globalThis.crypto.randomUUID(), turnVersion: current.turnVersion, + }, { + onNarrativeDelta(text) { + setRectificationOpeningAssistantText((value) => value + text); + }, }); } } @@ -2030,6 +2041,7 @@ export default function Home() { } } setRectificationInitialTurn(turn); + setRectificationOpeningAssistantText(""); synchronizeRectificationQuestion(turn, sourceSession); setComposerNotice(sessionSyncFailed ? "校正已经开始,但会话关联暂时未同步到云端。" : ""); } catch (caught) { @@ -2037,6 +2049,7 @@ export default function Home() { ? caught.message : "生时校正暂时无法继续,请稍后重试。"; setRectificationError(message); + setRectificationOpeningAssistantText(""); setComposerNotice(message); if (!reusingRectificationSession) { setSessions((current) => current.filter((session) => session.id !== rectificationSession.id)); @@ -2054,7 +2067,7 @@ export default function Home() { void openBirthTimeRectification(null, session); }; - function handleConversationalRectificationTurn(turn: ConversationalRectificationTurn) { + function handleConversationalRectificationTurn(turn: ConversationalRectificationResponse) { const requestIdentity = accountRefreshGuard.current.begin(); setRectificationInitialTurn(turn); synchronizeRectificationQuestion(turn); @@ -2646,6 +2659,19 @@ export default function Home() { && !sessions.some((session) => session.id === rectificationQuestionHandoff.current.peek()?.sessionId)) { rectificationQuestionHandoff.current.clear(); } + const localHandoff = rectificationQuestionHandoff.current.peek(); + const returnSession = (localHandoff + ? sessions.find((session) => session.id === localHandoff.sessionId) + : null) + ?? (rectificationReturnSessionId + ? sessions.find((session) => session.id === rectificationReturnSessionId) + : null) + ?? sessions.find((session) => session.sessionType === "consultation") + ?? null; + if (!returnSession) { + setComposerNotice("没有找到原问题所在的会话,请从会话列表打开原问题后重试。"); + return; + } rectificationContinuationInFlight.current = true; setRectificationContinuationPending(true); @@ -2661,12 +2687,8 @@ export default function Home() { return; } if (durableClaim.status === "consumed") { - const returnSessionId = rectificationQuestionHandoff.current.peek()?.sessionId - ?? rectificationReturnSessionId; - if (returnSessionId && sessions.some((session) => session.id === returnSessionId)) { - activeSessionIdRef.current = returnSessionId; - setActiveSessionId(returnSessionId); - } + activeSessionIdRef.current = returnSession.id; + setActiveSessionId(returnSession.id); setRectificationPendingQuestion(null); setComposerNotice("原问题已经继续回答,不会再次发送或扣点。"); return; @@ -2677,7 +2699,7 @@ export default function Home() { } const completed = await rectificationQuestionHandoff.current.continueOriginalQuestion( question, - { sessionId: activeSession.id, theme: activeSession.theme }, + { sessionId: returnSession.id, theme: returnSession.theme }, async (context) => { activeSessionIdRef.current = context.sessionId; setActiveSessionId(context.sessionId); @@ -2716,22 +2738,6 @@ export default function Home() { } } - continueRectificationQuestion.current = (question) => { - void continueRectificationOriginalQuestion(question); - }; - - useEffect(() => { - const turn = rectificationInitialTurn; - const question = turn?.pendingConsultationQuestion; - if (!turn || turn.status !== "completed" || !question - || !turn.actions.includes("continue_original_question") - || rectificationLoading || rectificationMutationPending) return; - const continuationIdentity = `${turn.caseId}:${turn.turnVersion}:${question}`; - if (automaticRectificationContinuation.current === continuationIdentity) return; - automaticRectificationContinuation.current = continuationIdentity; - continueRectificationQuestion.current(question); - }, [rectificationInitialTurn, rectificationLoading, rectificationMutationPending]); - useGSAP(() => { if (!starterHomeVisible || !starterWorkbench.current) return; const motion = gsap.matchMedia(); @@ -3090,6 +3096,7 @@ export default function Home() { ) : ( ; + completed: "回答已完成", +} as const satisfies Record; + +export type AgentActivityState = OrbState | "completed"; export function AgentActivityStatus({ state, label = labels[state], }: Readonly<{ - state: OrbState; + state: AgentActivityState; label?: string; }>) { return ( -
-