merge: update main before timing rectification sync
This commit is contained in:
+257
-1
@@ -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 的生时校正案例。
|
||||
- 根因:语言交互改版后仍保留旧产品流程的 `<details>` 进度与证据管理面板,把本应仅供后台评分和恢复使用的结构化状态再次暴露给用户。
|
||||
- 修复:从生时校正消息流移除整块候选进度与历史经历管理面板,不再显示候选状态、范围、经历列表或逐条更正按钮;后台 evidence、评分与持久化保持不变,最终可确认状态仍通过明确确认动作呈现。
|
||||
- 验证:组件与真实 Chromium 回归锁定页面不包含候选进度、历史经历面板或更正按钮,同时保留自然对话、流式回答、撤回窗口、自动贴底和最终确认能力。
|
||||
- 防复发:结构化 evidence 是 Agent 的后台推理与持久化输入,不得在语言优先界面重复渲染成需要用户管理的卡片或表单;需要纠正时继续通过自然语言表达。
|
||||
- 相关记录:BUG-020、BUG-034、BUG-042
|
||||
- 复发自:BUG-020
|
||||
- 修复版本:待提交
|
||||
|
||||
@@ -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<Uint8Array>({
|
||||
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<RectificationNarrativeGen
|
||||
name: "Conversational Rectification Narrator",
|
||||
model: model.model,
|
||||
skills: [jyotishSkillPath],
|
||||
instructions: "Return only the exact JSON object requested by the user prompt. Use the Jyotish Skill only to choose a natural, one-question-at-a-time evidence strategy and wording. Treat supplied packet facts as the exclusive source of candidate times, status, dates, layers, scores, references, and confirmation permissions. Never invent, recalculate, or confirm candidate data.",
|
||||
instructions: "Return only the exact JSON object requested by the user prompt. Load the Jyotish Skill to choose a natural, one-question-at-a-time evidence strategy and to explain the supplied expert workflow. Treat supplied packet facts as the exclusive source of candidate times, status, dates, layers, scores, technique states, references, and confirmation permissions. Never invent, recalculate, or confirm candidate data. A blocked or not_evaluated technique must never be described as used. On final turns, include a concise user-readable Technique Audit Table from packet.expertWorkflow; on collecting turns, keep the reply conversational and ask exactly one next question.",
|
||||
});
|
||||
return {
|
||||
modelId: model.id,
|
||||
async generate(prompt) {
|
||||
const result = await agent.generate([{ role: "user", content: prompt }]);
|
||||
return { text: result.text };
|
||||
const result = await agent.generate(
|
||||
[{ role: "user", content: prompt }],
|
||||
{
|
||||
structuredOutput: {
|
||||
schema: rectificationNarrativeOutputSchema,
|
||||
jsonPromptInjection: "inline",
|
||||
},
|
||||
},
|
||||
);
|
||||
return { text: JSON.stringify(rectificationNarrativeOutputSchema.parse(result.object)) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -880,7 +940,9 @@ export function createBirthTimeConversationPostHandler(
|
||||
errorCategory: "none",
|
||||
deploymentSha,
|
||||
});
|
||||
return Response.json(turn);
|
||||
return request.headers.get("accept")?.includes("application/x-ndjson")
|
||||
? streamConversationalRectificationResponse(turn)
|
||||
: Response.json(turn);
|
||||
} catch (error) {
|
||||
const publicError = toConversationalRectificationPublicError(error);
|
||||
const outcome = service ? conversationalRectificationTelemetryOutcome(service) : null;
|
||||
|
||||
@@ -165,7 +165,7 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
.message-markdown ul, .message-markdown ol { margin: 10px 0 16px; padding-left: 24px; }
|
||||
.message-markdown li { margin: 6px 0; }
|
||||
.agent-activity-status { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.5; }
|
||||
.agent-activity-status canvas { flex: 0 0 auto; }
|
||||
.agent-activity-status canvas, .agent-activity-status svg { flex: 0 0 auto; }
|
||||
.agent-activity-status + .message-markdown { margin-top: var(--space-2); }
|
||||
.composer-suggestions::-webkit-scrollbar { display: none; }
|
||||
.composer textarea::placeholder { color: var(--color-ink-tertiary); }
|
||||
@@ -782,20 +782,46 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
/* Conversational rectification shell */
|
||||
.conversation.is-rectification { overflow: hidden; padding-bottom: 0; }
|
||||
.rectification-chat { height: 100%; min-height: 0; display: flex; flex-direction: column; color: var(--color-ink); }
|
||||
.rectification-message-list { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--space-6); }
|
||||
.rectification-message-details { margin-top: var(--space-3); padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.rectification-message-details summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-size: var(--type-body-sm); font-weight: 600; }
|
||||
.rectification-message-details p { margin: 0 0 var(--space-2); font-size: var(--type-caption); line-height: 1.6; }
|
||||
.rectification-message-details ul { display: grid; gap: var(--space-2); margin: var(--space-3) 0 0; padding: 0; list-style: none; }
|
||||
.rectification-message-details li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding-top: var(--space-2); border-top: 1px solid var(--color-border); }
|
||||
.rectification-message-details button, .rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; }
|
||||
.rectification-message-list {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding-bottom: var(--space-6);
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.rectification-message-list.is-scrollbar-visible,
|
||||
.rectification-message-list:focus-within {
|
||||
scrollbar-color: color-mix(in srgb, var(--color-ink-tertiary) 46%, transparent) transparent;
|
||||
}
|
||||
.rectification-message-list::-webkit-scrollbar { width: 10px; }
|
||||
.rectification-message-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.rectification-message-list::-webkit-scrollbar-thumb {
|
||||
min-height: 40px;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
background-clip: padding-box;
|
||||
transition: background-color 160ms ease;
|
||||
}
|
||||
.rectification-message-list.is-scrollbar-visible::-webkit-scrollbar-thumb,
|
||||
.rectification-message-list:focus-within::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--color-ink-tertiary) 42%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.rectification-message-list::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--color-ink-secondary) 62%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; }
|
||||
.rectification-composer-wrap { position: static; flex: 0 0 auto; }
|
||||
.rectification-correction-target { width: min(760px, 100%); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: 0 auto var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); }
|
||||
.rectification-correction-target p { margin: 0; }
|
||||
.conversational-empty-state { width: min(760px, 100%); margin: var(--space-8) auto; color: var(--color-ink-secondary); }
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.rectification-message-details li { align-items: stretch; grid-template-columns: minmax(0, 1fr); }
|
||||
.rectification-correction-target { align-items: stretch; display: grid; }
|
||||
}
|
||||
|
||||
|
||||
+36
-29
@@ -49,7 +49,10 @@ import {
|
||||
} from "@/lib/birth-time-consultation-consent";
|
||||
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
|
||||
import { sendConversationalRectificationCommand } from "@/lib/conversational-rectification/client";
|
||||
import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts";
|
||||
import type {
|
||||
ConversationalRectificationResponse,
|
||||
ConversationalRectificationTurn,
|
||||
} from "@/lib/conversational-rectification/contracts";
|
||||
import {
|
||||
createDurableRectificationQuestionHandoffClient,
|
||||
createRectificationQuestionHandoffCoordinator,
|
||||
@@ -818,7 +821,8 @@ export default function Home() {
|
||||
);
|
||||
const [rectificationSessionId, setRectificationSessionId] = useState<string | null>(null);
|
||||
const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState<string | null>(null);
|
||||
const [rectificationInitialTurn, setRectificationInitialTurn] = useState<ConversationalRectificationTurn | null>(null);
|
||||
const [rectificationInitialTurn, setRectificationInitialTurn] = useState<ConversationalRectificationResponse | null>(null);
|
||||
const [rectificationOpeningAssistantText, setRectificationOpeningAssistantText] = useState("");
|
||||
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(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<Theme>());
|
||||
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() {
|
||||
) : (
|
||||
<ConversationalBirthTimeRectification
|
||||
initialTurn={visibleRectificationTurn}
|
||||
openingAssistantText={rectificationOpeningAssistantText}
|
||||
pendingConsultationQuestion={rectificationPendingQuestion}
|
||||
continuationPending={rectificationContinuationPending}
|
||||
onPendingChange={setRectificationMutationPending}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { CircleCheck } from "lucide-react";
|
||||
import { ThinkingOrb, type OrbState } from "thinking-orbs";
|
||||
|
||||
const labels = {
|
||||
@@ -7,18 +8,23 @@ const labels = {
|
||||
listening: "正在聆听…",
|
||||
composing: "正在组织回答…",
|
||||
shaping: "正在生成结果…",
|
||||
} as const satisfies Record<OrbState, string>;
|
||||
completed: "回答已完成",
|
||||
} as const satisfies Record<OrbState | "completed", string>;
|
||||
|
||||
export type AgentActivityState = OrbState | "completed";
|
||||
|
||||
export function AgentActivityStatus({
|
||||
state,
|
||||
label = labels[state],
|
||||
}: Readonly<{
|
||||
state: OrbState;
|
||||
state: AgentActivityState;
|
||||
label?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div className="agent-activity-status" role="status">
|
||||
<ThinkingOrb aria-hidden="true" state={state} size={20} />
|
||||
<div className="agent-activity-status" role={state === "completed" ? undefined : "status"}>
|
||||
{state === "completed"
|
||||
? <CircleCheck aria-hidden="true" size={20} strokeWidth={1.8} />
|
||||
: <ThinkingOrb aria-hidden="true" state={state} size={20} />}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,12 +50,17 @@ export function ChatMessageRow({ message }: { readonly message: ChatMessageView
|
||||
<div className="message-content">
|
||||
<div className="message-bubble">
|
||||
{message.role === "assistant" ? (
|
||||
message.state === "thinking"
|
||||
? <AgentActivityStatus state="working" label="正在核对星盘信息…" />
|
||||
: <>
|
||||
{message.state === "streaming" && <AgentActivityStatus state="composing" />}
|
||||
<ChatMessageContent text={message.text} />
|
||||
</>
|
||||
<>
|
||||
<AgentActivityStatus
|
||||
state={message.state === "thinking"
|
||||
? "working"
|
||||
: message.state === "streaming"
|
||||
? "composing"
|
||||
: "completed"}
|
||||
label={message.state === "thinking" ? "正在核对星盘信息…" : undefined}
|
||||
/>
|
||||
{message.text && <ChatMessageContent text={message.text} />}
|
||||
</>
|
||||
) : <p>{message.text}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,99 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ChatMessageContent } from "./chat-message-content.tsx";
|
||||
import { AgentAvatar, ChatMessageRow } from "./chat-message-row.tsx";
|
||||
import { ArrowUp, Square } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ChatMessageRow } from "./chat-message-row.tsx";
|
||||
import { Button } from "./ui/button.tsx";
|
||||
import { Textarea } from "./ui/textarea.tsx";
|
||||
import {
|
||||
useConversationalRectification,
|
||||
type ConversationalRectificationController,
|
||||
} from "../hooks/use-conversational-rectification.ts";
|
||||
import type { ConversationalRectificationTurn } from "../lib/conversational-rectification/contracts.ts";
|
||||
|
||||
type EvidenceDomain = NonNullable<
|
||||
ConversationalRectificationTurn["evidenceRequest"]
|
||||
>["domains"][number];
|
||||
|
||||
const domainLabels = {
|
||||
career: "事业与身份",
|
||||
education: "学业与学习",
|
||||
finance: "收入与资产",
|
||||
health_pressure: "健康与重大压力",
|
||||
relocation: "搬迁与居住地",
|
||||
relationship: "重要关系",
|
||||
family: "家庭变化",
|
||||
other: "其他关键经历",
|
||||
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
|
||||
|
||||
function nextEvidenceDomains(turn: ConversationalRectificationTurn): EvidenceDomain[] {
|
||||
const provided = new Set(turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : []));
|
||||
return [...(turn.evidenceRequest?.domains ?? [])]
|
||||
.sort((left, right) => Number(provided.has(left)) - Number(provided.has(right)))
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
function visibleTurnNarrative(turn: ConversationalRectificationTurn): string {
|
||||
if (["paused", "abandoned", "completed"].includes(turn.status)) {
|
||||
return turn.narrative;
|
||||
}
|
||||
|
||||
const suggestedDomains = nextEvidenceDomains(turn).map((domain) => domainLabels[domain]);
|
||||
if (turn.evidenceRecap.length === 0) {
|
||||
const examples = suggestedDomains.length > 0
|
||||
? suggestedDomains.join("或")
|
||||
: "工作、搬迁、关系或学业";
|
||||
return `为了帮助校正出生时间,请先说一件${examples}方面已经发生的重要经历。最好带上年月,直接像聊天一样描述即可。`;
|
||||
}
|
||||
|
||||
const latestEvidence = turn.evidenceRecap.at(-1)!;
|
||||
if (turn.candidate.status === "ready_for_confirmation") {
|
||||
return [
|
||||
`${latestEvidence.isCorrection ? "已修订" : "已记录"}:${latestEvidence.dateLabel} · ${latestEvidence.summary}。`,
|
||||
"目前已经形成一个待确认候选。你可以展开下方详情核对,也可以继续补充一件带年月的真实经历。",
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
const candidateRange = turn.candidate.rangeStart && turn.candidate.rangeEnd
|
||||
? `${turn.candidate.rangeStart}–${turn.candidate.rangeEnd}`
|
||||
: turn.candidate.representativeTime ?? "当前候选范围";
|
||||
const nextQuestion = suggestedDomains.length > 0
|
||||
? `接下来请说一件${suggestedDomains.join("或")}方面已经发生的事,尽量带上年月。`
|
||||
: "接下来请再说一件已经发生的真实经历,尽量带上年月。";
|
||||
return [
|
||||
`${latestEvidence.isCorrection ? "已修订" : "已记录"}:${latestEvidence.dateLabel} · ${latestEvidence.summary}。`,
|
||||
`候选范围现在是 ${candidateRange}。范围暂未变化不代表提交失败,我会结合后续经历继续比较相邻分钟。`,
|
||||
nextQuestion,
|
||||
].join("\n\n");
|
||||
}
|
||||
import type {
|
||||
ConversationalRectificationResponse,
|
||||
} from "../lib/conversational-rectification/contracts.ts";
|
||||
|
||||
type SurfaceProps = Readonly<{
|
||||
controller: ConversationalRectificationController;
|
||||
openingAssistantText?: string;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onContinueOriginalQuestion?: (question: string) => void;
|
||||
}>;
|
||||
|
||||
const ANSWER_UNDO_WINDOW_MS = 2_500;
|
||||
|
||||
function safely(request: Promise<unknown>) {
|
||||
void request.catch(() => undefined);
|
||||
}
|
||||
|
||||
function candidateStatus(turn: ConversationalRectificationTurn): string {
|
||||
if (turn.status === "completed" && turn.candidate.status === "confirmed") return "已确认";
|
||||
if (turn.status === "completed") return "范围已保存,分钟未确认";
|
||||
if (turn.candidate.status === "ready_for_confirmation") return "待确认,尚未验证";
|
||||
return "待验证";
|
||||
}
|
||||
|
||||
export function ConversationalRectificationSurface({
|
||||
controller,
|
||||
openingAssistantText = "",
|
||||
pendingConsultationQuestion,
|
||||
continuationPending = false,
|
||||
onContinueOriginalQuestion,
|
||||
}: SurfaceProps) {
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const messageList = useRef<HTMLDivElement>(null);
|
||||
const scrollbarHideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [submission, setSubmission] = useState<Readonly<{
|
||||
text: string;
|
||||
phase: "undo" | "generating";
|
||||
turnVersion: number;
|
||||
}> | null>(null);
|
||||
const turn = controller.turn;
|
||||
useLayoutEffect(() => {
|
||||
const list = messageList.current;
|
||||
if (!list) return;
|
||||
|
||||
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const followImmediately = controller.pending || submission !== null || prefersReducedMotion;
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (followImmediately) {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
return;
|
||||
}
|
||||
list.scrollTo({ top: list.scrollHeight, behavior: "smooth" });
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [
|
||||
controller.error,
|
||||
controller.messages?.length,
|
||||
controller.pending,
|
||||
controller.streamingAssistantText,
|
||||
submission,
|
||||
turn?.status,
|
||||
turn?.turnVersion,
|
||||
]);
|
||||
useEffect(() => () => {
|
||||
if (undoTimer.current) clearTimeout(undoTimer.current);
|
||||
if (scrollbarHideTimer.current) clearTimeout(scrollbarHideTimer.current);
|
||||
}, []);
|
||||
const pendingQuestion = turn?.status === "completed"
|
||||
? turn.pendingConsultationQuestion
|
||||
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
|
||||
@@ -101,7 +79,14 @@ export function ConversationalRectificationSurface({
|
||||
if (!turn) {
|
||||
return (
|
||||
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
|
||||
<p className="conversational-empty-state" aria-live="polite" role="status">正在建立校正记录…</p>
|
||||
{openingAssistantText
|
||||
? <ChatMessageRow message={{
|
||||
role: "assistant",
|
||||
text: openingAssistantText,
|
||||
renderKey: "rectification-opening-assistant",
|
||||
state: "streaming",
|
||||
}} />
|
||||
: <p className="conversational-empty-state" aria-live="polite" role="status">正在建立校正记录…</p>}
|
||||
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
|
||||
</section>
|
||||
);
|
||||
@@ -114,81 +99,88 @@ export function ConversationalRectificationSurface({
|
||||
const canContinue = turn.actions.includes("continue_original_question")
|
||||
&& Boolean(pendingQuestion)
|
||||
&& Boolean(onContinueOriginalQuestion);
|
||||
const submit = async () => {
|
||||
if (!canAnswer || !controller.draft.trim() || controller.pending) return;
|
||||
try {
|
||||
await controller.answer(undefined, controller.draft.trim());
|
||||
composer.current?.focus();
|
||||
} catch {
|
||||
// The controller keeps the draft and owns the visible error.
|
||||
}
|
||||
const busy = controller.pending || submission !== null;
|
||||
const submit = () => {
|
||||
const text = controller.draft.trim();
|
||||
if (!canAnswer || !text || busy) return;
|
||||
controller.setDraft("");
|
||||
setSubmission({ text, phase: "undo", turnVersion: turn.turnVersion });
|
||||
undoTimer.current = setTimeout(async () => {
|
||||
undoTimer.current = null;
|
||||
setSubmission({ text, phase: "generating", turnVersion: turn.turnVersion });
|
||||
try {
|
||||
await controller.answer(undefined, text);
|
||||
} catch {
|
||||
controller.setDraft(text);
|
||||
} finally {
|
||||
setSubmission(null);
|
||||
composer.current?.focus();
|
||||
}
|
||||
}, ANSWER_UNDO_WINDOW_MS);
|
||||
};
|
||||
const undoSubmission = () => {
|
||||
if (submission?.phase !== "undo") return;
|
||||
if (undoTimer.current) clearTimeout(undoTimer.current);
|
||||
undoTimer.current = null;
|
||||
controller.setDraft(submission.text);
|
||||
setSubmission(null);
|
||||
requestAnimationFrame(() => composer.current?.focus());
|
||||
};
|
||||
const revealScrollbar = () => {
|
||||
const list = messageList.current;
|
||||
if (!list) return;
|
||||
list.classList.add("is-scrollbar-visible");
|
||||
if (scrollbarHideTimer.current) clearTimeout(scrollbarHideTimer.current);
|
||||
scrollbarHideTimer.current = setTimeout(() => {
|
||||
list.classList.remove("is-scrollbar-visible");
|
||||
scrollbarHideTimer.current = null;
|
||||
}, 900);
|
||||
};
|
||||
const hideScrollbar = () => {
|
||||
if (scrollbarHideTimer.current) clearTimeout(scrollbarHideTimer.current);
|
||||
scrollbarHideTimer.current = null;
|
||||
messageList.current?.classList.remove("is-scrollbar-visible");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rectification-chat" aria-busy={controller.pending} aria-label="生时校正对话">
|
||||
<div className="message-list rectification-message-list">
|
||||
<span className="sr-only" aria-live="polite">{controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
|
||||
{turn.evidenceRecap.map((entry) => (
|
||||
<section className="rectification-chat" aria-busy={busy} aria-label="生时校正对话">
|
||||
<div
|
||||
ref={messageList}
|
||||
className="message-list rectification-message-list"
|
||||
onPointerLeave={hideScrollbar}
|
||||
onPointerMove={revealScrollbar}
|
||||
onScroll={revealScrollbar}
|
||||
>
|
||||
<span className="sr-only" aria-live="polite">{submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
|
||||
{(controller.messages ?? [{
|
||||
role: "assistant" as const,
|
||||
text: turn.narrative,
|
||||
renderKey: `assistant-${turn.turnVersion}`,
|
||||
}]).map((message) => (
|
||||
<ChatMessageRow
|
||||
key={entry.id}
|
||||
key={message.renderKey}
|
||||
message={{
|
||||
role: "user",
|
||||
text: `${entry.dateLabel} · ${entry.summary}${entry.isCorrection ? "(已修订)" : ""}`,
|
||||
renderKey: entry.id,
|
||||
role: message.role,
|
||||
text: message.text,
|
||||
renderKey: message.renderKey,
|
||||
state: "settled",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{controller.pending && controller.draft.trim() && (
|
||||
{submission && turn.turnVersion === submission.turnVersion && (
|
||||
<ChatMessageRow
|
||||
message={{ role: "user", text: controller.draft.trim(), renderKey: "pending-evidence", state: "settled" }}
|
||||
message={{ role: "user", text: submission.text, renderKey: "pending-evidence", state: "settled" }}
|
||||
/>
|
||||
)}
|
||||
<article className="message message-assistant" aria-label="Jyotisha">
|
||||
<AgentAvatar />
|
||||
<div className="message-content">
|
||||
<div className="message-bubble">
|
||||
<ChatMessageContent text={visibleTurnNarrative(turn)} />
|
||||
<details className="rectification-message-details">
|
||||
<summary>
|
||||
{turn.candidate.representativeTime
|
||||
? `当前候选 ${turn.candidate.representativeTime} · ${candidateStatus(turn)}`
|
||||
: `校正进度 · ${candidateStatus(turn)}`}
|
||||
</summary>
|
||||
<p>
|
||||
{turn.candidate.rangeStart && turn.candidate.rangeEnd
|
||||
? `候选范围 ${turn.candidate.rangeStart}—${turn.candidate.rangeEnd};已记录 ${turn.evidenceRecap.length} 条经历。`
|
||||
: `已记录 ${turn.evidenceRecap.length} 条经历,尚未缩小候选范围。`}
|
||||
</p>
|
||||
<p>候选只用于继续验证;这一步不会自动采用候选,未经发布门禁与明确确认不会成为当前排盘时间。</p>
|
||||
{turn.evidenceRecap.length > 0 && (
|
||||
<ul aria-label="已记录的真实经历">
|
||||
{turn.evidenceRecap.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<span>{entry.dateLabel} · {entry.summary}{entry.isCorrection ? "(已修订)" : ""}</span>
|
||||
{canAnswer && (
|
||||
<button
|
||||
aria-label={`更正这条经历:${entry.summary}`}
|
||||
disabled={controller.pending}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
controller.beginEvidenceCorrection(entry.id);
|
||||
composer.current?.focus();
|
||||
}}
|
||||
>
|
||||
更正
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{controller.pending && canAnswer && (
|
||||
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
|
||||
controller.streamingAssistantText
|
||||
? <ChatMessageRow message={{
|
||||
role: "assistant",
|
||||
text: controller.streamingAssistantText,
|
||||
renderKey: `assistant-${turn.turnVersion + 1}`,
|
||||
state: "streaming",
|
||||
}} />
|
||||
: <ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
|
||||
)}
|
||||
{turn.status === "paused" && <ChatMessageRow message={{ role: "assistant", text: "校正已暂停,输入与现有证据都已保留。", renderKey: "rectification-paused", state: "settled" }} />}
|
||||
{turn.status === "abandoned" && <ChatMessageRow message={{ role: "assistant", text: "本次校正已放弃,候选时间没有应用。", renderKey: "rectification-abandoned", state: "settled" }} />}
|
||||
@@ -209,7 +201,7 @@ export function ConversationalRectificationSurface({
|
||||
{canConfirm && (
|
||||
<button
|
||||
aria-label={`确认将 ${turn.candidate.representativeTime} 设为当前排盘时间;当前分钟尚未验证`}
|
||||
disabled={controller.pending}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
|
||||
>
|
||||
@@ -218,7 +210,7 @@ export function ConversationalRectificationSurface({
|
||||
)}
|
||||
{canContinue && (
|
||||
<button
|
||||
disabled={controller.pending || continuationPending}
|
||||
disabled={busy || continuationPending}
|
||||
type="button"
|
||||
onClick={() => onContinueOriginalQuestion?.(pendingQuestion!)}
|
||||
>
|
||||
@@ -231,12 +223,12 @@ export function ConversationalRectificationSurface({
|
||||
{controller.correctionTarget && (
|
||||
<div className="rectification-correction-target" role="status">
|
||||
<p>正在更正:{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</p>
|
||||
<button disabled={controller.pending} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
|
||||
<button disabled={busy} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
|
||||
取消更正
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form className="composer" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||
<form className="composer" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<label className="sr-only" htmlFor="conversational-rectification-answer">
|
||||
{controller.correctionTarget ? "输入更正后的经历" : "回答生时校正问题"}
|
||||
</label>
|
||||
@@ -244,7 +236,7 @@ export function ConversationalRectificationSurface({
|
||||
id="conversational-rectification-answer"
|
||||
ref={composer}
|
||||
autoFocus
|
||||
disabled={controller.pending}
|
||||
disabled={busy}
|
||||
maxLength={4_000}
|
||||
placeholder={controller.correctionTarget
|
||||
? "例如:更正为 2020 年 11 月离职"
|
||||
@@ -259,12 +251,20 @@ export function ConversationalRectificationSurface({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button aria-label={controller.pending ? "正在核对" : "发送"} disabled={controller.pending || !controller.draft.trim()} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
{submission?.phase === "undo" ? (
|
||||
<Button aria-label="撤回发送,本次不计入校正" title="撤回发送" size="icon" type="button" onClick={undoSubmission}>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label={busy ? "正在核对" : "发送"} disabled={busy || !controller.draft.trim()} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<div className="composer-footer">
|
||||
<p>{controller.pending ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}</p>
|
||||
<p>{submission?.phase === "undo"
|
||||
? "已发送,2.5 秒内可撤回修改,本次不会计入校正。"
|
||||
: busy ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}</p>
|
||||
</div>
|
||||
</>}
|
||||
</div>}
|
||||
@@ -273,10 +273,11 @@ export function ConversationalRectificationSurface({
|
||||
}
|
||||
|
||||
type ConversationalBirthTimeRectificationProps = Readonly<{
|
||||
initialTurn?: ConversationalRectificationTurn | null;
|
||||
initialTurn?: ConversationalRectificationResponse | null;
|
||||
openingAssistantText?: string;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onTurn?: (turn: ConversationalRectificationTurn) => void;
|
||||
onTurn?: (turn: ConversationalRectificationResponse) => void;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
onContinueOriginalQuestion?: (question: string) => void;
|
||||
}>;
|
||||
@@ -295,6 +296,7 @@ export function ConversationalBirthTimeRectification(props: ConversationalBirthT
|
||||
return (
|
||||
<ConversationalRectificationSurface
|
||||
controller={controller}
|
||||
openingAssistantText={props.openingAssistantText}
|
||||
pendingConsultationQuestion={props.pendingConsultationQuestion}
|
||||
continuationPending={props.continuationPending}
|
||||
onContinueOriginalQuestion={props.onContinueOriginalQuestion}
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
createConversationalRectificationActionRegistry,
|
||||
sendConversationalRectificationCommand,
|
||||
type ConversationalRectificationActionIdentity,
|
||||
type ConversationalRectificationStreamOptions,
|
||||
} from "../lib/conversational-rectification/client.ts";
|
||||
import type {
|
||||
ConversationalRectificationCommand,
|
||||
ConversationalRectificationResponse,
|
||||
ConversationalRectificationTurn,
|
||||
} from "../lib/conversational-rectification/contracts.ts";
|
||||
|
||||
@@ -18,21 +20,52 @@ type EvidenceDomain = NonNullable<
|
||||
>["domains"][number];
|
||||
type EvidenceRecapEntry = ConversationalRectificationTurn["evidenceRecap"][number];
|
||||
|
||||
export type ConversationalRectificationMessage = Readonly<{
|
||||
role: "assistant" | "user";
|
||||
text: string;
|
||||
renderKey: string;
|
||||
}>;
|
||||
|
||||
function assistantText(turn: ConversationalRectificationTurn): string {
|
||||
return turn.narrative.trim();
|
||||
}
|
||||
|
||||
function initialMessages(turn: ConversationalRectificationResponse | null): ConversationalRectificationMessage[] {
|
||||
if (!turn) return [];
|
||||
if (turn.messageHistory?.length) {
|
||||
return turn.messageHistory.flatMap((entry) => [
|
||||
...(entry.userMessage ? [{
|
||||
role: "user" as const,
|
||||
text: entry.userMessage,
|
||||
renderKey: `user-turn-${entry.turnVersion}`,
|
||||
}] : []),
|
||||
{
|
||||
role: "assistant" as const,
|
||||
text: entry.narrative.trim(),
|
||||
renderKey: `assistant-turn-${entry.turnVersion}`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
return [{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }];
|
||||
}
|
||||
|
||||
export type ConversationalRectificationControllerSnapshot = Readonly<{
|
||||
turn: ConversationalRectificationTurn | null;
|
||||
turn: ConversationalRectificationResponse | null;
|
||||
messages?: readonly ConversationalRectificationMessage[];
|
||||
draft: string;
|
||||
selectedDomain: EvidenceDomain | null;
|
||||
correctionTarget: EvidenceRecapEntry | null;
|
||||
pending: boolean;
|
||||
streamingAssistantText?: string;
|
||||
error: string;
|
||||
}>;
|
||||
|
||||
type MutationResult = Promise<ConversationalRectificationTurn | null>;
|
||||
type MutationResult = Promise<ConversationalRectificationResponse | null>;
|
||||
|
||||
export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{
|
||||
getSnapshot(): ConversationalRectificationControllerSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
synchronizeInitialTurn(turn: ConversationalRectificationTurn | null): void;
|
||||
synchronizeInitialTurn(turn: ConversationalRectificationResponse | null): void;
|
||||
setDraft(value: string): void;
|
||||
selectDomain(domain: EvidenceDomain | null): void;
|
||||
beginEvidenceCorrection(evidenceId: string): void;
|
||||
@@ -46,10 +79,13 @@ export type ConversationalRectificationController = ConversationalRectificationC
|
||||
}>;
|
||||
|
||||
type ControllerInput = Readonly<{
|
||||
initialTurn?: ConversationalRectificationTurn | null;
|
||||
send?: (command: ConversationalRectificationCommand) => Promise<ConversationalRectificationTurn>;
|
||||
initialTurn?: ConversationalRectificationResponse | null;
|
||||
send?: (
|
||||
command: ConversationalRectificationCommand,
|
||||
options?: ConversationalRectificationStreamOptions,
|
||||
) => Promise<ConversationalRectificationResponse>;
|
||||
createActionId?: () => string;
|
||||
onTurn?: (turn: ConversationalRectificationTurn) => void;
|
||||
onTurn?: (turn: ConversationalRectificationResponse) => void;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
}>;
|
||||
|
||||
@@ -57,6 +93,7 @@ type Mutation = Readonly<{
|
||||
identity: ConversationalRectificationActionIdentity;
|
||||
command(actionId: string): ConversationalRectificationCommand;
|
||||
clearDraftOnSuccess?: boolean;
|
||||
userMessage?: string;
|
||||
}>;
|
||||
|
||||
type ActiveMutation = Readonly<{
|
||||
@@ -71,10 +108,10 @@ function createLatestControllerInput(initial: ControllerInput) {
|
||||
update(next: ControllerInput) {
|
||||
current = next;
|
||||
},
|
||||
send(command: ConversationalRectificationCommand) {
|
||||
return (current.send ?? sendConversationalRectificationCommand)(command);
|
||||
send(command: ConversationalRectificationCommand, options?: ConversationalRectificationStreamOptions) {
|
||||
return (current.send ?? sendConversationalRectificationCommand)(command, options);
|
||||
},
|
||||
onTurn(turn: ConversationalRectificationTurn) {
|
||||
onTurn(turn: ConversationalRectificationResponse) {
|
||||
current.onTurn?.(turn);
|
||||
},
|
||||
onPendingChange(pending: boolean) {
|
||||
@@ -103,10 +140,12 @@ export function createConversationalRectificationController(
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: ConversationalRectificationControllerSnapshot = {
|
||||
turn: input.initialTurn ?? null,
|
||||
messages: initialMessages(input.initialTurn ?? null),
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
streamingAssistantText: "",
|
||||
error: "",
|
||||
};
|
||||
let activeMutation: ActiveMutation | null = null;
|
||||
@@ -129,9 +168,10 @@ export function createConversationalRectificationController(
|
||||
}
|
||||
};
|
||||
const acceptTurn = (
|
||||
turn: ConversationalRectificationTurn,
|
||||
turn: ConversationalRectificationResponse,
|
||||
clearDraft: boolean,
|
||||
expectedCaseContext: number,
|
||||
userMessage?: string,
|
||||
) => {
|
||||
const current = snapshot.turn;
|
||||
if (caseContext !== expectedCaseContext) return turn;
|
||||
@@ -148,7 +188,15 @@ export function createConversationalRectificationController(
|
||||
: null;
|
||||
patch({
|
||||
turn,
|
||||
messages: userMessage
|
||||
? [
|
||||
...(snapshot.messages ?? []),
|
||||
{ role: "user", text: userMessage, renderKey: `user-${turn.turnVersion}` },
|
||||
{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` },
|
||||
]
|
||||
: snapshot.messages,
|
||||
error: "",
|
||||
streamingAssistantText: "",
|
||||
selectedDomain,
|
||||
correctionTarget,
|
||||
...(clearDraft ? { draft: "" } : {}),
|
||||
@@ -160,7 +208,7 @@ export function createConversationalRectificationController(
|
||||
}
|
||||
return turn;
|
||||
};
|
||||
const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({
|
||||
const recoverLatest = async (turn: ConversationalRectificationResponse) => registry.run({
|
||||
caseId: turn.caseId,
|
||||
turnVersion: turn.turnVersion,
|
||||
operation: "resume",
|
||||
@@ -173,7 +221,7 @@ export function createConversationalRectificationController(
|
||||
}));
|
||||
const run = (mutation: Mutation): MutationResult => {
|
||||
if (activeMutation?.caseContext === caseContext) return activeMutation.promise;
|
||||
patch({ error: "" });
|
||||
patch({ error: "", streamingAssistantText: "" });
|
||||
setPending(true);
|
||||
const turnAtStart = snapshot.turn;
|
||||
const caseContextAtStart = caseContext;
|
||||
@@ -182,11 +230,17 @@ export function createConversationalRectificationController(
|
||||
&& activeMutation?.token === mutationToken;
|
||||
const operation = registry.run(
|
||||
mutation.identity,
|
||||
(actionId) => send(mutation.command(actionId)),
|
||||
(actionId) => send(mutation.command(actionId), {
|
||||
onNarrativeDelta(text) {
|
||||
if (!ownsCurrentContext()) return;
|
||||
patch({ streamingAssistantText: (snapshot.streamingAssistantText ?? "") + text });
|
||||
},
|
||||
}),
|
||||
).then((turn) => acceptTurn(
|
||||
turn,
|
||||
mutation.clearDraftOnSuccess === true,
|
||||
caseContextAtStart,
|
||||
mutation.userMessage,
|
||||
))
|
||||
.catch(async (error: unknown) => {
|
||||
if (turnAtStart && staleTurn(error) && caseContext === caseContextAtStart) {
|
||||
@@ -194,17 +248,20 @@ export function createConversationalRectificationController(
|
||||
const recovered = await recoverLatest(turnAtStart);
|
||||
return acceptTurn(recovered, false, caseContextAtStart);
|
||||
} catch (recoveryError) {
|
||||
if (ownsCurrentContext()) patch({ error: displayError(recoveryError) });
|
||||
if (ownsCurrentContext()) patch({ error: displayError(recoveryError), streamingAssistantText: "" });
|
||||
throw recoveryError;
|
||||
}
|
||||
}
|
||||
if (ownsCurrentContext()) patch({ error: displayError(error) });
|
||||
if (ownsCurrentContext()) patch({ error: displayError(error), streamingAssistantText: "" });
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeMutation?.token !== mutationToken) return;
|
||||
activeMutation = null;
|
||||
if (caseContext === caseContextAtStart) setPending(false);
|
||||
if (caseContext === caseContextAtStart) {
|
||||
patch({ streamingAssistantText: "" });
|
||||
setPending(false);
|
||||
}
|
||||
});
|
||||
activeMutation = {
|
||||
caseContext: caseContextAtStart,
|
||||
@@ -220,8 +277,9 @@ export function createConversationalRectificationController(
|
||||
const currentMutation = (
|
||||
operation: Exclude<ConversationalRectificationCommand["type"], "start">,
|
||||
payload: unknown,
|
||||
command: (turn: ConversationalRectificationTurn, actionId: string) => ConversationalRectificationCommand,
|
||||
command: (turn: ConversationalRectificationResponse, actionId: string) => ConversationalRectificationCommand,
|
||||
clearDraftOnSuccess = false,
|
||||
userMessage?: string,
|
||||
): MutationResult => {
|
||||
const turn = currentTurn();
|
||||
if (!turn) return Promise.resolve(null);
|
||||
@@ -234,22 +292,25 @@ export function createConversationalRectificationController(
|
||||
},
|
||||
command: (actionId) => command(turn, actionId),
|
||||
clearDraftOnSuccess,
|
||||
userMessage,
|
||||
});
|
||||
};
|
||||
|
||||
const controller = {
|
||||
get turn() { return snapshot.turn; },
|
||||
get messages() { return snapshot.messages; },
|
||||
get draft() { return snapshot.draft; },
|
||||
get selectedDomain() { return snapshot.selectedDomain; },
|
||||
get correctionTarget() { return snapshot.correctionTarget; },
|
||||
get pending() { return snapshot.pending; },
|
||||
get streamingAssistantText() { return snapshot.streamingAssistantText; },
|
||||
get error() { return snapshot.error; },
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
synchronizeInitialTurn(turn: ConversationalRectificationTurn | null) {
|
||||
synchronizeInitialTurn(turn: ConversationalRectificationResponse | null) {
|
||||
const current = snapshot.turn;
|
||||
if (turn === null) {
|
||||
if (current === null) return;
|
||||
@@ -262,10 +323,12 @@ export function createConversationalRectificationController(
|
||||
}
|
||||
patch({
|
||||
turn: null,
|
||||
messages: [],
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
streamingAssistantText: "",
|
||||
error: "",
|
||||
});
|
||||
return;
|
||||
@@ -280,10 +343,12 @@ export function createConversationalRectificationController(
|
||||
}
|
||||
patch({
|
||||
turn,
|
||||
messages: initialMessages(turn),
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
streamingAssistantText: "",
|
||||
error: "",
|
||||
});
|
||||
return;
|
||||
@@ -291,7 +356,14 @@ export function createConversationalRectificationController(
|
||||
if (turn.turnVersion <= current.turnVersion) return;
|
||||
patch({
|
||||
turn,
|
||||
messages: turn.messageHistory?.length
|
||||
? initialMessages(turn)
|
||||
: [
|
||||
...(snapshot.messages ?? []),
|
||||
{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` },
|
||||
],
|
||||
error: "",
|
||||
streamingAssistantText: "",
|
||||
selectedDomain: snapshot.selectedDomain
|
||||
&& turn.evidenceRequest?.domains.includes(snapshot.selectedDomain)
|
||||
? snapshot.selectedDomain
|
||||
@@ -360,7 +432,7 @@ export function createConversationalRectificationController(
|
||||
answer,
|
||||
...(domain ? { domain } : {}),
|
||||
...(correctsEvidenceId ? { correctsEvidenceId } : {}),
|
||||
}), true);
|
||||
}), true, answer);
|
||||
},
|
||||
pause() {
|
||||
const turn = currentTurn();
|
||||
|
||||
@@ -16,6 +16,7 @@ export const lifeEventPrecisionSchema = z.enum(["year", "month", "day"]);
|
||||
const lifeEventBase = {
|
||||
id: z.string().uuid(),
|
||||
domain: lifeEventDomainSchema,
|
||||
summary: z.string().trim().min(1).max(1_000).optional(),
|
||||
} as const;
|
||||
|
||||
const supportedYearSchema = z.string().regex(/^(19|20)\d{2}$/).refine(
|
||||
|
||||
@@ -92,6 +92,7 @@ export function eventScorePayload(input: JourneyEventScoreInput) {
|
||||
domain: event.domain,
|
||||
date: event.date,
|
||||
precision: event.precision,
|
||||
...(event.summary ? { summary: event.summary } : {}),
|
||||
})),
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { postJson } from "../birth-time-client-transport.ts";
|
||||
import {
|
||||
conversationalRectificationCommandSchema,
|
||||
conversationalRectificationTurnSchema,
|
||||
conversationalRectificationResponseSchema,
|
||||
type ConversationalRectificationCommand,
|
||||
type ConversationalRectificationTurn,
|
||||
type ConversationalRectificationResponse,
|
||||
} from "./contracts.ts";
|
||||
|
||||
export const CONVERSATIONAL_RECTIFICATION_UNAVAILABLE = "生时校正暂时无法继续,请稍后重试。";
|
||||
@@ -14,6 +13,18 @@ const publicErrorSchema = z.object({
|
||||
message: z.string(),
|
||||
}).passthrough();
|
||||
|
||||
const streamEventSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("delta"), text: z.string() }).strict(),
|
||||
z.object({
|
||||
type: z.literal("turn"),
|
||||
turn: conversationalRectificationResponseSchema,
|
||||
}).strict(),
|
||||
]);
|
||||
|
||||
export type ConversationalRectificationStreamOptions = Readonly<{
|
||||
onNarrativeDelta?: (text: string) => void;
|
||||
}>;
|
||||
|
||||
export class ConversationalRectificationRequestError extends Error {
|
||||
readonly name = "ConversationalRectificationRequestError";
|
||||
readonly status: number;
|
||||
@@ -95,21 +106,75 @@ function isRetryableTransportError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
async function postCommandWithOneReplay(body: string) {
|
||||
async function readJsonPayload(response: Response): Promise<unknown> {
|
||||
return response.json().catch(() => null);
|
||||
}
|
||||
|
||||
async function readStreamedTurn(
|
||||
response: Response,
|
||||
options: ConversationalRectificationStreamOptions,
|
||||
): Promise<ConversationalRectificationResponse> {
|
||||
if (!response.body) throw new SyntaxError("missing rectification response stream");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffered = "";
|
||||
let turn: ConversationalRectificationResponse | null = null;
|
||||
const consumeLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
const event = streamEventSchema.parse(JSON.parse(line));
|
||||
if (event.type === "delta") options.onNarrativeDelta?.(event.text);
|
||||
else turn = event.turn;
|
||||
};
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
buffered += decoder.decode(value, { stream: !done });
|
||||
let newline = buffered.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
consumeLine(buffered.slice(0, newline));
|
||||
buffered = buffered.slice(newline + 1);
|
||||
newline = buffered.indexOf("\n");
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
consumeLine(buffered);
|
||||
if (!turn) throw new SyntaxError("missing rectification turn event");
|
||||
return turn;
|
||||
}
|
||||
|
||||
async function postCommandWithOneReplay(
|
||||
body: string,
|
||||
options: ConversationalRectificationStreamOptions,
|
||||
) {
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
let emittedNarrative = false;
|
||||
try {
|
||||
const result = await postJson({
|
||||
url: "/api/birth-time-conversation",
|
||||
const response = await fetch("/api/birth-time-conversation", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
Accept: "application/x-ndjson, application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
retryLostResponse: false,
|
||||
});
|
||||
// postJson deliberately projects an unparseable non-ok body to null. Treating all null
|
||||
// error payloads as replayable also covers proxies that mislabel HTML as application/json.
|
||||
const nonJsonFailure = !result.response.ok && result.payload === null;
|
||||
if (attempt === 0 && (result.response.status === 502 || nonJsonFailure)) continue;
|
||||
return result;
|
||||
if (!response.ok) {
|
||||
const payload = await readJsonPayload(response);
|
||||
const nonJsonFailure = payload === null;
|
||||
if (attempt === 0 && (response.status === 502 || nonJsonFailure)) continue;
|
||||
return { response, payload, turn: null };
|
||||
}
|
||||
if (response.headers.get("content-type")?.includes("application/x-ndjson")) {
|
||||
const turn = await readStreamedTurn(response, {
|
||||
onNarrativeDelta(text) {
|
||||
emittedNarrative = true;
|
||||
options.onNarrativeDelta?.(text);
|
||||
},
|
||||
});
|
||||
return { response, payload: null, turn };
|
||||
}
|
||||
return { response, payload: await readJsonPayload(response), turn: null };
|
||||
} catch (error) {
|
||||
if (attempt === 0 && isRetryableTransportError(error)) continue;
|
||||
if (attempt === 0 && !emittedNarrative && isRetryableTransportError(error)) continue;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -118,11 +183,12 @@ async function postCommandWithOneReplay(body: string) {
|
||||
|
||||
export async function sendConversationalRectificationCommand(
|
||||
command: ConversationalRectificationCommand,
|
||||
): Promise<ConversationalRectificationTurn> {
|
||||
options: ConversationalRectificationStreamOptions = {},
|
||||
): Promise<ConversationalRectificationResponse> {
|
||||
const request = conversationalRectificationCommandSchema.parse(command);
|
||||
const body = JSON.stringify(request);
|
||||
try {
|
||||
const { response, payload } = await postCommandWithOneReplay(body);
|
||||
const { response, payload, turn } = await postCommandWithOneReplay(body, options);
|
||||
if (!response.ok) {
|
||||
const parsed = publicErrorSchema.safeParse(payload);
|
||||
const safeServerMessage = response.status < 500 && parsed.success
|
||||
@@ -134,7 +200,7 @@ export async function sendConversationalRectificationCommand(
|
||||
safeServerMessage,
|
||||
);
|
||||
}
|
||||
return conversationalRectificationTurnSchema.parse(payload);
|
||||
return turn ?? conversationalRectificationResponseSchema.parse(payload);
|
||||
} catch (error) {
|
||||
if (error instanceof ConversationalRectificationRequestError) throw error;
|
||||
throw new ConversationalRectificationRequestError(
|
||||
|
||||
@@ -94,7 +94,7 @@ const evidenceRecapSchema = boundedJson(
|
||||
24_576,
|
||||
);
|
||||
|
||||
export const conversationalRectificationTurnSchema = boundedJson(z.object({
|
||||
const conversationalRectificationTurnObjectSchema = z.object({
|
||||
caseId: caseIdSchema,
|
||||
journeyProtocol: z.literal("conversational-evidence-v3"),
|
||||
status: z.enum(["active", "paused", "confirming", "completed", "abandoned"]),
|
||||
@@ -112,6 +112,32 @@ export const conversationalRectificationTurnSchema = boundedJson(z.object({
|
||||
"continue_original_question",
|
||||
])).max(5),
|
||||
pendingConsultationQuestion: boundedNonblankText(500).nullable(),
|
||||
}).strict(), 65_536);
|
||||
}).strict();
|
||||
|
||||
export const conversationalRectificationTurnSchema = boundedJson(
|
||||
conversationalRectificationTurnObjectSchema,
|
||||
65_536,
|
||||
);
|
||||
|
||||
export type ConversationalRectificationTurn = z.infer<typeof conversationalRectificationTurnSchema>;
|
||||
|
||||
export const conversationalRectificationMessageHistoryEntrySchema = boundedJson(z.object({
|
||||
turnVersion: turnVersionSchema,
|
||||
userMessage: boundedNonblankText(4_000).nullable(),
|
||||
narrative: boundedNonblankText(12_000),
|
||||
}).strict(), 20_000);
|
||||
|
||||
export type ConversationalRectificationMessageHistoryEntry = z.infer<
|
||||
typeof conversationalRectificationMessageHistoryEntrySchema
|
||||
>;
|
||||
|
||||
export const conversationalRectificationResponseSchema = boundedJson(
|
||||
conversationalRectificationTurnObjectSchema.extend({
|
||||
messageHistory: z.array(conversationalRectificationMessageHistoryEntrySchema).max(200).optional(),
|
||||
}).strict(),
|
||||
3_500_000,
|
||||
);
|
||||
|
||||
export type ConversationalRectificationResponse = z.infer<
|
||||
typeof conversationalRectificationResponseSchema
|
||||
>;
|
||||
|
||||
@@ -48,12 +48,15 @@ export function rangeCompletionReason(input: Readonly<{
|
||||
packet: RectificationTechnicalPacket;
|
||||
scoreableEventCount: number;
|
||||
plateauCount: number;
|
||||
unansweredSuggestedDomainCount: number;
|
||||
}>): RangeCompletionReason | null {
|
||||
if (input.packet.candidate.status === "ready_for_confirmation") return null;
|
||||
if (input.scoreableEventCount < MINIMUM_SCOREABLE_EVENTS) return null;
|
||||
if (input.scoreableEventCount >= MAXIMUM_SCOREABLE_EVENTS) return "evidence_limit";
|
||||
if (input.scoreableEventCount >= MAXIMUM_SCOREABLE_EVENTS
|
||||
&& input.unansweredSuggestedDomainCount === 0) return "evidence_limit";
|
||||
if (input.packet.suggestedDomains.length === 0) return "no_discriminating_question";
|
||||
if (input.plateauCount >= MAXIMUM_PLATEAU_ROUNDS) return "range_plateau";
|
||||
if (input.plateauCount >= MAXIMUM_PLATEAU_ROUNDS
|
||||
&& input.unansweredSuggestedDomainCount === 0) return "range_plateau";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ function classifyDomain(summary: string): RectificationEvidenceDomain {
|
||||
if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) return "relationship";
|
||||
if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) return "family";
|
||||
if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) return "finance";
|
||||
if (/工作|入职|离职|辞职|升职|创业|职业|公司|项目/.test(summary)) return "career";
|
||||
if (/工作|入职|离职|辞职|升职|创业|职业|职位|任职|管理职责|公司|项目/.test(summary)) return "career";
|
||||
return "other";
|
||||
}
|
||||
|
||||
@@ -137,6 +137,36 @@ function splitSentences(value: string): string[][] {
|
||||
return sentences.length > 0 ? sentences : [[value.trim()]];
|
||||
}
|
||||
|
||||
function coalesceSameEventDetails(
|
||||
input: ExtractLifeEventEvidenceInput,
|
||||
events: readonly ExtractedLifeEventEvidence[],
|
||||
): readonly ExtractedLifeEventEvidence[] {
|
||||
const merged: ExtractedLifeEventEvidence[] = [];
|
||||
for (const event of events) {
|
||||
const previous = merged.at(-1);
|
||||
const canMerge = previous
|
||||
&& previous.dateValue !== null
|
||||
&& previous.dateValue === event.dateValue
|
||||
&& previous.datePrecision === event.datePrecision
|
||||
&& previous.domain === event.domain
|
||||
&& previous.extractionStatus === event.extractionStatus
|
||||
&& previous.scoreable === event.scoreable
|
||||
&& previous.correctsEvidenceIds.join("\0") === event.correctsEvidenceIds.join("\0");
|
||||
if (!canMerge) {
|
||||
merged.push(event);
|
||||
continue;
|
||||
}
|
||||
const summaries = [...new Set([previous.eventSummary, event.eventSummary])];
|
||||
const eventSummary = summaries.join(";");
|
||||
merged[merged.length - 1] = {
|
||||
...previous,
|
||||
id: evidenceId(input, merged.length - 1, eventSummary),
|
||||
eventSummary,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function extractLifeEventEvidence(
|
||||
input: ExtractLifeEventEvidenceInput,
|
||||
): readonly ExtractedLifeEventEvidence[] {
|
||||
@@ -173,5 +203,5 @@ export function extractLifeEventEvidence(
|
||||
});
|
||||
}
|
||||
}
|
||||
return events;
|
||||
return coalesceSameEventDetails(input, events);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,40 @@ import {
|
||||
|
||||
export type RectificationNarrativePhase = "first" | "intermediate" | "final";
|
||||
|
||||
export type RectificationNarrativeContext = Readonly<{
|
||||
latestUserText?: string;
|
||||
latestEvidence?: ReadonlyArray<{
|
||||
dateLabel: string;
|
||||
summary: string;
|
||||
domain: RectificationEvidenceDomain;
|
||||
}>;
|
||||
eventLedger?: ReadonlyArray<{
|
||||
id: string;
|
||||
rawText: string;
|
||||
dateLabel: string;
|
||||
summary: string;
|
||||
domain: RectificationEvidenceDomain;
|
||||
extractionStatus: "clear" | "needs_clarification" | "corrected";
|
||||
active: boolean;
|
||||
correctsEvidenceIds: readonly string[];
|
||||
}>;
|
||||
unresolvedEvidence?: ReadonlyArray<{
|
||||
id: string;
|
||||
rawText: string;
|
||||
summary: string;
|
||||
domain: RectificationEvidenceDomain;
|
||||
dateLabel: string;
|
||||
}>;
|
||||
}>;
|
||||
|
||||
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
|
||||
const modelIdSchema = z.string().trim().min(1).max(120);
|
||||
const validatorVersion = "rectification-narrative-grounding-v2";
|
||||
const domainSchema = z.enum(["career", "education", "finance", "health_pressure", "relocation", "relationship", "family", "other"]);
|
||||
const broadYearRangePattern = /(?:19|20)\d{2}\s*年?\s*(?:[-–—~~至到\/]|\.\.)\s*(?:19|20)\d{2}\s*年?/i;
|
||||
const explicitYearPattern = /(?:19|20)\d{2}\s*年?/g;
|
||||
const proposedYearAlternativesPattern = /(?:19|20)\d{2}\s*年?\s*(?:还是|或者|或是|或|、|,|,)\s*(?:19|20)\d{2}\s*年?/i;
|
||||
const choiceQuestionPattern = /(?:哪(?:一|个)?(?:年|年份|年代|时间段|区间|时期)|哪个时间段|还是|选择|选项|更符合|更匹配|A\s*[.、::)]|B\s*[.、::)]|which\s+(?:year|period|range)|options?)/i;
|
||||
const labeledYearChoicesPattern = /A\s*[.、::)]?[\s\S]{0,80}(?:19|20)\d{2}\s*年?[\s\S]{0,120}B\s*[.、::)]?[\s\S]{0,80}(?:19|20)\d{2}\s*年?/i;
|
||||
const domainLabels = {
|
||||
career: "事业",
|
||||
education: "学业",
|
||||
@@ -25,7 +51,7 @@ const domainLabels = {
|
||||
family: "家庭",
|
||||
other: "其他",
|
||||
} as const satisfies Readonly<Record<RectificationEvidenceDomain, string>>;
|
||||
const narrativeOutputSchema = z.object({
|
||||
export const rectificationNarrativeOutputSchema = z.object({
|
||||
narrative: z.string().trim().min(1).max(12_000),
|
||||
candidateStatus: z.enum(["pending_validation", "ready_for_confirmation"]),
|
||||
representativeTime: timeSchema,
|
||||
@@ -47,7 +73,7 @@ const narrativeOutputSchema = z.object({
|
||||
}).strict().nullable(),
|
||||
}).strict();
|
||||
|
||||
export type RectificationNarrativeModelOutput = z.infer<typeof narrativeOutputSchema>;
|
||||
export type RectificationNarrativeModelOutput = z.infer<typeof rectificationNarrativeOutputSchema>;
|
||||
|
||||
export type NarrativeValidation = {
|
||||
readonly valid: boolean;
|
||||
@@ -84,7 +110,7 @@ function parseModelOutput(text: string): RectificationNarrativeModelOutput {
|
||||
const start = normalized.indexOf("{");
|
||||
const end = normalized.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) throw new TypeError("narrative output is not JSON");
|
||||
return narrativeOutputSchema.parse(JSON.parse(normalized.slice(start, end + 1)));
|
||||
return rectificationNarrativeOutputSchema.parse(JSON.parse(normalized.slice(start, end + 1)));
|
||||
}
|
||||
|
||||
function narrativeTimes(value: string): string[] {
|
||||
@@ -104,11 +130,9 @@ function narrativeReferences(value: string): string[] {
|
||||
}
|
||||
|
||||
function isGenericBroadYearChoiceQuestionnaire(value: string): boolean {
|
||||
const distinctYears = unique((value.match(explicitYearPattern) ?? [])
|
||||
.map((year) => year.replace(/\s*年$/, "")));
|
||||
return proposedYearAlternativesPattern.test(value)
|
||||
|| (choiceQuestionPattern.test(value)
|
||||
&& (broadYearRangePattern.test(value) || distinctYears.length >= 2));
|
||||
|| labeledYearChoicesPattern.test(value)
|
||||
|| (choiceQuestionPattern.test(value) && broadYearRangePattern.test(value));
|
||||
}
|
||||
|
||||
function proseFields(output: RectificationNarrativeModelOutput): readonly {
|
||||
@@ -175,9 +199,7 @@ export function validateNarrativeAgainstPacket(
|
||||
for (const domain of output.evidenceRequest.domains) {
|
||||
if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`);
|
||||
}
|
||||
if (!/(?:已经发生|已发生|过去)/.test(output.evidenceRequest.prompt)
|
||||
|| !/年/.test(output.evidenceRequest.prompt)
|
||||
|| !/月/.test(output.evidenceRequest.prompt)) {
|
||||
if (!requestsPastDatedEvent(output.evidenceRequest.prompt)) {
|
||||
issues.push("evidence request must ask for a real past event by year and month");
|
||||
}
|
||||
} else if (phase !== "final") {
|
||||
@@ -203,7 +225,7 @@ export function validateNarrativeAgainstPacket(
|
||||
issues.push(`${field.path} is a forbidden generic broad-year choice questionnaire`);
|
||||
}
|
||||
}
|
||||
if (phase !== "final") {
|
||||
if (phase === "first") {
|
||||
if (!output.narrative.includes(candidate.range.startTime)
|
||||
|| !output.narrative.includes(candidate.range.endTime)
|
||||
|| !/(?:待验证|候选|核对)/.test(output.narrative)) {
|
||||
@@ -212,9 +234,7 @@ export function validateNarrativeAgainstPacket(
|
||||
if (narrativeLayers(output.narrative).length > 0) {
|
||||
issues.push("visible evidence narrative must not expose technical layer tokens");
|
||||
}
|
||||
if (!/(?:已经发生|已发生|过去)/.test(output.narrative)
|
||||
|| !/年/.test(output.narrative)
|
||||
|| !/月/.test(output.narrative)) {
|
||||
if (!requestsPastDatedEvent(output.narrative)) {
|
||||
issues.push("first narrative must request real past events by year and month");
|
||||
}
|
||||
if (!/(?:不是[\s\S]*确认|不能[\s\S]*(?:确定|确认)|仅[\s\S]*候选|必须[\s\S]*确认)/.test(output.narrative)) {
|
||||
@@ -238,6 +258,7 @@ function grounding(packet: RectificationTechnicalPacket) {
|
||||
suggestedDomains: packet.suggestedDomains,
|
||||
referenceIds: packet.referenceIds,
|
||||
futureWindows: projected.futureWindows,
|
||||
expertWorkflow: packet.expertWorkflow,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -247,14 +268,89 @@ function boundedReceiptIssues(issues: readonly string[]): string[] {
|
||||
.map((issue) => issue.trim().slice(0, 240) || "narrative_mismatch");
|
||||
}
|
||||
|
||||
function ensureSentence(value: string, sentence: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? `${trimmed}\n${sentence}` : sentence;
|
||||
}
|
||||
|
||||
function hasVisibleEvidenceQuestion(value: string): boolean {
|
||||
const withoutRhetoricalPrompts = value.replace(/(?:好吗|可以吗|行吗)[??]/g, "");
|
||||
return /[??]/.test(withoutRhetoricalPrompts)
|
||||
|| /请(?:先|再|补充|告诉|提供|确认|回忆)/.test(value)
|
||||
|| /(?:先说一件|说说|告诉我)/.test(value);
|
||||
}
|
||||
|
||||
function requestsPastDatedEvent(value: string): boolean {
|
||||
const asksForDate = /(?:年|月|日期|时间|什么时候)/.test(value);
|
||||
const refersToPastEvent = /(?:已经发生|已发生|过去|当时|后来|经历|发生|开始|毕业|入职|离职|结束|分手|事故|手术)/.test(value);
|
||||
const asksOnlyAboutFuture = /(?:未来|预计|计划|打算)/.test(value) && !refersToPastEvent;
|
||||
return asksForDate && refersToPastEvent && !asksOnlyAboutFuture;
|
||||
}
|
||||
|
||||
function repairRequiredSafetyLanguage(
|
||||
output: RectificationNarrativeModelOutput,
|
||||
packet: RectificationTechnicalPacket,
|
||||
phase: RectificationNarrativePhase,
|
||||
): RectificationNarrativeModelOutput {
|
||||
let narrative = output.narrative;
|
||||
let evidenceRequest = output.evidenceRequest;
|
||||
const modelEvidencePrompt = output.evidenceRequest?.prompt.trim() ?? "";
|
||||
|
||||
if (phase !== "final" && evidenceRequest) {
|
||||
if (!requestsPastDatedEvent(evidenceRequest.prompt)) {
|
||||
evidenceRequest = {
|
||||
...evidenceRequest,
|
||||
prompt: `请以已经发生的真实事件为准,并尽量说明年份和月份。${evidenceRequest.prompt}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === "first") {
|
||||
const candidate = packet.candidate;
|
||||
const statesCandidateRange = narrative.includes(candidate.range.startTime)
|
||||
&& narrative.includes(candidate.range.endTime)
|
||||
&& /(?:待验证|候选|核对)/.test(narrative);
|
||||
const statesUseBoundary = /(?:不是[\s\S]*确认|不能[\s\S]*(?:确定|确认)|仅[\s\S]*候选|必须[\s\S]*确认)/.test(narrative);
|
||||
|
||||
if (!statesCandidateRange || !statesUseBoundary) {
|
||||
narrative = [
|
||||
`我们先在 ${candidate.range.startTime}–${candidate.range.endTime} 内核对候选;这个范围不能直接当作已经确认的出生时间。`,
|
||||
narrative.trim(),
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// evidenceRequest.prompt is an internal planning field and is intentionally not
|
||||
// projected to the public turn. Keep the model-authored acknowledgement, but make
|
||||
// sure the one concrete follow-up question is also visible in the chat bubble.
|
||||
if (phase !== "final" && evidenceRequest && !hasVisibleEvidenceQuestion(narrative)) {
|
||||
narrative = ensureSentence(narrative, modelEvidencePrompt || evidenceRequest.prompt);
|
||||
}
|
||||
|
||||
if (phase === "first" && !requestsPastDatedEvent(narrative)) {
|
||||
narrative = ensureSentence(
|
||||
narrative,
|
||||
"请从已经发生的真实经历开始,尽量写明哪一年、哪一月。",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...output,
|
||||
narrative,
|
||||
evidenceRequest,
|
||||
};
|
||||
}
|
||||
|
||||
function promptFor(
|
||||
phase: RectificationNarrativePhase,
|
||||
packet: RectificationTechnicalPacket,
|
||||
context: RectificationNarrativeContext,
|
||||
retryIssues: readonly string[] = [],
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
task: "write_grounded_rectification_narrative",
|
||||
phase,
|
||||
conversationContext: context,
|
||||
packet: grounding(packet),
|
||||
outputContract: {
|
||||
candidateFactsMustMatch: true,
|
||||
@@ -262,10 +358,21 @@ function promptFor(
|
||||
everyAuthoredStringMustBeGrounded: true,
|
||||
keepTechnicalLayerValuesOutOfVisibleNarrative: phase !== "final",
|
||||
askExactlyOneHighInformationQuestion: phase !== "final",
|
||||
acknowledgeLatestEvidenceSpecificallyBeforeAsking: phase === "intermediate",
|
||||
doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked: phase === "intermediate",
|
||||
finishCurrentEventBeforeSwitchingDomains: phase === "intermediate",
|
||||
resolveDateContradictionsBeforeScoring: phase === "intermediate",
|
||||
mergeSameEventDetailsWithoutDoubleCounting: phase === "intermediate",
|
||||
askForTheSingleMostInformativeMissingDetail: phase === "intermediate",
|
||||
treatCauseResultAgencyAndNextTransitionAsPartsOfTheCurrentEvent: phase === "intermediate",
|
||||
useEventLedgerToAvoidRepeatingAnsweredQuestions: phase === "intermediate",
|
||||
usePacketDomainReasonTextExactly: true,
|
||||
requestRealPastEventsByYearAndMonth: phase !== "final",
|
||||
futureWindowsAreContextOnly: true,
|
||||
genericBroadYearRangeQuestionnaireForbidden: true,
|
||||
useExpertWorkflowAsTechniqueTruth: true,
|
||||
blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed: true,
|
||||
finalNarrativeIncludesAConciseTechniqueAuditTable: phase === "final",
|
||||
},
|
||||
retryIssues: boundedReceiptIssues(retryIssues),
|
||||
});
|
||||
@@ -311,13 +418,23 @@ export async function generateRectificationNarrative(input: {
|
||||
readonly phase: RectificationNarrativePhase;
|
||||
readonly packet: RectificationTechnicalPacket;
|
||||
readonly generator: RectificationNarrativeGenerator;
|
||||
readonly context?: RectificationNarrativeContext;
|
||||
}): Promise<RectificationNarrativeResult> {
|
||||
const modelId = modelIdSchema.parse(input.generator.modelId);
|
||||
let issues: readonly string[] = [];
|
||||
for (const attempt of [1, 2] as const) {
|
||||
try {
|
||||
const generated = await input.generator.generate(promptFor(input.phase, input.packet, issues));
|
||||
const output = parseModelOutput(generated.text);
|
||||
const generated = await input.generator.generate(promptFor(
|
||||
input.phase,
|
||||
input.packet,
|
||||
input.context ?? {},
|
||||
issues,
|
||||
));
|
||||
const output = repairRequiredSafetyLanguage(
|
||||
parseModelOutput(generated.text),
|
||||
input.packet,
|
||||
input.phase,
|
||||
);
|
||||
const validation = validateNarrativeAgainstPacket(output, input.packet, input.phase);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
@@ -338,10 +455,18 @@ export async function generateRectificationNarrative(input: {
|
||||
}
|
||||
issues = validation.issues;
|
||||
} catch (error) {
|
||||
issues = [error instanceof Error ? error.name : "NarrativeOutputError"];
|
||||
issues = error instanceof z.ZodError
|
||||
? error.issues.map((issue) => `${issue.path.join(".") || "root"}:${issue.code}`)
|
||||
: [error instanceof Error ? error.name : "NarrativeOutputError"];
|
||||
}
|
||||
}
|
||||
const output = fallbackOutput(input.packet, input.phase);
|
||||
console.warn("[rectification-narrative-fallback]", JSON.stringify({
|
||||
phase: input.phase,
|
||||
modelId,
|
||||
attempts: 2,
|
||||
issues: boundedReceiptIssues(issues),
|
||||
}));
|
||||
return {
|
||||
narrative: output.narrative,
|
||||
output,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
conversationalRectificationCommandSchema,
|
||||
conversationalRectificationTurnSchema,
|
||||
type ConversationalRectificationCommand,
|
||||
type ConversationalRectificationResponse,
|
||||
type ConversationalRectificationTurn,
|
||||
} from "./contracts.ts";
|
||||
import { ConversationalRectificationError } from "./errors.ts";
|
||||
@@ -188,7 +189,7 @@ function visibleEvidenceSummary(value: string): string {
|
||||
return cleaned || value;
|
||||
}
|
||||
|
||||
function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn {
|
||||
function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationResponse {
|
||||
const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn);
|
||||
if (!parsed.success) throw new ConversationalRectificationError("store_unavailable");
|
||||
const evidenceDomains = new Map(
|
||||
@@ -196,6 +197,7 @@ function publicTurn(value: StoredConversationalRectificationCase): Conversationa
|
||||
);
|
||||
return {
|
||||
...parsed.data,
|
||||
...(value.messageHistory ? { messageHistory: [...value.messageHistory] } : {}),
|
||||
evidenceRecap: parsed.data.evidenceRecap.map((item) => ({
|
||||
...item,
|
||||
summary: visibleEvidenceSummary(item.summary),
|
||||
@@ -251,6 +253,43 @@ function evidenceRecap(evidence: ReadonlyArray<LifeEventEvidenceInput>) {
|
||||
}));
|
||||
}
|
||||
|
||||
function narrativeConversationContext(input: Readonly<{
|
||||
latestUserText: string;
|
||||
allEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
}>) {
|
||||
const activeEvidence = effectiveLifeEventEvidence(input.allEvidence);
|
||||
const activeIds = new Set(activeEvidence.map((item) => item.id));
|
||||
return {
|
||||
latestUserText: input.latestUserText.trim().slice(0, 4_000),
|
||||
latestEvidence: evidenceRecap(input.newEvidence).map((item) => ({
|
||||
dateLabel: item.dateLabel,
|
||||
summary: item.summary,
|
||||
domain: item.domain,
|
||||
})),
|
||||
eventLedger: input.allEvidence.slice(-40).map((item) => ({
|
||||
id: item.id,
|
||||
rawText: item.rawText,
|
||||
dateLabel: item.dateValue ?? "日期待补充",
|
||||
summary: visibleEvidenceSummary(item.eventSummary),
|
||||
domain: item.domain,
|
||||
extractionStatus: item.extractionStatus,
|
||||
active: activeIds.has(item.id),
|
||||
correctsEvidenceIds: [...(item.correctsEvidenceIds ?? [])],
|
||||
})),
|
||||
unresolvedEvidence: activeEvidence
|
||||
.filter((item) => item.extractionStatus === "needs_clarification")
|
||||
.slice(-20)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
rawText: item.rawText,
|
||||
summary: visibleEvidenceSummary(item.eventSummary),
|
||||
domain: item.domain,
|
||||
dateLabel: item.dateValue ?? "日期待补充",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const progressDomainLabels = {
|
||||
career: "事业",
|
||||
education: "学业",
|
||||
@@ -266,8 +305,10 @@ function evidenceProgressNarrative(input: Readonly<{
|
||||
previousCandidate: PrivateCandidateInput;
|
||||
packet: RectificationTechnicalPacket;
|
||||
newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
allEvidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
scoreableEventCount: number;
|
||||
willContinue: boolean;
|
||||
authoredNarrative?: string | null;
|
||||
}>): string {
|
||||
const recorded = evidenceRecap(input.newEvidence);
|
||||
const acknowledgement = recorded.length === 0
|
||||
@@ -283,8 +324,7 @@ function evidenceProgressNarrative(input: Readonly<{
|
||||
: rangeChanged
|
||||
? `候选范围已从 ${previousStart ?? "原范围"}–${previousEnd ?? "原范围"} 更新为 ${nextStart}–${nextEnd}。`
|
||||
: `本轮已纳入 ${input.scoreableEventCount} 条可评分经历,但候选范围暂未稳定缩小;这不是提交失败。`;
|
||||
const suggested = input.packet.suggestedDomains
|
||||
.slice(0, 2)
|
||||
const suggested = nextEvidenceDomains(input.packet, input.allEvidence)
|
||||
.map((item) => progressDomainLabels[item.domain]);
|
||||
const nextStep = input.willContinue && suggested.length > 0
|
||||
? `下一步:请优先补充一件${suggested.join("或")}领域已经发生的事件,并选择大致年月。`
|
||||
@@ -294,12 +334,48 @@ function evidenceProgressNarrative(input: Readonly<{
|
||||
.map((item) => `${progressDomainLabels[item.domain]}事件用于比较 ${item.layer}`)
|
||||
.join(";")}。`
|
||||
: "";
|
||||
return [acknowledgement, progress, nextStep, differenceBasis]
|
||||
const authored = input.authoredNarrative?.trim();
|
||||
if (authored) return authored.slice(0, 12_000);
|
||||
return [acknowledgement, authored, progress, nextStep, differenceBasis]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.slice(0, 12_000);
|
||||
}
|
||||
|
||||
function unansweredEvidenceDomains(
|
||||
packet: RectificationTechnicalPacket,
|
||||
evidence: ReadonlyArray<LifeEventEvidenceInput>,
|
||||
) {
|
||||
const answeredDomains = new Set(effectiveLifeEventEvidence(evidence)
|
||||
.filter((item) => item.extractionStatus !== "needs_clarification")
|
||||
.map((item) => item.domain));
|
||||
return packet.suggestedDomains.filter((item) => !answeredDomains.has(item.domain));
|
||||
}
|
||||
|
||||
function nextEvidenceDomains(
|
||||
packet: RectificationTechnicalPacket,
|
||||
evidence: ReadonlyArray<LifeEventEvidenceInput>,
|
||||
) {
|
||||
const unanswered = unansweredEvidenceDomains(packet, evidence);
|
||||
return (unanswered.length > 0 ? unanswered : packet.suggestedDomains).slice(0, 2);
|
||||
}
|
||||
|
||||
function evidenceRequestForProgress(input: Readonly<{
|
||||
packet: RectificationTechnicalPacket;
|
||||
evidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
willContinue: boolean;
|
||||
}>): RectificationNarrativeResult["output"]["evidenceRequest"] {
|
||||
if (!input.willContinue) return null;
|
||||
const suggested = nextEvidenceDomains(input.packet, input.evidence);
|
||||
if (suggested.length === 0) return null;
|
||||
const labels = suggested.map((item) => progressDomainLabels[item.domain]);
|
||||
return {
|
||||
domains: suggested.map((item) => item.domain),
|
||||
datePrecision: "month_preferred",
|
||||
prompt: `请说一件${labels.join("或")}方面已经发生的事,尽量写明哪一年、哪一月以及发生了什么。`,
|
||||
};
|
||||
}
|
||||
|
||||
function exactTechnicalReceipt(packet: RectificationTechnicalPacket) {
|
||||
const projected = projectRectificationTechnicalPacket(packet);
|
||||
return {
|
||||
@@ -410,7 +486,7 @@ function completedRangeTurn(
|
||||
turn: ConversationalRectificationTurn,
|
||||
reason: RangeCompletionReason,
|
||||
): ConversationalRectificationTurn {
|
||||
const suffix = `${rangeCompletionCopy(reason)} 本次校正已结束并保存当前候选范围;候选代表时间不会替换当前排盘时间。`;
|
||||
const suffix = `${rangeCompletionCopy(reason)} 当前证据没有收敛到可确认分钟,因此本次不计费,已退回暂扣点数。候选范围仅作记录,代表时间不会替换当前排盘时间。`;
|
||||
const parsed = conversationalRectificationTurnSchema.safeParse({
|
||||
...turn,
|
||||
status: "completed",
|
||||
@@ -505,6 +581,16 @@ function nonScoringTurn(input: {
|
||||
const allEvidence = [...input.current.eventEvidence, ...input.newEvidence];
|
||||
const hasFuture = input.newEvidence.some((item) => item.extractionStatus !== "needs_clarification"
|
||||
&& item.scoreable === false && item.dateValue !== null);
|
||||
const latestIncomplete = input.newEvidence
|
||||
.filter((item) => item.extractionStatus === "needs_clarification")
|
||||
.at(-1);
|
||||
const clarificationNarrative = latestIncomplete?.dateValue === null
|
||||
&& latestIncomplete.eventSummary !== "事件内容待补充"
|
||||
? `你提到“${visibleEvidenceSummary(latestIncomplete.eventSummary)}”,具体内容我已经记下了。它大致是什么年月?只记得年份也可以。`
|
||||
: latestIncomplete?.dateValue
|
||||
&& latestIncomplete.eventSummary === "事件内容待补充"
|
||||
? `我已经记下 ${latestIncomplete.dateValue} 这个时间。那时具体发生了什么重要事情?`
|
||||
: null;
|
||||
const correctionNarrative = input.correctionReset?.reason === "validation_fallback"
|
||||
? "这条更正已保存,原记录已经停止参与候选评分,候选范围也已重新计算。为避免只凭一次修订直接确认出生分钟,本轮先保持待验证;请继续补充另一件已经发生的真实经历。"
|
||||
: input.correctionReset?.reason === "direction_change"
|
||||
@@ -518,7 +604,8 @@ function nonScoringTurn(input: {
|
||||
? "好的,我们不沿用不符合你的方向。你可以自由描述另一件已经发生的生活变化,尽量写明年月;我会根据事实继续,而不是让你选择宽泛年份。"
|
||||
: hasFuture
|
||||
? "已保存这段描述。未来事件只能作为背景,不能用于校正评分;请再说一件已经发生的事件,并尽量写明年月。"
|
||||
: "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。";
|
||||
: clarificationNarrative
|
||||
?? "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。";
|
||||
const status = input.correctionReset
|
||||
? "active" as const
|
||||
: input.current.status === "confirming" ? "confirming" as const : "active" as const;
|
||||
@@ -765,6 +852,48 @@ export function createConversationalRectificationService(
|
||||
return extracted;
|
||||
}
|
||||
|
||||
function completeLatestClarification(input: Readonly<{
|
||||
command: CommandOf<"answer">;
|
||||
current: LoadedConversationalRectificationCase;
|
||||
extracted: readonly LifeEventEvidence[];
|
||||
}>): readonly LifeEventEvidence[] {
|
||||
if (input.command.correctsEvidenceId || input.extracted.length !== 1) return input.extracted;
|
||||
const pending = effectiveLifeEventEvidence(input.current.eventEvidence)
|
||||
.filter((item) => item.extractionStatus === "needs_clarification")
|
||||
.at(-1);
|
||||
const supplied = input.extracted[0];
|
||||
if (!pending || !supplied) return input.extracted;
|
||||
|
||||
const pendingHasSummary = pending.eventSummary !== "事件内容待补充";
|
||||
const suppliedHasSummary = supplied.eventSummary !== "事件内容待补充";
|
||||
const fillsMissingDate = pending.dateValue === null
|
||||
&& supplied.dateValue !== null
|
||||
&& pendingHasSummary
|
||||
&& !suppliedHasSummary;
|
||||
const fillsMissingSummary = pending.dateValue !== null
|
||||
&& supplied.dateValue === null
|
||||
&& !pendingHasSummary
|
||||
&& suppliedHasSummary;
|
||||
if (!fillsMissingDate && !fillsMissingSummary) return input.extracted;
|
||||
|
||||
const dateValue = supplied.dateValue ?? pending.dateValue;
|
||||
const summary = suppliedHasSummary ? supplied.eventSummary : pending.eventSummary;
|
||||
if (!dateValue || summary === "事件内容待补充") return input.extracted;
|
||||
const merged = extractLifeEventEvidence({
|
||||
rawText: `${dateValue} ${summary}`,
|
||||
sourceTurnId: input.command.actionId,
|
||||
asOfDate: ports.asOfDate(),
|
||||
correctsEvidenceId: pending.id,
|
||||
});
|
||||
if (merged.length !== 1) return input.extracted;
|
||||
return merged.map((item) => ({
|
||||
...item,
|
||||
rawText: `${pending.rawText}\n补充:${input.command.answer}`,
|
||||
domain: pending.domain === "other" ? item.domain : pending.domain,
|
||||
correctsEvidenceIds: [...item.correctsEvidenceIds],
|
||||
}));
|
||||
}
|
||||
|
||||
const service: ConversationalRectificationService = Object.freeze({
|
||||
importLegacyCase,
|
||||
async start(userId, rawCommand) {
|
||||
@@ -942,7 +1071,11 @@ export function createConversationalRectificationService(
|
||||
const current = await load(userId, command.caseId);
|
||||
requireMutable(current);
|
||||
const evidence = evidenceForDeclaredBirthDate(
|
||||
extractedEvidence(command),
|
||||
completeLatestClarification({
|
||||
command,
|
||||
current,
|
||||
extracted: extractedEvidence(command),
|
||||
}),
|
||||
current.declaredBirthInput.birthDate,
|
||||
);
|
||||
|
||||
@@ -954,6 +1087,7 @@ export function createConversationalRectificationService(
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
commandFingerprint: fingerprint,
|
||||
userMessage: command.answer,
|
||||
turn: current.latestTurn,
|
||||
evidence,
|
||||
validationReceipt: latestReceipt(current),
|
||||
@@ -1026,6 +1160,7 @@ export function createConversationalRectificationService(
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
commandFingerprint: fingerprint,
|
||||
userMessage: command.answer,
|
||||
turn: next.turn,
|
||||
evidence,
|
||||
validationReceipt: next.receipt,
|
||||
@@ -1041,6 +1176,11 @@ export function createConversationalRectificationService(
|
||||
phase,
|
||||
packet: gatedPacket,
|
||||
generator: ports.narrativeGenerator,
|
||||
context: narrativeConversationContext({
|
||||
latestUserText: command.answer,
|
||||
allEvidence: [...current.eventEvidence, ...evidence],
|
||||
newEvidence: evidence,
|
||||
}),
|
||||
});
|
||||
if (narrative.fallbackUsed) {
|
||||
const next = nonScoringTurn({
|
||||
@@ -1065,6 +1205,7 @@ export function createConversationalRectificationService(
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
commandFingerprint: fingerprint,
|
||||
userMessage: command.answer,
|
||||
turn: next.turn,
|
||||
evidence,
|
||||
validationReceipt: narrative.validationReceipt,
|
||||
@@ -1094,6 +1235,7 @@ export function createConversationalRectificationService(
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
commandFingerprint: fingerprint,
|
||||
userMessage: command.answer,
|
||||
turn,
|
||||
evidence,
|
||||
validationReceipt: narrative.validationReceipt,
|
||||
@@ -1118,6 +1260,7 @@ export function createConversationalRectificationService(
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
commandFingerprint: fingerprint,
|
||||
userMessage: command.answer,
|
||||
turn: next.turn,
|
||||
evidence,
|
||||
validationReceipt: next.receipt,
|
||||
@@ -1149,12 +1292,21 @@ export function createConversationalRectificationService(
|
||||
phase,
|
||||
packet: gatedPacket,
|
||||
generator: ports.narrativeGenerator,
|
||||
context: narrativeConversationContext({
|
||||
latestUserText: command.answer,
|
||||
allEvidence: [...current.eventEvidence, ...evidence],
|
||||
newEvidence: evidence,
|
||||
}),
|
||||
});
|
||||
const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket);
|
||||
const completionReason = rangeCompletionReason({
|
||||
packet: gatedPacket,
|
||||
scoreableEventCount: allScoreable.length,
|
||||
plateauCount,
|
||||
unansweredSuggestedDomainCount: unansweredEvidenceDomains(
|
||||
gatedPacket,
|
||||
[...current.eventEvidence, ...evidence],
|
||||
).length,
|
||||
});
|
||||
const narrativeWithProgress = {
|
||||
...narrative,
|
||||
@@ -1162,10 +1314,21 @@ export function createConversationalRectificationService(
|
||||
previousCandidate: current.privateCandidate,
|
||||
packet: gatedPacket,
|
||||
newEvidence: evidence,
|
||||
allEvidence: [...current.eventEvidence, ...evidence],
|
||||
scoreableEventCount: allScoreable.length,
|
||||
willContinue: completionReason === null
|
||||
&& gatedPacket.candidate.status !== "ready_for_confirmation",
|
||||
authoredNarrative: narrative.fallbackUsed ? null : narrative.narrative,
|
||||
}),
|
||||
output: {
|
||||
...narrative.output,
|
||||
evidenceRequest: evidenceRequestForProgress({
|
||||
packet: gatedPacket,
|
||||
evidence: [...current.eventEvidence, ...evidence],
|
||||
willContinue: completionReason === null
|
||||
&& gatedPacket.candidate.status !== "ready_for_confirmation",
|
||||
}),
|
||||
},
|
||||
} satisfies RectificationNarrativeResult;
|
||||
const privateCandidate = privateCandidateFromPacket({
|
||||
packet: gatedPacket,
|
||||
@@ -1193,6 +1356,7 @@ export function createConversationalRectificationService(
|
||||
expectedVersion: command.turnVersion,
|
||||
actionId: command.actionId,
|
||||
commandFingerprint: fingerprint,
|
||||
userMessage: command.answer,
|
||||
turn,
|
||||
evidence,
|
||||
validationReceipt: narrative.validationReceipt,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { conversationalRectificationTurnSchema } from "./contracts.ts";
|
||||
import {
|
||||
conversationalRectificationMessageHistoryEntrySchema,
|
||||
conversationalRectificationTurnSchema,
|
||||
} from "./contracts.ts";
|
||||
import { boundedJson } from "./json-bounds.ts";
|
||||
|
||||
const uuidSchema = z.string().uuid();
|
||||
@@ -247,6 +250,7 @@ export const storedCaseRowSchema = boundedJson(z.object({
|
||||
pending_consultation_question: boundedText(500).nullable(),
|
||||
billing_state: z.enum(["reserved", "charged", "released", "migration_waived"]).nullable(),
|
||||
latest_turn: conversationalRectificationTurnSchema,
|
||||
message_history: z.array(conversationalRectificationMessageHistoryEntrySchema).max(200).optional(),
|
||||
declared_birth_input: declaredBirthInputSchema.optional(),
|
||||
private_candidate: privateCandidateSchema.optional(),
|
||||
event_evidence: z.array(lifeEventEvidenceSchema).max(2_000).optional(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type ConversationalRectificationMessageHistoryEntry,
|
||||
conversationalRectificationTurnSchema,
|
||||
type ConversationalRectificationTurn,
|
||||
} from "./contracts.ts";
|
||||
@@ -57,6 +58,7 @@ export type StoredConversationalRectificationCase = Readonly<{
|
||||
pendingConsultationQuestion: string | null;
|
||||
billingState: "reserved" | "charged" | "released" | "migration_waived" | null;
|
||||
latestTurn: ConversationalRectificationTurn;
|
||||
messageHistory?: ReadonlyArray<ConversationalRectificationMessageHistoryEntry>;
|
||||
declaredBirthInput?: DeepReadonly<DeclaredBirthInput>;
|
||||
privateCandidate?: DeepReadonly<PrivateCandidate>;
|
||||
eventEvidence?: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
@@ -104,6 +106,7 @@ export type CreateConversationalRectificationCaseInput = MutationIdentity & Read
|
||||
export type LifeEventEvidenceInput = DeepReadonly<LifeEventEvidence>;
|
||||
|
||||
export type SaveConversationalRectificationTurnInput = CommandMutationIdentity & Readonly<{
|
||||
userMessage: string;
|
||||
turn: ConversationalRectificationTurnInput;
|
||||
evidence: ReadonlyArray<LifeEventEvidenceInput>;
|
||||
validationReceipt: ValidationReceiptInput;
|
||||
@@ -190,6 +193,8 @@ function parseStoredCase(data: unknown, allowNull = false): StoredConversational
|
||||
pendingConsultationQuestion: value.pending_consultation_question,
|
||||
billingState: value.billing_state,
|
||||
latestTurn: value.latest_turn,
|
||||
...(value.message_history === undefined
|
||||
? {} : { messageHistory: value.message_history }),
|
||||
...(value.declared_birth_input === undefined
|
||||
? {} : { declaredBirthInput: value.declared_birth_input }),
|
||||
...(value.private_candidate === undefined
|
||||
@@ -319,7 +324,7 @@ export class ConversationalRectificationStore {
|
||||
userId: string;
|
||||
caseId?: string;
|
||||
}>): Promise<LoadedConversationalRectificationCase | null> {
|
||||
const loaded = await this.callCaseRpc("load_conversational_rectification_case", {
|
||||
const loaded = await this.callCaseRpc("load_conversational_rectification_case_with_history", {
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId ?? null,
|
||||
}, true);
|
||||
@@ -349,11 +354,12 @@ export class ConversationalRectificationStore {
|
||||
input: SaveConversationalRectificationTurnInput,
|
||||
): Promise<StoredConversationalRectificationCase> {
|
||||
const functionName = input.turn.status === "completed"
|
||||
? "complete_conversational_rectification_with_range"
|
||||
: "save_conversational_rectification_turn";
|
||||
? "complete_conversational_rectification_with_range_and_history"
|
||||
: "save_conversational_rectification_turn_with_history";
|
||||
const result = await this.callCaseRpc(functionName, {
|
||||
...mutationArgs(input),
|
||||
p_command_fingerprint: commandFingerprint(input),
|
||||
p_user_message: input.userMessage,
|
||||
p_turn: requirePublicTurn(input.turn),
|
||||
p_evidence: requireEvidence(input.evidence),
|
||||
p_validation_receipt: requireValidationReceipt(input.validationReceipt),
|
||||
@@ -372,7 +378,7 @@ export class ConversationalRectificationStore {
|
||||
async abandon(
|
||||
input: ConversationalRectificationTransitionInput,
|
||||
): Promise<StoredConversationalRectificationCase> {
|
||||
return this.requireTransition("abandon_conversational_rectification_case", input);
|
||||
return this.requireTransition("abandon_conversational_rectification_without_result", input);
|
||||
}
|
||||
|
||||
private async requireTransition(
|
||||
|
||||
@@ -41,6 +41,29 @@ export type SuggestedEvidenceDomain = {
|
||||
readonly reason: string;
|
||||
};
|
||||
|
||||
export type RectificationTechniqueAuditRow = {
|
||||
readonly technique: string;
|
||||
readonly status: "used" | "partial" | "blocked" | "not_evaluated";
|
||||
readonly evidence: readonly string[];
|
||||
readonly boundary: string;
|
||||
};
|
||||
|
||||
export type RectificationExpertWorkflow = {
|
||||
readonly boundary: "not_auto_rectified";
|
||||
readonly candidateWindows: readonly {
|
||||
readonly startTime: string;
|
||||
readonly endTime: string;
|
||||
readonly status: "pending_validation" | "ready_for_confirmation";
|
||||
}[];
|
||||
readonly techniqueAuditTable: readonly RectificationTechniqueAuditRow[];
|
||||
readonly confirmationAllowed: boolean;
|
||||
readonly hardBlockers: readonly string[];
|
||||
readonly gates: Readonly<Record<string, {
|
||||
readonly status: "pass" | "fail" | "blocked" | "not_evaluated";
|
||||
readonly reason: string;
|
||||
}>>;
|
||||
};
|
||||
|
||||
export type RectificationTechnicalPacket = {
|
||||
readonly calculationVersion: string;
|
||||
readonly candidate: {
|
||||
@@ -79,6 +102,7 @@ export type RectificationTechnicalPacket = {
|
||||
readonly endDate: string;
|
||||
readonly scoreable: false;
|
||||
}[];
|
||||
readonly expertWorkflow?: RectificationExpertWorkflow;
|
||||
};
|
||||
|
||||
type PacketInput = {
|
||||
@@ -142,6 +166,90 @@ function unique(values: readonly string[]): string[] {
|
||||
return [...new Set(values.filter((value) => value.trim().length > 0))];
|
||||
}
|
||||
|
||||
function buildExpertWorkflow(
|
||||
eventScore: CandidateResult | null,
|
||||
range: RectificationTechnicalPacket["candidate"]["range"],
|
||||
status: RectificationTechnicalPacket["candidate"]["status"],
|
||||
): RectificationExpertWorkflow {
|
||||
const receipt = eventScore?.techniqueReceipt;
|
||||
const row = (
|
||||
technique: string,
|
||||
used: boolean,
|
||||
evidence: readonly string[],
|
||||
boundary: string,
|
||||
unavailableStatus: RectificationTechniqueAuditRow["status"] = "not_evaluated",
|
||||
): RectificationTechniqueAuditRow => ({
|
||||
technique,
|
||||
status: used ? "used" : unavailableStatus,
|
||||
evidence,
|
||||
boundary,
|
||||
});
|
||||
const missingLayers = receipt?.missingLayers ?? [];
|
||||
const shadbalaPartial = missingLayers.some((layer) => layer.toLowerCase().includes("shadbala"));
|
||||
const strengthLayers = receipt?.auxiliaryLayers.filter((layer) => /shadbala|ashtakavarga/i.test(layer)) ?? [];
|
||||
return {
|
||||
boundary: "not_auto_rectified",
|
||||
candidateWindows: [{ ...range, status }],
|
||||
techniqueAuditTable: [
|
||||
row(
|
||||
"Vimshottari Dasha",
|
||||
receipt?.dashaTracks.some((track) => track.includes("vimshottari")) === true,
|
||||
receipt?.dashaTracks.filter((track) => track.includes("vimshottari")) ?? [],
|
||||
"用于已发生事件的时间匹配,不单独确认出生分钟。",
|
||||
),
|
||||
row(
|
||||
"Narayana Dasha",
|
||||
receipt?.dashaTracks.some((track) => track.includes("narayana")) === true,
|
||||
receipt?.dashaTracks.filter((track) => track.includes("narayana")) ?? [],
|
||||
"作为第二条时序轨交叉检查,不替代分钟稳定性门禁。",
|
||||
),
|
||||
row(
|
||||
"UL / A7 / A10",
|
||||
(receipt?.usedArudha.length ?? 0) > 0,
|
||||
receipt?.usedArudha ?? [],
|
||||
"只在对应关系或事业事件中参与候选比较。",
|
||||
),
|
||||
row(
|
||||
"Divisional charts",
|
||||
(receipt?.usedDivisionalCharts.length ?? 0) > 0,
|
||||
receipt?.usedDivisionalCharts ?? [],
|
||||
"分盘只用于区分候选范围,不能把范围中点当作准确分钟。",
|
||||
),
|
||||
{
|
||||
technique: "Shadbala / Ashtakavarga",
|
||||
status: shadbalaPartial ? "partial" : strengthLayers.length > 0 ? "used" : "not_evaluated",
|
||||
evidence: unique([...strengthLayers, ...missingLayers.filter((layer) => /shadbala|ashtakavarga/i.test(layer))]),
|
||||
boundary: shadbalaPartial
|
||||
? "仅使用已验证组件;缺失组件不得补算或包装成完整闭环。"
|
||||
: "仅作为辅助权重,不单独确认出生分钟。",
|
||||
},
|
||||
row(
|
||||
"Functional Benefic / Malefic",
|
||||
receipt?.auxiliaryLayers.includes("functional_benefic_malefic") === true,
|
||||
receipt?.auxiliaryLayers.filter((layer) => layer === "functional_benefic_malefic") ?? [],
|
||||
"仅用于解释事件与候选的相容性。",
|
||||
),
|
||||
{
|
||||
technique: "KP cusp / sub-lord",
|
||||
status: "blocked",
|
||||
evidence: [],
|
||||
boundary: "当前服务端生时校正评分合同未提供可审计的 KP cusp 结果,禁止声称已使用。",
|
||||
},
|
||||
{
|
||||
technique: "Minute confirmation",
|
||||
status: receipt?.confirmationAllowed === true ? "used" : "blocked",
|
||||
evidence: receipt?.hardBlockers ?? ["minute_holdout_not_ready"],
|
||||
boundary: receipt?.confirmationAllowed === true
|
||||
? "仍须用户明确确认后才能替换当前排盘时间。"
|
||||
: "公开 AA 分钟 holdout 与盲测门禁未通过前,不得自动确认分钟。",
|
||||
},
|
||||
],
|
||||
confirmationAllowed: receipt?.confirmationAllowed === true,
|
||||
hardBlockers: receipt?.hardBlockers ?? ["minute_holdout_not_ready"],
|
||||
gates: receipt?.gates ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
function timeToMinute(value: string): number {
|
||||
const [hour = 0, minute = 0] = value.split(":").map(Number);
|
||||
return hour * 60 + minute;
|
||||
@@ -320,10 +428,11 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
|
||||
const partitionIds = unique(Object.values(input.candidateDifferences.scoringPartitions)
|
||||
.flatMap((partitions) => partitions.map((partition) => partition.partitionId)));
|
||||
|
||||
const candidateStatus = input.eventScore?.canApply ? "ready_for_confirmation" : "pending_validation";
|
||||
return {
|
||||
calculationVersion: input.consultation.calculationVersion,
|
||||
candidate: {
|
||||
status: input.eventScore?.canApply ? "ready_for_confirmation" : "pending_validation",
|
||||
status: candidateStatus,
|
||||
representativeTime,
|
||||
range,
|
||||
},
|
||||
@@ -349,6 +458,7 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
|
||||
suggestedDomains: domains.slice(0, 4),
|
||||
referenceIds: unique([...opportunityRefs, ...ruleRefs, ...layerRefs]),
|
||||
futureWindows: input.consultation.futureWindows.map((window) => ({ ...window, scoreable: false })),
|
||||
expertWorkflow: buildExpertWorkflow(input.eventScore, range, candidateStatus),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ConversationalRectificationTurn } from "./contracts.ts";
|
||||
|
||||
type EvidenceDomain = NonNullable<
|
||||
ConversationalRectificationTurn["evidenceRequest"]
|
||||
>["domains"][number];
|
||||
|
||||
const domainLabels = {
|
||||
career: "事业与身份",
|
||||
education: "学业与学习",
|
||||
finance: "收入与资产",
|
||||
health_pressure: "健康与重大压力",
|
||||
relocation: "搬迁与居住地",
|
||||
relationship: "重要关系",
|
||||
family: "家庭变化",
|
||||
other: "其他关键经历",
|
||||
} as const satisfies Readonly<Record<EvidenceDomain, string>>;
|
||||
|
||||
function nextEvidenceDomains(turn: ConversationalRectificationTurn): EvidenceDomain[] {
|
||||
const provided = new Set(turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : []));
|
||||
const requested = [...(turn.evidenceRequest?.domains ?? [])];
|
||||
const unanswered = requested.filter((domain) => !provided.has(domain));
|
||||
return (unanswered.length > 0 ? unanswered : requested).slice(0, 2);
|
||||
}
|
||||
|
||||
export function visibleRectificationNarrative(turn: ConversationalRectificationTurn): string {
|
||||
if (turn.narrative.trim()) return turn.narrative;
|
||||
|
||||
const suggestedDomains = nextEvidenceDomains(turn).map((domain) => domainLabels[domain]);
|
||||
if (turn.evidenceRecap.length === 0) {
|
||||
const examples = suggestedDomains.length > 0
|
||||
? suggestedDomains.join("或")
|
||||
: "工作、搬迁、关系或学业";
|
||||
return `为了帮助校正出生时间,请先说一件${examples}方面已经发生的重要经历。最好带上年月,直接像聊天一样描述即可。`;
|
||||
}
|
||||
|
||||
const latestEvidence = turn.evidenceRecap.at(-1)!;
|
||||
if (latestEvidence.dateLabel === "日期待补充") {
|
||||
return `你提到“${latestEvidence.summary}”,具体内容我已经记下了。它大致是什么年月?只记得年份也可以。`;
|
||||
}
|
||||
if (turn.candidate.status === "ready_for_confirmation") {
|
||||
return [
|
||||
`${latestEvidence.isCorrection ? "已修订" : "已记录"}:${latestEvidence.dateLabel} · ${latestEvidence.summary}。`,
|
||||
"目前已经形成一个待确认候选。你可以展开下方详情核对,也可以继续补充一件带年月的真实经历。",
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
const candidateRange = turn.candidate.rangeStart && turn.candidate.rangeEnd
|
||||
? `${turn.candidate.rangeStart}–${turn.candidate.rangeEnd}`
|
||||
: turn.candidate.representativeTime ?? "当前候选范围";
|
||||
const nextQuestion = suggestedDomains.length > 0
|
||||
? `接下来请说一件${suggestedDomains.join("或")}方面已经发生的事,尽量带上年月。`
|
||||
: "接下来请再说一件已经发生的真实经历,尽量带上年月。";
|
||||
return [
|
||||
`${latestEvidence.isCorrection ? "已修订" : "已记录"}:${latestEvidence.dateLabel} · ${latestEvidence.summary}。`,
|
||||
`候选范围现在是 ${candidateRange}。范围暂未变化不代表提交失败,我会结合后续经历继续比较相邻分钟。`,
|
||||
nextQuestion,
|
||||
].join("\n\n");
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
-- A birth-time rectification fee is earned only when a gated minute is
|
||||
-- explicitly confirmed. Range-only completion and abandonment refund the
|
||||
-- already charged reservation in the same transaction as the terminal state.
|
||||
|
||||
create or replace function public.conversational_rectification_refund_unconfirmed_case(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_action_id uuid
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_case public.birth_time_rectification_cases%rowtype;
|
||||
v_billing public.birth_time_rectification_billing%rowtype;
|
||||
v_balance integer;
|
||||
begin
|
||||
select c.* into v_case
|
||||
from public.birth_time_rectification_cases c
|
||||
where c.id = p_case_id and c.user_id = p_user_id
|
||||
for update;
|
||||
if not found
|
||||
or v_case.journey_protocol is distinct from 'conversational-evidence-v3'
|
||||
or v_case.status not in ('completed', 'abandoned')
|
||||
or v_case.turn_state #>> '{candidate,status}' = 'confirmed' then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select profile.credits into v_balance
|
||||
from public.profiles profile
|
||||
where profile.id = p_user_id
|
||||
for update;
|
||||
if not found then
|
||||
raise exception 'conversational_billing_failed' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
select b.* into v_billing
|
||||
from public.birth_time_rectification_billing b
|
||||
where b.case_id = p_case_id and b.user_id = p_user_id
|
||||
for update;
|
||||
if not found then
|
||||
raise exception 'conversational_billing_failed' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
if v_billing.state = 'charged' then
|
||||
update public.profiles profile
|
||||
set credits = profile.credits + v_billing.price,
|
||||
updated_at = pg_catalog.now()
|
||||
where profile.id = p_user_id
|
||||
returning profile.credits into v_balance;
|
||||
|
||||
insert into public.credit_transactions (
|
||||
user_id, transaction_type, amount, balance_after, request_id
|
||||
) values (
|
||||
p_user_id, 'refund', v_billing.price, v_balance,
|
||||
'rectification:' || p_case_id::text
|
||||
);
|
||||
|
||||
update public.birth_time_rectification_billing
|
||||
set state = 'released',
|
||||
release_action_id = p_action_id,
|
||||
balance_after = v_balance,
|
||||
released_at = pg_catalog.now(),
|
||||
updated_at = pg_catalog.now()
|
||||
where case_id = p_case_id and user_id = p_user_id and state = 'charged';
|
||||
if not found then
|
||||
raise exception 'conversational_billing_failed' using errcode = 'P0001';
|
||||
end if;
|
||||
elsif v_billing.state not in ('released', 'migration_waived') then
|
||||
raise exception 'conversational_billing_failed' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
return public.conversational_rectification_case_projection(p_user_id, p_case_id);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.complete_conversational_rectification_with_range(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_action_id uuid,
|
||||
p_turn jsonb,
|
||||
p_evidence jsonb,
|
||||
p_validation_receipt jsonb,
|
||||
p_private_candidate jsonb,
|
||||
p_command_fingerprint text default null
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_active_turn jsonb;
|
||||
v_response jsonb;
|
||||
v_expected_actions jsonb;
|
||||
begin
|
||||
v_expected_actions := case
|
||||
when p_turn -> 'pendingConsultationQuestion' = 'null'::jsonb then '[]'::jsonb
|
||||
else '["continue_original_question"]'::jsonb
|
||||
end;
|
||||
if public.conversational_rectification_valid_public_turn(p_turn) is not true
|
||||
or p_turn ->> 'status' is distinct from 'completed'
|
||||
or p_turn #>> '{candidate,status}' is distinct from 'pending_validation'
|
||||
or p_turn -> 'evidenceRequest' is distinct from 'null'::jsonb
|
||||
or p_turn -> 'actions' is distinct from v_expected_actions then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
v_active_turn := pg_catalog.jsonb_set(p_turn, '{status}', '"active"'::jsonb, false);
|
||||
v_active_turn := pg_catalog.jsonb_set(v_active_turn, '{actions}', '[]'::jsonb, false);
|
||||
perform public.save_conversational_rectification_turn(
|
||||
p_user_id,
|
||||
p_case_id,
|
||||
p_expected_version,
|
||||
p_action_id,
|
||||
v_active_turn,
|
||||
p_evidence,
|
||||
p_validation_receipt,
|
||||
p_private_candidate,
|
||||
p_command_fingerprint
|
||||
);
|
||||
|
||||
update public.birth_time_rectification_turns
|
||||
set narrative = p_turn ->> 'narrative',
|
||||
evidence_request = null,
|
||||
actions = p_turn -> 'actions'
|
||||
where case_id = p_case_id
|
||||
and turn_version = p_expected_version + 1;
|
||||
if not found then
|
||||
raise exception 'conversational_stale_turn' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
update public.birth_time_rectification_cases
|
||||
set status = 'completed',
|
||||
turn_state = p_turn,
|
||||
journey_snapshot = p_turn,
|
||||
updated_at = pg_catalog.now()
|
||||
where id = p_case_id
|
||||
and user_id = p_user_id
|
||||
and turn_version = p_expected_version + 1;
|
||||
if not found then
|
||||
raise exception 'conversational_stale_turn' using errcode = 'P0001';
|
||||
end if;
|
||||
|
||||
v_response := public.conversational_rectification_refund_unconfirmed_case(
|
||||
p_user_id, p_case_id, p_action_id
|
||||
);
|
||||
update public.birth_time_rectification_action_receipts
|
||||
set response = v_response
|
||||
where case_id = p_case_id
|
||||
and action_id = p_action_id
|
||||
and user_id = p_user_id
|
||||
and action_kind = 'save_turn'
|
||||
and expected_turn_version = p_expected_version;
|
||||
if not found then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_response;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.abandon_conversational_rectification_without_result(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_action_id uuid,
|
||||
p_turn jsonb,
|
||||
p_validation_receipt jsonb,
|
||||
p_command_fingerprint text default null
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_response jsonb;
|
||||
begin
|
||||
perform public.abandon_conversational_rectification_case(
|
||||
p_user_id,
|
||||
p_case_id,
|
||||
p_expected_version,
|
||||
p_action_id,
|
||||
p_turn,
|
||||
p_validation_receipt,
|
||||
p_command_fingerprint
|
||||
);
|
||||
v_response := public.conversational_rectification_refund_unconfirmed_case(
|
||||
p_user_id, p_case_id, p_action_id
|
||||
);
|
||||
update public.birth_time_rectification_action_receipts
|
||||
set response = v_response
|
||||
where case_id = p_case_id
|
||||
and action_id = p_action_id
|
||||
and user_id = p_user_id
|
||||
and action_kind = 'abandon'
|
||||
and expected_turn_version = p_expected_version;
|
||||
if not found then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_response;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.conversational_rectification_refund_unconfirmed_case(
|
||||
uuid, uuid, uuid
|
||||
) from public, anon, authenticated;
|
||||
revoke all on function public.abandon_conversational_rectification_without_result(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, text
|
||||
) from public, anon;
|
||||
grant execute on function public.abandon_conversational_rectification_without_result(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, text
|
||||
) to authenticated, service_role;
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
-- Preserve the real user/Agent exchange across reloads. The durable public turn
|
||||
-- remains unchanged; history is returned only by the service-role resume RPC.
|
||||
|
||||
alter table public.birth_time_rectification_turns
|
||||
add column if not exists user_message text;
|
||||
|
||||
alter table public.birth_time_rectification_turns
|
||||
drop constraint if exists birth_time_rectification_turns_user_message_check;
|
||||
|
||||
alter table public.birth_time_rectification_turns
|
||||
add constraint birth_time_rectification_turns_user_message_check check (
|
||||
user_message is null or (
|
||||
public.conversational_rectification_text_utf16_length(user_message) between 1 and 4000
|
||||
and public.conversational_rectification_text_is_nonblank(user_message)
|
||||
)
|
||||
);
|
||||
|
||||
-- Older answer turns already point to their extracted evidence. Recover the
|
||||
-- original raw answer where it is unambiguous instead of inventing UI copy.
|
||||
update public.birth_time_rectification_turns turn_row
|
||||
set user_message = (
|
||||
select event.raw_text
|
||||
from public.birth_time_rectification_event_evidence event
|
||||
where event.case_id = turn_row.case_id
|
||||
and event.source_turn_id = turn_row.id
|
||||
order by event.created_at, event.id
|
||||
limit 1
|
||||
)
|
||||
where turn_row.user_message is null
|
||||
and exists (
|
||||
select 1
|
||||
from public.birth_time_rectification_event_evidence event
|
||||
where event.case_id = turn_row.case_id
|
||||
and event.source_turn_id = turn_row.id
|
||||
);
|
||||
|
||||
create or replace function public.load_conversational_rectification_case_with_history(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid default null
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_loaded jsonb;
|
||||
v_case_id uuid;
|
||||
begin
|
||||
v_loaded := public.load_conversational_rectification_case(p_user_id, p_case_id);
|
||||
if v_loaded is null then
|
||||
return null;
|
||||
end if;
|
||||
v_case_id := (v_loaded ->> 'case_id')::uuid;
|
||||
return v_loaded || pg_catalog.jsonb_build_object(
|
||||
'message_history', coalesce((
|
||||
select pg_catalog.jsonb_agg(
|
||||
pg_catalog.jsonb_build_object(
|
||||
'turnVersion', turn_row.turn_version,
|
||||
'userMessage', turn_row.user_message,
|
||||
'narrative', turn_row.narrative
|
||||
) order by turn_row.turn_version
|
||||
)
|
||||
from (
|
||||
select history.turn_version, history.user_message, history.narrative
|
||||
from public.birth_time_rectification_turns history
|
||||
where history.case_id = v_case_id
|
||||
order by history.turn_version desc
|
||||
limit 200
|
||||
) turn_row
|
||||
), '[]'::jsonb)
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.save_conversational_rectification_turn_with_history(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_action_id uuid,
|
||||
p_turn jsonb,
|
||||
p_evidence jsonb,
|
||||
p_validation_receipt jsonb,
|
||||
p_private_candidate jsonb,
|
||||
p_command_fingerprint text,
|
||||
p_user_message text
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_response jsonb;
|
||||
v_saved_user_message text;
|
||||
begin
|
||||
if p_user_message is null
|
||||
or public.conversational_rectification_text_utf16_length(p_user_message) not between 1 and 4000
|
||||
or public.conversational_rectification_text_is_nonblank(p_user_message) is not true then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
v_response := public.save_conversational_rectification_turn(
|
||||
p_user_id, p_case_id, p_expected_version, p_action_id, p_turn, p_evidence,
|
||||
p_validation_receipt, p_private_candidate, p_command_fingerprint
|
||||
);
|
||||
update public.birth_time_rectification_turns
|
||||
set user_message = coalesce(user_message, p_user_message)
|
||||
where case_id = p_case_id
|
||||
and turn_version = p_expected_version + 1
|
||||
returning user_message into v_saved_user_message;
|
||||
if not found or v_saved_user_message is distinct from p_user_message then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_response;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.complete_conversational_rectification_with_range_and_history(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_action_id uuid,
|
||||
p_turn jsonb,
|
||||
p_evidence jsonb,
|
||||
p_validation_receipt jsonb,
|
||||
p_private_candidate jsonb,
|
||||
p_command_fingerprint text,
|
||||
p_user_message text
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_response jsonb;
|
||||
v_saved_user_message text;
|
||||
begin
|
||||
if p_user_message is null
|
||||
or public.conversational_rectification_text_utf16_length(p_user_message) not between 1 and 4000
|
||||
or public.conversational_rectification_text_is_nonblank(p_user_message) is not true then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
v_response := public.complete_conversational_rectification_with_range(
|
||||
p_user_id, p_case_id, p_expected_version, p_action_id, p_turn, p_evidence,
|
||||
p_validation_receipt, p_private_candidate, p_command_fingerprint
|
||||
);
|
||||
update public.birth_time_rectification_turns
|
||||
set user_message = coalesce(user_message, p_user_message)
|
||||
where case_id = p_case_id
|
||||
and turn_version = p_expected_version + 1
|
||||
returning user_message into v_saved_user_message;
|
||||
if not found or v_saved_user_message is distinct from p_user_message then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_response;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.load_conversational_rectification_case_with_history(uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
revoke all on function public.save_conversational_rectification_turn_with_history(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
|
||||
) from public, anon, authenticated;
|
||||
revoke all on function public.complete_conversational_rectification_with_range_and_history(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
|
||||
) from public, anon, authenticated;
|
||||
|
||||
grant execute on function public.load_conversational_rectification_case_with_history(uuid, uuid)
|
||||
to service_role;
|
||||
grant execute on function public.save_conversational_rectification_turn_with_history(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
|
||||
) to service_role;
|
||||
grant execute on function public.complete_conversational_rectification_with_range_and_history(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
|
||||
) to service_role;
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
-- Only render message history that was captured verbatim at answer time.
|
||||
-- Evidence rows created before the history contract contain normalized event
|
||||
-- summaries, not guaranteed user-authored chat text, so they must not be used
|
||||
-- to reconstruct a conversation.
|
||||
|
||||
alter table public.birth_time_rectification_turns
|
||||
add column if not exists user_message_captured boolean not null default false;
|
||||
|
||||
create or replace function public.load_conversational_rectification_case_with_history(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid default null
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
stable
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_loaded jsonb;
|
||||
v_case_id uuid;
|
||||
begin
|
||||
v_loaded := public.load_conversational_rectification_case(p_user_id, p_case_id);
|
||||
if v_loaded is null then
|
||||
return null;
|
||||
end if;
|
||||
v_case_id := (v_loaded ->> 'case_id')::uuid;
|
||||
return v_loaded || pg_catalog.jsonb_build_object(
|
||||
'message_history', coalesce((
|
||||
select pg_catalog.jsonb_agg(
|
||||
pg_catalog.jsonb_build_object(
|
||||
'turnVersion', turn_row.turn_version,
|
||||
'userMessage', turn_row.user_message,
|
||||
'narrative', turn_row.narrative
|
||||
) order by turn_row.turn_version
|
||||
)
|
||||
from (
|
||||
select history.turn_version, history.user_message, history.narrative
|
||||
from public.birth_time_rectification_turns history
|
||||
where history.case_id = v_case_id
|
||||
and history.user_message_captured is true
|
||||
order by history.turn_version desc
|
||||
limit 200
|
||||
) turn_row
|
||||
), '[]'::jsonb)
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.save_conversational_rectification_turn_with_history(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_action_id uuid,
|
||||
p_turn jsonb,
|
||||
p_evidence jsonb,
|
||||
p_validation_receipt jsonb,
|
||||
p_private_candidate jsonb,
|
||||
p_command_fingerprint text,
|
||||
p_user_message text
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_response jsonb;
|
||||
v_saved_user_message text;
|
||||
begin
|
||||
if p_user_message is null
|
||||
or public.conversational_rectification_text_utf16_length(p_user_message) not between 1 and 4000
|
||||
or public.conversational_rectification_text_is_nonblank(p_user_message) is not true then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
v_response := public.save_conversational_rectification_turn(
|
||||
p_user_id, p_case_id, p_expected_version, p_action_id, p_turn, p_evidence,
|
||||
p_validation_receipt, p_private_candidate, p_command_fingerprint
|
||||
);
|
||||
update public.birth_time_rectification_turns
|
||||
set user_message = coalesce(user_message, p_user_message),
|
||||
user_message_captured = true
|
||||
where case_id = p_case_id
|
||||
and turn_version = p_expected_version + 1
|
||||
returning user_message into v_saved_user_message;
|
||||
if not found or v_saved_user_message is distinct from p_user_message then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_response;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.complete_conversational_rectification_with_range_and_history(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_expected_version bigint,
|
||||
p_action_id uuid,
|
||||
p_turn jsonb,
|
||||
p_evidence jsonb,
|
||||
p_validation_receipt jsonb,
|
||||
p_private_candidate jsonb,
|
||||
p_command_fingerprint text,
|
||||
p_user_message text
|
||||
)
|
||||
returns jsonb
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_response jsonb;
|
||||
v_saved_user_message text;
|
||||
begin
|
||||
if p_user_message is null
|
||||
or public.conversational_rectification_text_utf16_length(p_user_message) not between 1 and 4000
|
||||
or public.conversational_rectification_text_is_nonblank(p_user_message) is not true then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
v_response := public.complete_conversational_rectification_with_range(
|
||||
p_user_id, p_case_id, p_expected_version, p_action_id, p_turn, p_evidence,
|
||||
p_validation_receipt, p_private_candidate, p_command_fingerprint
|
||||
);
|
||||
update public.birth_time_rectification_turns
|
||||
set user_message = coalesce(user_message, p_user_message),
|
||||
user_message_captured = true
|
||||
where case_id = p_case_id
|
||||
and turn_version = p_expected_version + 1
|
||||
returning user_message into v_saved_user_message;
|
||||
if not found or v_saved_user_message is distinct from p_user_message then
|
||||
raise exception 'conversational_action_conflict' using errcode = 'P0001';
|
||||
end if;
|
||||
return v_response;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.load_conversational_rectification_case_with_history(uuid, uuid)
|
||||
from public, anon, authenticated;
|
||||
revoke all on function public.save_conversational_rectification_turn_with_history(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
|
||||
) from public, anon, authenticated;
|
||||
revoke all on function public.complete_conversational_rectification_with_range_and_history(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
|
||||
) from public, anon, authenticated;
|
||||
|
||||
grant execute on function public.load_conversational_rectification_case_with_history(uuid, uuid)
|
||||
to service_role;
|
||||
grant execute on function public.save_conversational_rectification_turn_with_history(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
|
||||
) to service_role;
|
||||
grant execute on function public.complete_conversational_rectification_with_range_and_history(
|
||||
uuid, uuid, bigint, uuid, jsonb, jsonb, jsonb, jsonb, text, text
|
||||
) to service_role;
|
||||
@@ -20,7 +20,7 @@ test("journey engine serializes only stored event-scoring inputs", () => {
|
||||
lon: 121.4737,
|
||||
tz: 8,
|
||||
events: [
|
||||
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month" },
|
||||
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" },
|
||||
{ id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "education", date: "2011", precision: "year" },
|
||||
{ id: "0ef52e51-ab5f-453b-81e5-adb44a929224", domain: "relationship", date: "2021-05-01", precision: "day" },
|
||||
],
|
||||
@@ -34,7 +34,7 @@ test("journey engine serializes only stored event-scoring inputs", () => {
|
||||
lon: 121.4737,
|
||||
tz: 8,
|
||||
events: [
|
||||
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month" },
|
||||
{ id: "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", domain: "career", date: "2019-07", precision: "month", summary: "晋升为团队负责人" },
|
||||
{ id: "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", domain: "education", date: "2011", precision: "year" },
|
||||
{ id: "0ef52e51-ab5f-453b-81e5-adb44a929224", domain: "relationship", date: "2021-05-01", precision: "day" },
|
||||
],
|
||||
|
||||
@@ -45,10 +45,14 @@ test("does not duplicate a completed assistant answer while loading state settle
|
||||
});
|
||||
|
||||
test("shows honest agent activity states before and during streamed text", () => {
|
||||
assert.match(messageRowSource, /state="working" label="正在核对星盘信息…"/);
|
||||
assert.match(messageRowSource, /message\.state === "streaming" && <AgentActivityStatus state="composing"/);
|
||||
assert.match(messageRowSource, /message\.state === "thinking"[\s\S]*?\? "working"/);
|
||||
assert.match(messageRowSource, /message\.state === "thinking" \? "正在核对星盘信息…"/);
|
||||
assert.match(messageRowSource, /message\.state === "streaming"[\s\S]*?"composing"/);
|
||||
assert.match(messageRowSource, /: "completed"/);
|
||||
assert.match(messageRowSource, /message\.text && <ChatMessageContent text=\{message\.text\}/);
|
||||
assert.match(activitySource, /<ThinkingOrb aria-hidden="true" state=\{state\} size=\{20\}/);
|
||||
assert.match(activitySource, /satisfies Record<OrbState, string>/);
|
||||
assert.match(activitySource, /<CircleCheck aria-hidden="true" size=\{20\}/);
|
||||
assert.match(activitySource, /satisfies Record<OrbState \| "completed", string>/);
|
||||
assert.doesNotMatch(globalStyles, /\.thinking\b/);
|
||||
});
|
||||
|
||||
|
||||
@@ -116,8 +116,10 @@ test("homepage birth-time card opens its dedicated session before the first turn
|
||||
assert.match(handler, /rectificationOpenInFlight\.current/);
|
||||
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
|
||||
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
|
||||
assert.match(handler, /type: "start",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
|
||||
assert.match(handler, /type: "resume",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
|
||||
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
|
||||
assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?initialTurn=\{visibleRectificationTurn\}/);
|
||||
assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?initialTurn=\{visibleRectificationTurn\}[\s\S]*?openingAssistantText=\{rectificationOpeningAssistantText\}/);
|
||||
});
|
||||
|
||||
test("rectification cards render only inside the active rectification session", () => {
|
||||
@@ -192,13 +194,14 @@ test("rectify-first suggestions hand the source question to a dedicated rectific
|
||||
assert.match(source, /onClick=\{\(\) => chooseConversationSuggestion\(question\)\}/);
|
||||
});
|
||||
|
||||
test("completed handoffs automatically return and continue the source question", () => {
|
||||
test("completed handoffs return only after the user clicks and target the source session", () => {
|
||||
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /turn\.status !== "completed"/);
|
||||
assert.match(source, /turn\.actions\.includes\("continue_original_question"\)/);
|
||||
assert.match(source, /automaticRectificationContinuation\.current === continuationIdentity/);
|
||||
assert.match(source, /continueRectificationQuestion\.current\(question\)/);
|
||||
assert.doesNotMatch(source, /automaticRectificationContinuation/);
|
||||
assert.match(source, /const returnSession = \(localHandoff/);
|
||||
assert.match(source, /session\.sessionType === "consultation"/);
|
||||
assert.match(source, /onContinueOriginalQuestion=\{\(question\) => void continueRectificationOriginalQuestion\(question\)\}/);
|
||||
assert.match(source, /sessionId: returnSession\.id/);
|
||||
assert.match(source, /setActiveSessionId\(context\.sessionId\)/);
|
||||
assert.match(source, /clearBirthTimeConsultationConsent\([\s\S]*?context\.sessionId/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
MAXIMUM_SCOREABLE_EVENTS,
|
||||
rangeCompletionReason,
|
||||
} from "../src/lib/conversational-rectification/convergence.ts";
|
||||
import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts";
|
||||
|
||||
const pendingPacket = {
|
||||
candidate: { status: "pending_validation" },
|
||||
suggestedDomains: [
|
||||
{ domain: "relationship", layer: "D9", reason: "relationship evidence" },
|
||||
{ domain: "finance", layer: "D11", reason: "finance evidence" },
|
||||
],
|
||||
} as RectificationTechnicalPacket;
|
||||
|
||||
test("does not stop at the evidence count limit while discriminating domains remain unanswered", () => {
|
||||
assert.equal(rangeCompletionReason({
|
||||
packet: pendingPacket,
|
||||
scoreableEventCount: MAXIMUM_SCOREABLE_EVENTS,
|
||||
plateauCount: 1,
|
||||
unansweredSuggestedDomainCount: 2,
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("stops at the evidence count limit after the suggested domains are covered", () => {
|
||||
assert.equal(rangeCompletionReason({
|
||||
packet: pendingPacket,
|
||||
scoreableEventCount: MAXIMUM_SCOREABLE_EVENTS,
|
||||
plateauCount: 1,
|
||||
unansweredSuggestedDomainCount: 0,
|
||||
}), "evidence_limit");
|
||||
});
|
||||
@@ -24,6 +24,21 @@ test("preserves raw text and splits two clear facts sharing an explicit month",
|
||||
assert.equal(new Set(evidence.map((item) => item.id)).size, 2);
|
||||
});
|
||||
|
||||
test("merges same-date same-domain clauses into one scoreable life event", () => {
|
||||
const rawText = "2017年7月入职第一家公司,并从事数据分析工作";
|
||||
const evidence = extractLifeEventEvidence({
|
||||
rawText,
|
||||
sourceTurnId,
|
||||
asOfDate: "2026-07-20",
|
||||
});
|
||||
|
||||
assert.equal(evidence.length, 1);
|
||||
assert.equal(evidence[0]?.domain, "career");
|
||||
assert.equal(evidence[0]?.dateValue, "2017-07");
|
||||
assert.equal(evidence[0]?.eventSummary, "入职第一家公司;从事数据分析工作");
|
||||
assert.equal(evidence[0]?.scoreable, true);
|
||||
});
|
||||
|
||||
test("removes the date-picker transport labels from the visible event summary", () => {
|
||||
const rawText = "发生时间:2016 年 6 月\n事件详情:大学毕业";
|
||||
const [evidence] = extractLifeEventEvidence({
|
||||
@@ -121,6 +136,21 @@ test("classifies dated income and asset changes as finance evidence", () => {
|
||||
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
|
||||
});
|
||||
|
||||
for (const rawText of [
|
||||
"2020年8月开始承担管理职责",
|
||||
"2020年8月职位发生明显变化",
|
||||
"2020年8月开始任职部门负责人",
|
||||
]) {
|
||||
test(`classifies dated role and management changes as career evidence: ${rawText}`, () => {
|
||||
const [evidence] = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" });
|
||||
|
||||
assert.equal(evidence?.domain, "career");
|
||||
assert.equal(evidence?.dateValue, "2020-08");
|
||||
assert.equal(evidence?.scoreable, true);
|
||||
assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("keeps a bare year as non-scoreable clarification instead of an event summary", () => {
|
||||
const rawText = "2021年";
|
||||
const [evidence] = extractLifeEventEvidence({
|
||||
|
||||
@@ -119,6 +119,201 @@ test("validates a rich first-turn narrative against the technical packet", async
|
||||
assert.doesNotMatch(result.narrative, /^哪一个时间段[\s\S]*\d{4}[–—-]\d{4}/);
|
||||
});
|
||||
|
||||
test("repairs missing safety wording without replacing the model-authored narrative", async () => {
|
||||
const packet = syntheticTechnicalPacket();
|
||||
const output = {
|
||||
...richOutput(),
|
||||
narrative: "我们先从你印象最深的一次关系变化聊起,好吗?",
|
||||
evidenceRequest: {
|
||||
domains: ["relationship" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "那段关系大约是什么时候发生的?",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "first",
|
||||
packet,
|
||||
generator: generator([output]),
|
||||
});
|
||||
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.match(result.narrative, /印象最深的一次关系变化/);
|
||||
assert.match(result.narrative, /05:16–05:24/);
|
||||
assert.match(result.narrative, /不能直接当作已经确认/);
|
||||
assert.match(result.narrative, /关系变化[\s\S]*什么时候发生/);
|
||||
assert.doesNotMatch(result.narrative, /请只提供已经发生/);
|
||||
assert.match(result.output.evidenceRequest?.prompt ?? "", /关系大约是什么时候/);
|
||||
assert.doesNotMatch(result.output.evidenceRequest?.prompt ?? "", /请以已经发生的真实事件为准/);
|
||||
});
|
||||
|
||||
test("repairs a natural intermediate follow-up prompt without discarding the specific reply", async () => {
|
||||
const packet = syntheticTechnicalPacket();
|
||||
const output = {
|
||||
...richOutput(),
|
||||
narrative: "你把毕业和读研的衔接说清楚了,这两段会分别记录。下一步想确认毕业后的第一份工作是什么时候开始的?",
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "第一份工作是什么时候开始的?",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet,
|
||||
generator: generator([output]),
|
||||
});
|
||||
|
||||
assert.equal(result.attempts, 1);
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.match(result.narrative, /毕业和读研的衔接/);
|
||||
assert.doesNotMatch(result.narrative, /当前累计/);
|
||||
assert.equal(result.output.evidenceRequest?.prompt, "第一份工作是什么时候开始的?");
|
||||
});
|
||||
|
||||
test("makes an internal evidence prompt visible when the acknowledgement contains no question", async () => {
|
||||
const output = {
|
||||
...richOutput(),
|
||||
narrative: "已记录本科毕业后衔接读研,这是一段连续教育转折,暂时不重复计数。",
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "毕业后的第一份工作是什么时候开始的?",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
generator: generator([output]),
|
||||
});
|
||||
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.match(result.narrative, /连续教育转折/);
|
||||
assert.match(result.narrative, /第一份工作是什么时候开始的/);
|
||||
assert.doesNotMatch(result.narrative, /请只提供已经发生/);
|
||||
});
|
||||
|
||||
test("adds the concrete question when the acknowledgement only says whether more detail is needed", async () => {
|
||||
const output = {
|
||||
...richOutput(),
|
||||
narrative: "这次入职会作为一条事业事件记录,但还需要知道后续是否发生过离职、转岗或升职。",
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "这份工作后来第一次发生明确变化是在什么时候?",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
generator: generator([output]),
|
||||
});
|
||||
|
||||
assert.equal(result.fallbackUsed, false);
|
||||
assert.match(result.narrative, /还需要知道后续是否发生过/);
|
||||
assert.match(result.narrative, /第一次发生明确变化是在什么时候/);
|
||||
});
|
||||
|
||||
test("passes the bounded expert workflow to the skill-guided narrator", async () => {
|
||||
const prompts: string[] = [];
|
||||
const packet = {
|
||||
...syntheticTechnicalPacket(),
|
||||
expertWorkflow: {
|
||||
boundary: "not_auto_rectified" as const,
|
||||
candidateWindows: [{
|
||||
startTime: "05:16",
|
||||
endTime: "05:24",
|
||||
status: "pending_validation" as const,
|
||||
}],
|
||||
techniqueAuditTable: [{
|
||||
technique: "KP cusp / sub-lord",
|
||||
status: "blocked" as const,
|
||||
evidence: [],
|
||||
boundary: "当前评分合同没有可审计结果。",
|
||||
}],
|
||||
confirmationAllowed: false,
|
||||
hardBlockers: ["minute_holdout_not_ready"],
|
||||
gates: {},
|
||||
},
|
||||
};
|
||||
|
||||
await generateRectificationNarrative({
|
||||
phase: "first",
|
||||
packet,
|
||||
generator: generator([richOutput()], prompts),
|
||||
});
|
||||
|
||||
assert.match(prompts[0] ?? "", /expertWorkflow/);
|
||||
assert.match(prompts[0] ?? "", /KP cusp \/ sub-lord/);
|
||||
assert.match(prompts[0] ?? "", /blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed/);
|
||||
});
|
||||
|
||||
test("passes the user's latest concrete event to an intermediate skill-guided reply", async () => {
|
||||
const prompts: string[] = [];
|
||||
await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
context: {
|
||||
latestEvidence: [{
|
||||
dateLabel: "2023-09",
|
||||
summary: "离开家乡去上海开始第一份长期工作",
|
||||
domain: "career",
|
||||
}],
|
||||
},
|
||||
generator: generator([richOutput()], prompts),
|
||||
});
|
||||
|
||||
assert.match(prompts[0] ?? "", /离开家乡去上海开始第一份长期工作/);
|
||||
assert.match(prompts[0] ?? "", /acknowledgeLatestEvidenceSpecificallyBeforeAsking/);
|
||||
assert.match(prompts[0] ?? "", /doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked/);
|
||||
});
|
||||
|
||||
test("passes the active event ledger and unresolved facts to the intermediate agent", async () => {
|
||||
const prompts: string[] = [];
|
||||
await generateRectificationNarrative({
|
||||
phase: "intermediate",
|
||||
packet: syntheticTechnicalPacket(),
|
||||
context: {
|
||||
latestUserText: "23年关系结束后发生过一次交通事故",
|
||||
latestEvidence: [{
|
||||
dateLabel: "2023",
|
||||
summary: "关系结束后发生过一次交通事故",
|
||||
domain: "health_pressure",
|
||||
}],
|
||||
eventLedger: [{
|
||||
id: "relationship-ending",
|
||||
rawText: "2024年8月8日一段重要关系结束",
|
||||
dateLabel: "2024-08-08",
|
||||
summary: "一段重要关系结束",
|
||||
domain: "relationship",
|
||||
extractionStatus: "clear",
|
||||
active: true,
|
||||
correctsEvidenceIds: [],
|
||||
}],
|
||||
unresolvedEvidence: [{
|
||||
id: "accident-year-conflict",
|
||||
rawText: "23年关系结束后发生过一次交通事故",
|
||||
summary: "关系结束后发生过一次交通事故",
|
||||
domain: "health_pressure",
|
||||
dateLabel: "2023",
|
||||
}],
|
||||
},
|
||||
generator: generator([richOutput()], prompts),
|
||||
});
|
||||
|
||||
const prompt = prompts[0] ?? "";
|
||||
assert.match(prompt, /23年关系结束后发生过一次交通事故/);
|
||||
assert.match(prompt, /2024-08-08/);
|
||||
assert.match(prompt, /一段重要关系结束/);
|
||||
assert.match(prompt, /finishCurrentEventBeforeSwitchingDomains/);
|
||||
assert.match(prompt, /resolveDateContradictionsBeforeScoring/);
|
||||
assert.match(prompt, /mergeSameEventDetailsWithoutDoubleCounting/);
|
||||
});
|
||||
|
||||
test("rejects invented representative times, layers, and references", () => {
|
||||
const packet = syntheticTechnicalPacket();
|
||||
const invalid = {
|
||||
@@ -244,6 +439,24 @@ test("accepts a request for one real past event's year and month without propose
|
||||
);
|
||||
});
|
||||
|
||||
test("accepts a natural reply that mentions several known dates before asking a non-year alternative", () => {
|
||||
const output = richOutput();
|
||||
const legitimate = {
|
||||
...output,
|
||||
narrative: "你在2014年开始读研,并在2017年正常毕业,这条教育线已经完整。毕业后的第一份工作是直接入职,还是先休息了一段时间?",
|
||||
evidenceRequest: {
|
||||
domains: ["career" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
prompt: "毕业后的第一份工作是什么时候开始的?",
|
||||
},
|
||||
} satisfies RectificationNarrativeModelOutput;
|
||||
|
||||
assert.deepEqual(
|
||||
validateNarrativeAgainstPacket(legitimate, syntheticTechnicalPacket(), "intermediate"),
|
||||
{ valid: true, issues: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects ungrounded layers and references nested in a domain reason", () => {
|
||||
const output = richOutput();
|
||||
const firstReason = output.domainReasons[0];
|
||||
|
||||
@@ -204,3 +204,33 @@ test("502 and non-JSON failures retry only once before one stable Chinese messag
|
||||
assert.equal(bodies.length, 2);
|
||||
assert.equal(bodies[0], bodies[1]);
|
||||
});
|
||||
|
||||
test("client emits validated rectification narrative chunks before accepting the durable turn", async (context) => {
|
||||
const chunks = ["已记录这段经历。", "接下来核对关系事件。"];
|
||||
context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => {
|
||||
assert.match(String((init?.headers as Record<string, string>)?.Accept), /application\/x-ndjson/);
|
||||
return new Response([
|
||||
...chunks.map((text) => JSON.stringify({ type: "delta", text })),
|
||||
JSON.stringify({ type: "turn", turn }),
|
||||
"",
|
||||
].join("\n"), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
|
||||
});
|
||||
});
|
||||
const seen: string[] = [];
|
||||
|
||||
const result = await sendConversationalRectificationCommand({
|
||||
type: "pause",
|
||||
caseId,
|
||||
actionId: firstActionId,
|
||||
turnVersion: 4,
|
||||
}, {
|
||||
onNarrativeDelta(text) {
|
||||
seen.push(text);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, chunks);
|
||||
assert.deepEqual(result, turn);
|
||||
});
|
||||
|
||||
@@ -91,10 +91,11 @@ test("rectification is a language-first exchange with one free-text answer path"
|
||||
{ controller: controller() },
|
||||
));
|
||||
|
||||
assert.match(markup, /已记录:2021 年 7 月 · 开始第一份长期工作/);
|
||||
assert.match(markup, /目前已经形成一个待确认候选/);
|
||||
assert.ok(markup.indexOf("待确认候选") < markup.indexOf("当前候选 05:18"));
|
||||
assert.doesNotMatch(markup, /D9 与 D10|当前判断/);
|
||||
assert.match(markup, /当前判断/);
|
||||
assert.match(markup, /D9 与 D10/);
|
||||
assert.doesNotMatch(markup, /已记录的经历|已记录的真实经历|更正这条经历/);
|
||||
assert.doesNotMatch(markup, /当前候选 05:18/);
|
||||
assert.doesNotMatch(markup, /目前已经形成一个待确认候选/);
|
||||
assert.match(markup, /<textarea[^>]+id="conversational-rectification-answer"/);
|
||||
assert.match(markup, /像聊天一样回答即可/);
|
||||
assert.match(markup, /2018 年 6 月去了上海工作/);
|
||||
@@ -103,7 +104,7 @@ test("rectification is a language-first exchange with one free-text answer path"
|
||||
assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/);
|
||||
});
|
||||
|
||||
test("a resumed legacy turn replaces repeated technical prose with actionable guidance", () => {
|
||||
test("a resumed legacy turn preserves its Agent narrative without a separate evidence panel", () => {
|
||||
const legacyTurn = {
|
||||
...turn,
|
||||
status: "active",
|
||||
@@ -116,10 +117,34 @@ test("a resumed legacy turn replaces repeated technical prose with actionable gu
|
||||
{ controller: controller({ turn: legacyTurn }) },
|
||||
));
|
||||
|
||||
assert.match(markup, /已记录:2021 年 7 月 · 开始第一份长期工作/);
|
||||
assert.match(markup, /范围暂未变化不代表提交失败/);
|
||||
assert.match(markup, /接下来请说一件重要关系或搬迁与居住地方面已经发生的事/);
|
||||
assert.doesNotMatch(markup, /D1 保持稳定/);
|
||||
assert.match(markup, /05:30 是范围内的待验证候选/);
|
||||
assert.match(markup, /D1 保持稳定/);
|
||||
assert.doesNotMatch(markup, /2021 年 7 月 · 开始第一份长期工作|已记录的经历/);
|
||||
assert.doesNotMatch(markup, /范围暂未变化不代表提交失败/);
|
||||
});
|
||||
|
||||
test("an incomplete concrete event gets a targeted clarification instead of the generic question", () => {
|
||||
const incompleteTurn = {
|
||||
...turn,
|
||||
status: "active",
|
||||
candidate: { ...turn.candidate, status: "pending_validation" },
|
||||
evidenceRecap: [{
|
||||
...turn.evidenceRecap[0]!,
|
||||
summary: "离开家去北京开始工作",
|
||||
dateLabel: "日期待补充",
|
||||
domain: "relocation",
|
||||
}],
|
||||
narrative: "你提到离开家去北京开始工作,这件事很有区分度。大致是什么年月?",
|
||||
actions: ["answer", "pause", "abandon"],
|
||||
} satisfies ConversationalRectificationTurn;
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
{ controller: controller({ turn: incompleteTurn }) },
|
||||
));
|
||||
|
||||
assert.match(markup, /你提到离开家去北京开始工作/);
|
||||
assert.match(markup, /大致是什么年月/);
|
||||
assert.doesNotMatch(markup, /接下来请说一件/);
|
||||
});
|
||||
|
||||
test("an uninitialized surface shows progress without a second start card", () => {
|
||||
@@ -144,28 +169,48 @@ test("an uninitialized surface shows progress without a second start card", () =
|
||||
assert.doesNotMatch(markup, /系统会先说明候选边界|开始生时校正<\/button>/);
|
||||
});
|
||||
|
||||
test("evidence is correctable, secondary controls stay hidden, and confirmation is explicit", () => {
|
||||
test("the first Agent guidance streams into the empty rectification surface", () => {
|
||||
const emptyController = controller({
|
||||
turn: null,
|
||||
pending: true,
|
||||
getSnapshot: () => ({
|
||||
turn: null,
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: true,
|
||||
error: "",
|
||||
}),
|
||||
});
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
{
|
||||
controller: emptyController,
|
||||
openingAssistantText: "先说一件已经发生、并且记得年月的重要经历。",
|
||||
},
|
||||
));
|
||||
|
||||
assert.match(markup, /先说一件已经发生、并且记得年月的重要经历/);
|
||||
assert.doesNotMatch(markup, /正在建立校正记录/);
|
||||
});
|
||||
|
||||
test("history management stays hidden while final confirmation remains explicit", () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
{ controller: controller() },
|
||||
));
|
||||
|
||||
assert.match(markup, /已记录的真实经历/);
|
||||
assert.match(markup, /2021 年 7 月/);
|
||||
assert.match(markup, /更正这条经历:开始第一份长期工作/);
|
||||
assert.doesNotMatch(markup, /已记录的真实经历|2021 年 7 月|更正这条经历/);
|
||||
assert.doesNotMatch(markup, /本轮分析|等待经历验证/);
|
||||
assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/);
|
||||
assert.match(markup, /当前候选 05:18/);
|
||||
assert.match(markup, /待确认,尚未验证/);
|
||||
assert.doesNotMatch(markup, /当前候选 05:18/);
|
||||
assert.match(markup, /确认采用 05:18(尚未验证)/);
|
||||
assert.match(markup, /aria-label="确认将 05:18 设为当前排盘时间;当前分钟尚未验证"/);
|
||||
assert.match(markup, /已记录 1 条经历/);
|
||||
assert.match(markup, /候选只用于继续验证/);
|
||||
assert.match(markup, /这一步不会自动采用候选/);
|
||||
assert.doesNotMatch(markup, /已记录 1 条经历|候选只用于继续验证|这一步不会自动采用候选/);
|
||||
assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/);
|
||||
});
|
||||
|
||||
test("correction mode identifies its durable target, can be cancelled, and marks revised recaps", () => {
|
||||
test("an existing correction target can still be cancelled without rendering history management", () => {
|
||||
const revisedTurn = {
|
||||
...turn,
|
||||
evidenceRecap: [{ ...turn.evidenceRecap[0]!, isCorrection: true }],
|
||||
@@ -191,7 +236,7 @@ test("correction mode identifies its durable target, can be cancelled, and marks
|
||||
assert.match(markup, /正在更正/);
|
||||
assert.match(markup, /开始第一份长期工作/);
|
||||
assert.match(markup, /取消更正/);
|
||||
assert.match(markup, /(已修订)/);
|
||||
assert.doesNotMatch(markup, /(已修订)|已记录的经历|更正这条经历/);
|
||||
});
|
||||
|
||||
test("pending markup and responsive CSS expose accessibility contracts", () => {
|
||||
@@ -211,6 +256,13 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
|
||||
ConversationalRectificationSurface,
|
||||
{ controller: pendingController },
|
||||
));
|
||||
const streamingMarkup = renderToStaticMarkup(React.createElement(
|
||||
ConversationalRectificationSurface,
|
||||
{ controller: controller({
|
||||
pending: true,
|
||||
streamingAssistantText: "已记录关系事件,接下来核对开始时间。",
|
||||
}) },
|
||||
));
|
||||
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
|
||||
const component = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
@@ -222,13 +274,20 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
|
||||
assert.match(markup, /Jyotisha 正在核对经历/);
|
||||
assert.match(markup, /正在核对这段经历/);
|
||||
assert.match(markup, /Jyotisha 正在分析/);
|
||||
assert.match(streamingMarkup, /已记录关系事件,接下来核对开始时间/);
|
||||
assert.doesNotMatch(streamingMarkup, /Jyotisha 正在分析/);
|
||||
assert.match(markup, /<textarea[^>]+disabled=""[^>]*>保留中的文字<\/textarea>/);
|
||||
assert.match(markup, /aria-label="生时校正对话"/);
|
||||
assert.match(markup, /role="alert"|aria-live="polite"/);
|
||||
assert.match(css, /\.conversation\.is-rectification[^}]*padding-bottom:\s*0/);
|
||||
assert.match(css, /\.rectification-chat[^}]*height:\s*100%[^}]*display:\s*flex/);
|
||||
assert.match(css, /\.rectification-message-list[^}]*flex:\s*1[^}]*overflow-y:\s*auto/);
|
||||
assert.match(css, /\.rectification-message-details button[^}]*min-height:\s*44px/);
|
||||
assert.match(css, /\.rectification-message-list[^}]*scrollbar-color:\s*transparent transparent/);
|
||||
assert.match(css, /\.rectification-message-list[^}]*scrollbar-width:\s*thin/);
|
||||
assert.match(css, /\.rectification-message-list::\-webkit-scrollbar-thumb[^}]*background:\s*transparent[^}]*background-clip:\s*padding-box/);
|
||||
assert.match(css, /\.rectification-message-list\.is-scrollbar-visible::\-webkit-scrollbar-thumb[^}]*color-mix/);
|
||||
assert.match(css, /\.rectification-message-list::\-webkit-scrollbar-thumb:hover[^}]*color-mix/);
|
||||
assert.match(css, /\.rectification-correction-target button[^}]*min-height:\s*44px/);
|
||||
assert.match(css, /\.composer:focus-within[^}]*border-color:/);
|
||||
assert.match(css, /\.composer textarea[^}]*border:\s*0/);
|
||||
assert.match(css, /\.rectification-composer-wrap[^}]*position:\s*static/);
|
||||
@@ -236,9 +295,12 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
|
||||
assert.doesNotMatch(css, /\.conversational-domain-picker|\.conversational-event-date/);
|
||||
assert.doesNotMatch(css, /button\[aria-label\$="下一步建议"\]/);
|
||||
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
|
||||
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.rectification-message-details/);
|
||||
assert.doesNotMatch(css, /\.rectification-message-details/);
|
||||
assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/);
|
||||
assert.match(component, /controller\.answer\(undefined, controller\.draft\.trim\(\)\)/);
|
||||
assert.match(component, /onPointerMove=\{revealScrollbar\}/);
|
||||
assert.match(component, /onScroll=\{revealScrollbar\}/);
|
||||
assert.match(component, /classList\.remove\("is-scrollbar-visible"\)/);
|
||||
assert.match(component, /controller\.answer\(undefined, text\)/);
|
||||
assert.match(component, /event\.key === "Enter" && !event\.shiftKey/);
|
||||
assert.match(component, /onPendingChange/);
|
||||
assert.match(component, /onPendingChange:\s*props\.onPendingChange/);
|
||||
@@ -653,7 +715,14 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
|
||||
});
|
||||
const turns = {
|
||||
activeA1: makeTurn(caseA, 1),
|
||||
activeA3: makeTurn(caseA, 3),
|
||||
activeA3: {
|
||||
...makeTurn(caseA, 13),
|
||||
messageHistory: Array.from({ length: 12 }, (_, index) => ({
|
||||
turnVersion: index + 1,
|
||||
userMessage: "第 " + (index + 1) + " 条已发生经历,用于制造可滚动的真实对话历史。",
|
||||
narrative: "已记录第 " + (index + 1) + " 条经历,并继续核对其他领域的候选差异。",
|
||||
})),
|
||||
},
|
||||
activeB1: makeTurn(caseB, 1),
|
||||
abandonedB2: makeTurn(caseB, 2, "abandoned"),
|
||||
};
|
||||
@@ -663,8 +732,16 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
|
||||
const [initialTurn, setInitialTurn] = useState(null);
|
||||
const [transportLabel, setTransportLabel] = useState("first");
|
||||
const [callbackLabel, setCallbackLabel] = useState("first");
|
||||
const send = async (command) => {
|
||||
const send = async (command, options) => {
|
||||
events.push("send:" + transportLabel + ":" + command.type);
|
||||
if (command.type === "answer") {
|
||||
await new Promise((resolveSend) => setTimeout(resolveSend, 20));
|
||||
options?.onNarrativeDelta?.("正在结合这段经历核对候选。 ");
|
||||
events.push("delta:first");
|
||||
await new Promise((resolveSend) => setTimeout(resolveSend, 20));
|
||||
options?.onNarrativeDelta?.("下一步会继续验证其他领域。");
|
||||
events.push("delta:second");
|
||||
}
|
||||
await new Promise((resolveSend) => setTimeout(resolveSend, 20));
|
||||
if (command.type === "pause") {
|
||||
return makeTurn(command.caseId, command.turnVersion + 1, "paused");
|
||||
@@ -708,7 +785,7 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
|
||||
outfile: bundlePath,
|
||||
platform: "browser",
|
||||
}), 10_000, "browser fixture bundle");
|
||||
writeFileSync(htmlPath, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}\nhtml,body{height:auto;overflow:auto}body{padding:12px}#root{width:100%;min-width:0}</style></head><body><main id="root"></main><script src="${pathToFileURL(bundlePath).href}"></script></body></html>`);
|
||||
writeFileSync(htmlPath, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}\nhtml,body{height:100%;overflow:hidden}body{box-sizing:border-box;padding:12px}#root{width:100%;height:100%;min-width:0}</style></head><body><main id="root"></main><script src="${pathToFileURL(bundlePath).href}"></script></body></html>`);
|
||||
|
||||
({ browser, cdp } = await launchFixture(htmlPath, userDataDirectory));
|
||||
await cdp.send("Runtime.enable");
|
||||
@@ -725,8 +802,9 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
|
||||
);
|
||||
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('当前候选 05:18')
|
||||
&& document.body.textContent.includes('已记录:2021-07')`) ?? Promise.resolve(false),
|
||||
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('当前判断')
|
||||
&& !document.body.textContent.includes('当前候选 05:18')
|
||||
&& !document.body.textContent.includes('已记录的经历')`) ?? Promise.resolve(false),
|
||||
"streamlined async initial turn",
|
||||
);
|
||||
|
||||
@@ -756,29 +834,33 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
|
||||
assert.equal(layout.selectCount, 0, "language-first flow should not render date selects");
|
||||
assert.equal(layout.domainChoiceCount, 0, "language-first flow should not render domain buttons");
|
||||
|
||||
await cdp.evaluate("document.querySelector('.rectification-message-details').open = true");
|
||||
await cdp.evaluate("document.querySelector('[aria-label^=\"更正这条经历\"]').click()");
|
||||
const mistakenAnswer = "2020年9月离职写错了";
|
||||
await cdp.evaluate(`(() => {
|
||||
const textarea = document.getElementById('conversational-rectification-answer');
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
|
||||
setter.call(textarea, ${JSON.stringify(mistakenAnswer)});
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
document.querySelector('[aria-label="发送"]').click();
|
||||
})()`);
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`(() => {
|
||||
const textarea = document.getElementById('conversational-rectification-answer');
|
||||
return document.body.textContent.includes('正在更正')
|
||||
&& document.body.textContent.includes('开始第一份长期工作')
|
||||
&& textarea?.value === '';
|
||||
})()`) ?? Promise.resolve(false),
|
||||
"durable correction target selection without polluting the new answer",
|
||||
() => cdp?.evaluate<boolean>(`document.querySelector('[aria-label="撤回发送,本次不计入校正"]') !== null`) ?? Promise.resolve(false),
|
||||
"rectification undo window",
|
||||
);
|
||||
await cdp.evaluate("[...document.querySelectorAll('button')].find((button) => button.textContent.includes('取消更正')).click()");
|
||||
await cdp.evaluate("document.querySelector('[aria-label=\"撤回发送,本次不计入校正\"]').click()");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>("!document.body.textContent.includes('正在更正') && document.getElementById('conversational-rectification-answer').value === ''") ?? Promise.resolve(false),
|
||||
"correction cancellation",
|
||||
() => cdp?.evaluate<boolean>(`document.getElementById('conversational-rectification-answer').value === ${JSON.stringify(mistakenAnswer)}`) ?? Promise.resolve(false),
|
||||
"mistaken answer restored to draft",
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_700));
|
||||
assert.equal(await cdp.evaluate<boolean>("globalThis.__rectificationHarness.events.some((event) => event.endsWith(':answer'))"), false);
|
||||
|
||||
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`(() => {
|
||||
const text = document.body.textContent;
|
||||
return text.includes('当前候选')
|
||||
&& !text.includes('候选时间')
|
||||
return text.includes('当前判断')
|
||||
&& !text.includes('当前候选 05:18')
|
||||
&& !text.includes('已记录的经历')
|
||||
&& !text.includes('本轮技术回执')
|
||||
&& !text.includes('暂停,稍后继续')
|
||||
&& !text.includes('放弃本次校正')
|
||||
@@ -787,6 +869,42 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
|
||||
"streamlined rectification controls",
|
||||
);
|
||||
|
||||
const followsBottom = `(() => {
|
||||
const list = document.querySelector('.rectification-message-list');
|
||||
return list.scrollHeight > list.clientHeight
|
||||
&& Math.abs(list.scrollHeight - list.clientHeight - list.scrollTop) <= 2;
|
||||
})()`;
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(followsBottom) ?? Promise.resolve(false),
|
||||
"initial rectification history at the bottom",
|
||||
);
|
||||
await cdp.evaluate("document.querySelector('.rectification-message-list').scrollTop = 0");
|
||||
const correctedAnswer = "2020年9月底主动离职,原因是组织内耗严重。";
|
||||
await cdp.evaluate(`(() => {
|
||||
const textarea = document.getElementById('conversational-rectification-answer');
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set;
|
||||
setter.call(textarea, ${JSON.stringify(correctedAnswer)});
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
document.querySelector('[aria-label="发送"]').click();
|
||||
})()`);
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(followsBottom) ?? Promise.resolve(false),
|
||||
"optimistic user message follows the bottom",
|
||||
);
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`globalThis.__rectificationHarness.events.includes('delta:first')
|
||||
&& document.body.textContent.includes('正在结合这段经历核对候选。')
|
||||
&& ${followsBottom}`) ?? Promise.resolve(false),
|
||||
"streaming assistant delta follows the bottom",
|
||||
);
|
||||
await waitFor(
|
||||
() => cdp?.evaluate<boolean>(`globalThis.__rectificationHarness.events.includes('delta:second')
|
||||
&& document.body.textContent.includes('回答已完成')
|
||||
&& document.body.textContent.includes(${JSON.stringify(correctedAnswer)})
|
||||
&& ${followsBottom}`) ?? Promise.resolve(false),
|
||||
"settled assistant answer follows the bottom",
|
||||
);
|
||||
|
||||
} finally {
|
||||
try {
|
||||
cdp?.close();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "../src/lib/conversational-rectification/client.ts";
|
||||
import type {
|
||||
ConversationalRectificationCommand,
|
||||
ConversationalRectificationResponse,
|
||||
ConversationalRectificationTurn,
|
||||
} from "../src/lib/conversational-rectification/contracts.ts";
|
||||
|
||||
@@ -64,6 +65,30 @@ function correctableTurn(turnVersion = 2): ConversationalRectificationTurn {
|
||||
};
|
||||
}
|
||||
|
||||
function turnWithMessageHistory(turnVersion = 3): ConversationalRectificationResponse {
|
||||
return {
|
||||
...activeTurn(turnVersion),
|
||||
narrative: "你提到第一份工作从数据分析开始。下一步想核对一次明确的职业转折。",
|
||||
messageHistory: [
|
||||
{
|
||||
turnVersion: 1,
|
||||
userMessage: null,
|
||||
narrative: "我们先从一件时间明确的经历开始。",
|
||||
},
|
||||
{
|
||||
turnVersion: 2,
|
||||
userMessage: "2017 年 7 月入职第一家公司,从事数据分析。",
|
||||
narrative: "这段职业起点已经记下。你当时为什么选择数据分析?",
|
||||
},
|
||||
{
|
||||
turnVersion: 3,
|
||||
userMessage: "专业相关,也觉得数据工作更适合我。",
|
||||
narrative: "你提到第一份工作从数据分析开始。下一步想核对一次明确的职业转折。",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function idFactory() {
|
||||
const ids = [...actionIds];
|
||||
return () => ids.shift() ?? assert.fail("unexpected action id allocation");
|
||||
@@ -114,6 +139,86 @@ test("controller admits only one in-flight mutation and clears text only after s
|
||||
assert.deepEqual(pendingChanges, [true, false]);
|
||||
});
|
||||
|
||||
test("controller publishes streamed Agent text while the durable rectification turn is pending", async () => {
|
||||
const request = deferred<ConversationalRectificationResponse>();
|
||||
let emit: ((text: string) => void) | undefined;
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: activeTurn(),
|
||||
createActionId: idFactory(),
|
||||
send: async (_command, options) => {
|
||||
emit = options?.onNarrativeDelta;
|
||||
return request.promise;
|
||||
},
|
||||
});
|
||||
controller.setDraft("2024 年 8 月结束一段重要感情");
|
||||
|
||||
const answer = controller.answer("relationship");
|
||||
emit?.("已记录关系事件,");
|
||||
emit?.("接下来核对开始时间。");
|
||||
|
||||
assert.equal(controller.getSnapshot().pending, true);
|
||||
assert.equal(controller.getSnapshot().streamingAssistantText, "已记录关系事件,接下来核对开始时间。");
|
||||
|
||||
request.resolve({
|
||||
...activeTurn(3),
|
||||
narrative: "已记录关系事件,接下来核对开始时间。",
|
||||
});
|
||||
await answer;
|
||||
|
||||
assert.equal(controller.getSnapshot().pending, false);
|
||||
assert.equal(controller.getSnapshot().streamingAssistantText, "");
|
||||
assert.equal(controller.getSnapshot().messages?.at(-1)?.text, "已记录关系事件,接下来核对开始时间。");
|
||||
});
|
||||
|
||||
test("controller retains alternating user and Agent messages after each answer", async () => {
|
||||
const initial = activeTurn();
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: initial,
|
||||
createActionId: idFactory(),
|
||||
send: async () => ({
|
||||
...activeTurn(3),
|
||||
narrative: "你提到 2021 年 7 月开始第一份工作,这次职业起点已经记下。接下来想核对一次搬迁。",
|
||||
}),
|
||||
});
|
||||
|
||||
controller.setDraft("2021 年 7 月开始第一份工作");
|
||||
await controller.answer("career");
|
||||
|
||||
assert.deepEqual(controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })), [
|
||||
{ role: "assistant", text: initial.narrative },
|
||||
{ role: "user", text: "2021 年 7 月开始第一份工作" },
|
||||
{ role: "assistant", text: "你提到 2021 年 7 月开始第一份工作,这次职业起点已经记下。接下来想核对一次搬迁。" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("controller restores the real user and Agent history without synthesizing recap templates", () => {
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: turnWithMessageHistory(),
|
||||
});
|
||||
|
||||
const messages = controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text }));
|
||||
assert.deepEqual(messages, [
|
||||
{ role: "assistant", text: "我们先从一件时间明确的经历开始。" },
|
||||
{ role: "user", text: "2017 年 7 月入职第一家公司,从事数据分析。" },
|
||||
{ role: "assistant", text: "这段职业起点已经记下。你当时为什么选择数据分析?" },
|
||||
{ role: "user", text: "专业相关,也觉得数据工作更适合我。" },
|
||||
{ role: "assistant", text: "你提到第一份工作从数据分析开始。下一步想核对一次明确的职业转折。" },
|
||||
]);
|
||||
assert.doesNotMatch(messages?.map(({ text }) => text).join("\n") ?? "", /已记录这段经历/);
|
||||
});
|
||||
|
||||
test("legacy responses without message history show only the real latest Agent narrative", () => {
|
||||
const legacy = {
|
||||
...correctableTurn(3),
|
||||
narrative: "你刚才补充的职业经历还缺离职原因,我先继续问这一件事。",
|
||||
};
|
||||
const controller = createConversationalRectificationController({ initialTurn: legacy });
|
||||
|
||||
assert.deepEqual(controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })), [
|
||||
{ role: "assistant", text: legacy.narrative },
|
||||
]);
|
||||
});
|
||||
|
||||
test("controller preserves the exact draft and stable action id across failures", async () => {
|
||||
const commands: ConversationalRectificationCommand[] = [];
|
||||
const controller = createConversationalRectificationController({
|
||||
@@ -393,10 +498,16 @@ test("a case switch detaches the old mutation so the new case can mutate indepen
|
||||
controller.synchronizeInitialTurn(caseBTurn);
|
||||
assert.deepEqual(controller.getSnapshot(), {
|
||||
turn: caseBTurn,
|
||||
messages: [{
|
||||
role: "assistant",
|
||||
text: caseBTurn.narrative,
|
||||
renderKey: "assistant-1",
|
||||
}],
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
streamingAssistantText: "",
|
||||
error: "",
|
||||
});
|
||||
|
||||
@@ -437,10 +548,12 @@ test("synchronizing to no case detaches an ordinary failure without publishing i
|
||||
controller.synchronizeInitialTurn(null);
|
||||
assert.deepEqual(controller.getSnapshot(), {
|
||||
turn: null,
|
||||
messages: [],
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
streamingAssistantText: "",
|
||||
error: "",
|
||||
});
|
||||
|
||||
@@ -448,10 +561,12 @@ test("synchronizing to no case detaches an ordinary failure without publishing i
|
||||
await rejected;
|
||||
assert.deepEqual(controller.getSnapshot(), {
|
||||
turn: null,
|
||||
messages: [],
|
||||
draft: "",
|
||||
selectedDomain: null,
|
||||
correctionTarget: null,
|
||||
pending: false,
|
||||
streamingAssistantText: "",
|
||||
error: "",
|
||||
});
|
||||
});
|
||||
@@ -530,6 +645,22 @@ test("a newer external same-case turn wins over an older in-flight response", as
|
||||
assert.equal(controller.getSnapshot().turn?.turnVersion, 5);
|
||||
});
|
||||
|
||||
test("a newer synchronized response replaces local bubbles with its durable message history", () => {
|
||||
const controller = createConversationalRectificationController({
|
||||
initialTurn: activeTurn(1),
|
||||
});
|
||||
|
||||
controller.synchronizeInitialTurn(turnWithMessageHistory(3));
|
||||
|
||||
assert.deepEqual(controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })), [
|
||||
{ role: "assistant", text: "我们先从一件时间明确的经历开始。" },
|
||||
{ role: "user", text: "2017 年 7 月入职第一家公司,从事数据分析。" },
|
||||
{ role: "assistant", text: "这段职业起点已经记下。你当时为什么选择数据分析?" },
|
||||
{ role: "user", text: "专业相关,也觉得数据工作更适合我。" },
|
||||
{ role: "assistant", text: "你提到第一份工作从数据分析开始。下一步想核对一次明确的职业转折。" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("an external same-version turn is not replaced by a late response", async () => {
|
||||
let resolveRequest: ((turn: ConversationalRectificationTurn) => void) | undefined;
|
||||
const request = new Promise<ConversationalRectificationTurn>((resolve) => {
|
||||
|
||||
@@ -492,7 +492,9 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
|
||||
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009004",
|
||||
turnVersion: turn.turnVersion, domain: "career", answer: "后来工作压力很大",
|
||||
});
|
||||
assert.match(turn.narrative, /还缺少.*明确时间/);
|
||||
assert.match(turn.narrative, /工作压力很大/);
|
||||
assert.match(turn.narrative, /什么年月/);
|
||||
assert.doesNotMatch(turn.narrative, /请提供.*真实事件/);
|
||||
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
|
||||
|
||||
const futureEvidenceAction = "00000000-0000-4000-8000-000000009050";
|
||||
|
||||
@@ -99,6 +99,7 @@ function validGenerator(
|
||||
events: string[],
|
||||
varyNarrative = false,
|
||||
invalidNarrativeFromGeneration?: number,
|
||||
prompts: string[] = [],
|
||||
) {
|
||||
let generation = 0;
|
||||
return {
|
||||
@@ -106,12 +107,17 @@ function validGenerator(
|
||||
async generate(prompt: string) {
|
||||
generation += 1;
|
||||
events.push("narrative");
|
||||
prompts.push(prompt);
|
||||
if (invalidNarrativeFromGeneration !== undefined
|
||||
&& generation >= invalidNarrativeFromGeneration) {
|
||||
return { text: "not a grounded narrative result" };
|
||||
}
|
||||
const request = JSON.parse(prompt) as {
|
||||
phase: "first" | "intermediate" | "final";
|
||||
conversationContext?: {
|
||||
latestEvidence?: Array<{ dateLabel: string; summary: string; domain: string }>;
|
||||
eventLedger?: Array<{ domain: string; active: boolean }>;
|
||||
};
|
||||
packet: Omit<ReturnType<typeof packet>, "candidate"> & {
|
||||
candidate: ReturnType<typeof packet>["candidate"] & {
|
||||
rangeStart: string;
|
||||
@@ -120,10 +126,18 @@ function validGenerator(
|
||||
};
|
||||
};
|
||||
const value = request.packet;
|
||||
const domains = value.suggestedDomains.slice(0, 1).map((item) => item.domain);
|
||||
const answeredDomains = new Set(request.conversationContext?.eventLedger
|
||||
?.filter((item) => item.active)
|
||||
.map((item) => item.domain) ?? []);
|
||||
const nextSuggested = value.suggestedDomains.find((item) => !answeredDomains.has(item.domain))
|
||||
?? value.suggestedDomains[0];
|
||||
const domains = nextSuggested ? [nextSuggested.domain] : [];
|
||||
const nextDomain = domains[0] === "relationship" ? "重要关系" : "事业";
|
||||
const latest = request.conversationContext?.latestEvidence?.at(-1);
|
||||
const narrative = [
|
||||
`当前仍在核对 ${value.candidate.rangeStart}–${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
|
||||
request.phase === "intermediate" && latest
|
||||
? `记下了:${latest.dateLabel} · ${latest.summary}。`
|
||||
: `当前仍在核对 ${value.candidate.rangeStart}–${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
|
||||
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
|
||||
request.phase === "final" ? "当前证据已形成候选总结。" : `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`,
|
||||
].join("");
|
||||
@@ -161,6 +175,7 @@ function harness(options: {
|
||||
readonly invalidNarrativeFromGeneration?: number;
|
||||
} = {}) {
|
||||
const events: string[] = [];
|
||||
const narrativePrompts: string[] = [];
|
||||
const mutations: string[] = [];
|
||||
const cases = new Map<string, MutableCase>();
|
||||
const receipts = new Map<string, {
|
||||
@@ -388,12 +403,14 @@ function harness(options: {
|
||||
events,
|
||||
options.varyNarrative,
|
||||
options.invalidNarrativeFromGeneration,
|
||||
narrativePrompts,
|
||||
),
|
||||
asOfDate: () => "2026-07-21",
|
||||
};
|
||||
|
||||
return {
|
||||
events,
|
||||
narrativePrompts,
|
||||
mutations,
|
||||
packetEvidenceCounts,
|
||||
packetEvidenceIds,
|
||||
@@ -817,6 +834,60 @@ test("generic date uncertainty does not suppress clear historical evidence", asy
|
||||
.some((item) => item.eventSummary.includes("毕业") && item.scoreable === true));
|
||||
});
|
||||
|
||||
test("a concrete event without a date is acknowledged and a date-only follow-up completes it", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
|
||||
const clarification = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "我离开家去北京开始工作",
|
||||
});
|
||||
|
||||
assert.match(clarification.narrative, /离开家去北京开始工作/);
|
||||
assert.match(clarification.narrative, /什么年月/);
|
||||
assert.equal(clarification.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
|
||||
|
||||
const completed = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: secondAnswerActionId,
|
||||
turnVersion: 1,
|
||||
answer: "2023年3月",
|
||||
});
|
||||
|
||||
const stored = value.cases.get(startActionId)?.row.eventEvidence ?? [];
|
||||
assert.equal(stored.length, 2, "the incomplete fact and its completion remain auditable");
|
||||
assert.deepEqual(stored[1]?.correctsEvidenceIds, [stored[0]?.id]);
|
||||
assert.equal(stored[1]?.eventSummary, "我离开家去北京开始工作");
|
||||
assert.equal(stored[1]?.dateValue, "2023-03");
|
||||
assert.equal(stored[1]?.scoreable, true);
|
||||
assert.deepEqual(completed.evidenceRecap.map((item) => ({
|
||||
summary: item.summary,
|
||||
dateLabel: item.dateLabel,
|
||||
})), [{ summary: "我离开家去北京开始工作", dateLabel: "2023-03" }]);
|
||||
assert.match(completed.narrative, /记下了:2023-03 · 我离开家去北京开始工作/);
|
||||
});
|
||||
|
||||
test("the next evidence request moves past a domain the user already answered", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
|
||||
const turn = await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "2020年5月结婚",
|
||||
});
|
||||
|
||||
assert.deepEqual(turn.evidenceRequest?.domains, ["career"]);
|
||||
assert.match(turn.narrative, /事业/);
|
||||
assert.doesNotMatch(turn.narrative, /下一步[^\n]*重要关系/);
|
||||
});
|
||||
|
||||
test("a rejected professional narrative falls back safely while the first scoreable answer still narrows", async () => {
|
||||
const value = harness({ invalidNarrativeFromGeneration: 2 });
|
||||
await start(value, null);
|
||||
@@ -859,21 +930,46 @@ test("one and two supported events save and narrate before the third accumulated
|
||||
assert.equal(stored?.eventEvidence.length, index + 1);
|
||||
assert.equal(turn.evidenceRecap.length, index + 1);
|
||||
assert.equal(turn.status, index < 2 ? "active" : "confirming");
|
||||
assert.match(turn.narrative, new RegExp(`当前累计 ${index + 1} 条可评分经历|候选范围已从|本轮已纳入 ${index + 1} 条可评分经历`));
|
||||
assert.match(turn.narrative, /已记录:/);
|
||||
assert.ok(turn.narrative.length > 0);
|
||||
assert.doesNotMatch(turn.narrative, /当前累计|本轮已纳入|本轮区分重点|下一步:/);
|
||||
}
|
||||
|
||||
assert.equal(value.counts().packetBuilds, 4);
|
||||
assert.equal(value.events.filter((event) => event === "narrative").length, 4);
|
||||
});
|
||||
|
||||
test("a non-confirmable conversational case completes with its saved range instead of asking forever", async () => {
|
||||
test("intermediate narrative receives the complete active event ledger", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, null);
|
||||
await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: answerActionId,
|
||||
turnVersion: 0,
|
||||
answer: "2020年9月底主动离开研究单位",
|
||||
});
|
||||
await value.service.answer(userId, {
|
||||
type: "answer",
|
||||
caseId: startActionId,
|
||||
actionId: secondAnswerActionId,
|
||||
turnVersion: 1,
|
||||
answer: "2023年4月进入下一家公司",
|
||||
});
|
||||
|
||||
const prompt = value.narrativePrompts.at(-1) ?? "";
|
||||
assert.match(prompt, /2020年9月底主动离开研究单位/);
|
||||
assert.match(prompt, /2023年4月进入下一家公司/);
|
||||
assert.match(prompt, /eventLedger/);
|
||||
});
|
||||
|
||||
test("a non-confirmable conversational case asks each discriminating domain before completing its saved range", async () => {
|
||||
const value = harness({ readyAfterEvidenceCount: 99 });
|
||||
await start(value, "请继续回答原来的事业问题");
|
||||
const answers = [
|
||||
[answerActionId, "2019年7月毕业"],
|
||||
[secondAnswerActionId, "2020年8月搬家"],
|
||||
[thirdAnswerActionId, "2021年9月换工作"],
|
||||
[fourthAnswerActionId, "2022年10月结婚"],
|
||||
] as const;
|
||||
let latest = value.cases.get(startActionId)?.row.latestTurn;
|
||||
|
||||
@@ -885,6 +981,10 @@ test("a non-confirmable conversational case completes with its saved range inste
|
||||
turnVersion: index,
|
||||
answer,
|
||||
});
|
||||
if (index === 2) {
|
||||
assert.equal(latest.status, "active");
|
||||
assert.deepEqual(latest.evidenceRequest?.domains, ["relationship"]);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(latest?.status, "completed");
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createBirthTimeConversationPostHandler,
|
||||
declaredBirthInputForLegacyCase,
|
||||
loadProductionConversationalRectificationProfile,
|
||||
streamConversationalRectificationResponse,
|
||||
type BirthTimeConversationRouteService,
|
||||
} from "../src/app/api/birth-time-conversation/route.ts";
|
||||
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
|
||||
@@ -21,12 +22,53 @@ const actionId = "00000000-0000-4000-8000-000000000712";
|
||||
const caseId = "00000000-0000-4000-8000-000000000713";
|
||||
const requestId = "00000000-0000-4000-8000-000000000714";
|
||||
|
||||
test("validated rectification responses stream narrative deltas before the durable turn", async () => {
|
||||
const narrative = "已记录具体经历,并继续追问关系事件。";
|
||||
const turn = {
|
||||
caseId,
|
||||
journeyProtocol: "conversational-evidence-v3" as const,
|
||||
status: "active" as const,
|
||||
turnVersion: 2,
|
||||
narrative,
|
||||
candidate: {
|
||||
status: "pending_validation" as const,
|
||||
representativeTime: "05:30",
|
||||
rangeStart: "04:30",
|
||||
rangeEnd: "06:30",
|
||||
},
|
||||
technicalReceipt: {
|
||||
calculationVersion: "rectification-technical-v1" as const,
|
||||
stableLayers: ["D1"],
|
||||
sensitiveLayers: ["D9"],
|
||||
candidateDifferenceRefs: ["relationship"],
|
||||
},
|
||||
evidenceRequest: {
|
||||
domains: ["relationship" as const],
|
||||
datePrecision: "month_preferred" as const,
|
||||
freeTextAllowed: true as const,
|
||||
},
|
||||
evidenceRecap: [],
|
||||
actions: ["answer" as const],
|
||||
pendingConsultationQuestion: null,
|
||||
};
|
||||
|
||||
const response = streamConversationalRectificationResponse(turn, 0);
|
||||
const lines = (await response.text()).trim().split("\n").map((line) => JSON.parse(line));
|
||||
|
||||
assert.match(response.headers.get("content-type") ?? "", /application\/x-ndjson/);
|
||||
assert.equal(response.headers.get("x-accel-buffering"), "no");
|
||||
assert.equal(lines.filter((event) => event.type === "delta").map((event) => event.text).join(""), narrative);
|
||||
assert.deepEqual(lines.at(-1), { type: "turn", turn });
|
||||
});
|
||||
|
||||
test("production narrator loads the Jyotish Skill without overriding packet truth", () => {
|
||||
const source = readFileSync(new URL("../src/app/api/birth-time-conversation/route.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /skills:\s*\[jyotishSkillPath\]/);
|
||||
assert.match(source, /Use the Jyotish Skill only to choose a natural, one-question-at-a-time evidence strategy and wording/);
|
||||
assert.match(source, /Load the Jyotish Skill to choose a natural, one-question-at-a-time evidence strategy/);
|
||||
assert.match(source, /explain the supplied expert workflow/);
|
||||
assert.match(source, /supplied packet facts as the exclusive source/);
|
||||
assert.match(source, /blocked or not_evaluated technique must never be described as used/);
|
||||
assert.match(source, /Never invent, recalculate, or confirm candidate data/);
|
||||
});
|
||||
|
||||
@@ -652,6 +694,11 @@ test("production packet waits for three supported events and then scores the acc
|
||||
|
||||
assert.equal(scoreCalls.length, 1);
|
||||
assert.deepEqual(scoreCalls[0]?.map((event) => event.id), evidence.map((item) => item.id));
|
||||
assert.deepEqual(
|
||||
scoreCalls[0]?.map((event) => event.summary),
|
||||
evidence.map((item) => item.eventSummary),
|
||||
"event scoring must retain the concrete user-reported fact, not only domain and date",
|
||||
);
|
||||
assert.deepEqual(
|
||||
differenceCalls.map((input) => input.events.map((event) => event.id)),
|
||||
[evidence.slice(0, 1), evidence.slice(0, 2), evidence].map((items) => items.map((item) => item.id)),
|
||||
|
||||
@@ -201,7 +201,7 @@ test("loads the latest unfinished case by account without a chat identifier", as
|
||||
|
||||
const loaded = await store.loadCase({ userId });
|
||||
assert.equal(loaded?.caseId, caseId);
|
||||
assert.deepEqual(calls, [["load_conversational_rectification_case", {
|
||||
assert.deepEqual(calls, [["load_conversational_rectification_case_with_history", {
|
||||
p_user_id: userId,
|
||||
p_case_id: null,
|
||||
}]]);
|
||||
@@ -294,6 +294,7 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard
|
||||
|
||||
await store.saveTurn({
|
||||
...common,
|
||||
userMessage: "2019 年 7 月换工作",
|
||||
turn: { ...firstTurn, turnVersion: 1 },
|
||||
evidence,
|
||||
validationReceipt,
|
||||
@@ -340,9 +341,9 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard
|
||||
});
|
||||
|
||||
assert.deepEqual(calls.map(([name]) => name), [
|
||||
"save_conversational_rectification_turn",
|
||||
"save_conversational_rectification_turn_with_history",
|
||||
"pause_conversational_rectification_case",
|
||||
"abandon_conversational_rectification_case",
|
||||
"abandon_conversational_rectification_without_result",
|
||||
"confirm_conversational_rectification_candidate",
|
||||
"import_legacy_conversational_rectification_case",
|
||||
]);
|
||||
@@ -382,13 +383,14 @@ test("a completed unverified range uses the non-confirming completion RPC", asyn
|
||||
actionId,
|
||||
expectedVersion: 0,
|
||||
commandFingerprint,
|
||||
userMessage: "这些经历已经补充完了",
|
||||
turn: completedTurn,
|
||||
evidence: [],
|
||||
validationReceipt,
|
||||
privateCandidate: { resultId, calculationVersion: "rectification-v3.1" },
|
||||
});
|
||||
|
||||
assert.equal(called, "complete_conversational_rectification_with_range");
|
||||
assert.equal(called, "complete_conversational_rectification_with_range_and_history");
|
||||
assert.equal(result.status, "completed");
|
||||
assert.equal(result.latestTurn.candidate.status, "pending_validation");
|
||||
});
|
||||
|
||||
@@ -175,6 +175,66 @@ test("builds a deterministic private packet from server-computed engine receipts
|
||||
}]);
|
||||
});
|
||||
|
||||
test("projects the server technique receipt into a bounded expert workflow", () => {
|
||||
const packet = buildRectificationTechnicalPacket({
|
||||
scan,
|
||||
candidateDifferences,
|
||||
eventScore: {
|
||||
...eventScore,
|
||||
techniqueReceipt: {
|
||||
calculationStatus: "evaluated",
|
||||
usedDivisionalCharts: ["D9", "D10"],
|
||||
usedArudha: ["UL", "A7", "A10"],
|
||||
dashaTracks: ["vimshottari_md_ad_pd", "narayana_md_ad"],
|
||||
missingLayers: ["shadbala_kala_dig_chesta_total"],
|
||||
auxiliaryLayers: ["functional_benefic_malefic", "ashtakavarga"],
|
||||
hardBlockers: ["minute_holdout_not_ready"],
|
||||
confirmationAllowed: false,
|
||||
decision: "continue_rectification",
|
||||
gates: {
|
||||
public_holdout_release: {
|
||||
status: "blocked",
|
||||
reason: "frozen_public_AA_minute_holdout_is_below_20_cases",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
consultation: {
|
||||
source: "server_consultation_workflow",
|
||||
calculationVersion: "rectification-technical-v1",
|
||||
availableLayers: ["D1", "D9", "D10"],
|
||||
layerReferences: {
|
||||
D1: ["consult-d1-ascendant"],
|
||||
D9: ["consult-d9-candidate-difference"],
|
||||
D10: ["consult-d10-candidate-difference"],
|
||||
},
|
||||
timeLinkedScanSamples: [
|
||||
{ sampleIndex: 0, time: "05:10" },
|
||||
{ sampleIndex: 1, time: "05:16" },
|
||||
{ sampleIndex: 2, time: "05:17" },
|
||||
{ sampleIndex: 3, time: "05:30" },
|
||||
],
|
||||
boundaryDistanceMinutes: 4,
|
||||
futureWindows: [],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(packet.expertWorkflow?.boundary, "not_auto_rectified");
|
||||
assert.deepEqual(packet.expertWorkflow?.candidateWindows, [{
|
||||
startTime: "05:16",
|
||||
endTime: "05:24",
|
||||
status: "pending_validation",
|
||||
}]);
|
||||
const audit = new Map(packet.expertWorkflow?.techniqueAuditTable.map((row) => [row.technique, row]));
|
||||
assert.equal(audit.get("Vimshottari Dasha")?.status, "used");
|
||||
assert.equal(audit.get("Narayana Dasha")?.status, "used");
|
||||
assert.equal(audit.get("UL / A7 / A10")?.status, "used");
|
||||
assert.equal(audit.get("Shadbala / Ashtakavarga")?.status, "partial");
|
||||
assert.equal(audit.get("KP cusp / sub-lord")?.status, "blocked");
|
||||
assert.equal(audit.get("Minute confirmation")?.status, "blocked");
|
||||
assert.equal(packet.expertWorkflow?.confirmationAllowed, false);
|
||||
});
|
||||
|
||||
test("chooses one strongest technical layer per domain from actual candidate switches", () => {
|
||||
const differenceDrivenScan = {
|
||||
...scan,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts";
|
||||
import { visibleRectificationNarrative } from "../src/lib/conversational-rectification/visible-narrative.ts";
|
||||
|
||||
function turn(overrides: Partial<ConversationalRectificationTurn> = {}): ConversationalRectificationTurn {
|
||||
return {
|
||||
caseId: "00000000-0000-4000-8000-000000000921",
|
||||
journeyProtocol: "conversational-evidence-v3",
|
||||
status: "active",
|
||||
turnVersion: 1,
|
||||
narrative: "internal narrative",
|
||||
candidate: {
|
||||
status: "pending_validation",
|
||||
representativeTime: "05:20",
|
||||
rangeStart: "04:50",
|
||||
rangeEnd: "05:50",
|
||||
},
|
||||
technicalReceipt: {
|
||||
calculationVersion: "rectification-technical-v1",
|
||||
stableLayers: ["D1"],
|
||||
sensitiveLayers: ["D9", "D10"],
|
||||
candidateDifferenceRefs: ["consult-d9", "consult-d10"],
|
||||
},
|
||||
evidenceRequest: {
|
||||
domains: ["relationship", "career"],
|
||||
datePrecision: "month_preferred",
|
||||
freeTextAllowed: true,
|
||||
},
|
||||
evidenceRecap: [],
|
||||
actions: ["answer", "pause", "abandon"],
|
||||
pendingConsultationQuestion: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("visible rectification copy preserves the Agent's concrete follow-up", () => {
|
||||
const narrative = visibleRectificationNarrative(turn({
|
||||
narrative: "你提到离开家去北京开始工作,这次迁居和工作变化很关键。它大致是什么年月?",
|
||||
evidenceRecap: [{
|
||||
id: "00000000-0000-4000-8000-000000000922",
|
||||
summary: "离开家去北京开始工作",
|
||||
dateLabel: "日期待补充",
|
||||
domain: "relocation",
|
||||
isCorrection: false,
|
||||
}],
|
||||
}));
|
||||
|
||||
assert.match(narrative, /你提到离开家去北京开始工作/);
|
||||
assert.match(narrative, /大致是什么年月/);
|
||||
assert.doesNotMatch(narrative, /接下来请说一件/);
|
||||
});
|
||||
|
||||
test("visible rectification copy does not replace a tailored Agent answer with a template", () => {
|
||||
const narrative = visibleRectificationNarrative(turn({
|
||||
narrative: "结婚这件事我已经记下了。接下来想核对一次事业转折:你是哪一年、哪一月开始第一份长期工作的?",
|
||||
evidenceRecap: [{
|
||||
id: "00000000-0000-4000-8000-000000000923",
|
||||
summary: "结婚",
|
||||
dateLabel: "2020-05",
|
||||
domain: "relationship",
|
||||
isCorrection: false,
|
||||
}],
|
||||
}));
|
||||
|
||||
assert.match(narrative, /结婚这件事我已经记下了/);
|
||||
assert.match(narrative, /事业转折/);
|
||||
});
|
||||
|
||||
test("the rectification chat renders alternating messages without a separate evidence panel", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(source, /controller\.messages/);
|
||||
assert.match(source, /controller\.messages[\s\S]*?\.map\(\(message\)/);
|
||||
assert.doesNotMatch(source, /rectification-progress-details|turn\.evidenceRecap\.map|更正这条经历/);
|
||||
});
|
||||
@@ -28,6 +28,45 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
assert.equal(migration.status, 0, migration.stderr);
|
||||
assert.match(migration.stdout, /applied 20260715000000_account_credits\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260721150000_align_conversational_finance_domain\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260723010000_restore_conversational_message_history\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260723020000_mark_captured_conversational_messages\.sql/);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select is_nullable || ':' || data_type
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'birth_time_rectification_turns'
|
||||
and column_name = 'user_message'
|
||||
`),
|
||||
"YES:text",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select is_nullable || ':' || data_type || ':' || column_default
|
||||
from information_schema.columns
|
||||
where table_schema = 'public'
|
||||
and table_name = 'birth_time_rectification_turns'
|
||||
and column_name = 'user_message_captured'
|
||||
`),
|
||||
"NO:boolean:f",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select
|
||||
has_function_privilege(
|
||||
'service_role',
|
||||
'public.load_conversational_rectification_case_with_history(uuid, uuid)',
|
||||
'execute'
|
||||
) || ':' ||
|
||||
has_function_privilege(
|
||||
'authenticated',
|
||||
'public.load_conversational_rectification_case_with_history(uuid, uuid)',
|
||||
'execute'
|
||||
)
|
||||
`),
|
||||
"true:f",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
|
||||
@@ -43,7 +43,7 @@ import varga # noqa: E402
|
||||
|
||||
AYANAMSA: Final = "lahiri"
|
||||
NODE_MODE: Final = "mean"
|
||||
INPUT_CONTRACT_VERSION: Final = "rectification-candidate-input-v1"
|
||||
INPUT_CONTRACT_VERSION: Final = "rectification-candidate-input-v2"
|
||||
|
||||
DomainConfig = tuple[tuple[str, ...], tuple[int, ...]]
|
||||
DOMAIN_CONFIG: Final[dict[EventDomain, DomainConfig]] = {
|
||||
@@ -426,6 +426,13 @@ def _canonical_input_contract(request: RectificationEventRequest) -> tuple[dict,
|
||||
"timezone_offset": float(request["tz"]),
|
||||
"timezone_source": "explicit_offset",
|
||||
},
|
||||
"events": [{
|
||||
"id": event["id"],
|
||||
"domain": event["domain"],
|
||||
"date": event["date"],
|
||||
"precision": event["precision"],
|
||||
"summary": event.get("summary", ""),
|
||||
} for event in request["events"]],
|
||||
"calculation": {
|
||||
"ayanamsa": AYANAMSA,
|
||||
"node_mode": NODE_MODE,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Final, Literal, TypedDict, assert_never
|
||||
from typing import Any, Final, Literal, NotRequired, TypedDict, assert_never
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
EventPrecision = Literal["year", "month", "day"]
|
||||
@@ -44,6 +44,7 @@ class LifeEvent(TypedDict):
|
||||
domain: EventDomain
|
||||
date: str
|
||||
precision: EventPrecision
|
||||
summary: NotRequired[str]
|
||||
|
||||
|
||||
class RectificationEventRequest(TypedDict):
|
||||
|
||||
@@ -6938,14 +6938,17 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
lon = self._get_float(body, 'lon', 0, -180, 180)
|
||||
tz = self._get_float(body, 'tz', 0, -14, 14)
|
||||
events = body.get('events')
|
||||
if not isinstance(events, list) or not 3 <= len(events) <= 6:
|
||||
raise BadRequest('events must contain between 3 and 6 items')
|
||||
if not isinstance(events, list) or not 3 <= len(events) <= 8:
|
||||
raise BadRequest('events must contain between 3 and 8 items')
|
||||
normalized_events = []
|
||||
allowed_domains = {'education', 'relocation', 'relationship', 'career', 'finance', 'health_pressure'}
|
||||
formats = {'year': '%Y', 'month': '%Y-%m', 'day': '%Y-%m-%d'}
|
||||
for raw_event in events:
|
||||
if not isinstance(raw_event, dict) or set(raw_event) != {'id', 'domain', 'date', 'precision'}:
|
||||
raise BadRequest('each event must contain only id, domain, date, and precision')
|
||||
if not isinstance(raw_event, dict) or set(raw_event) not in (
|
||||
{'id', 'domain', 'date', 'precision'},
|
||||
{'id', 'domain', 'date', 'precision', 'summary'},
|
||||
):
|
||||
raise BadRequest('each event must contain id, domain, date, precision, and optional summary')
|
||||
try:
|
||||
uuid.UUID(str(raw_event['id']))
|
||||
except (ValueError, TypeError, AttributeError) as exc:
|
||||
@@ -6957,6 +6960,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
raise BadRequest('event domain is unsupported')
|
||||
if precision not in formats or not isinstance(event_date, str):
|
||||
raise BadRequest('event precision is unsupported')
|
||||
summary = raw_event.get('summary', '')
|
||||
if not isinstance(summary, str) or len(summary.strip()) > 1000:
|
||||
raise BadRequest('event summary must be text up to 1000 characters')
|
||||
try:
|
||||
parsed_event_date = datetime.strptime(event_date, formats[precision])
|
||||
except ValueError as exc:
|
||||
@@ -6968,6 +6974,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
||||
'domain': domain,
|
||||
'date': event_date,
|
||||
'precision': precision,
|
||||
'summary': summary.strip(),
|
||||
})
|
||||
module = _load_local_module('active_rectification_events')
|
||||
result = module.score_life_events({
|
||||
|
||||
@@ -431,7 +431,13 @@ def test_high_rigor_event_rectification_queues_vedastro_packet(monkeypatch) -> N
|
||||
"tz": 8,
|
||||
"high_rigor": True,
|
||||
"events": [
|
||||
{"id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", "domain": "education", "date": "2011-09", "precision": "month"},
|
||||
{
|
||||
"id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5",
|
||||
"domain": "education",
|
||||
"date": "2011-09",
|
||||
"precision": "month",
|
||||
"summary": "2011 年 9 月离开家乡开始大学生活",
|
||||
},
|
||||
{"id": "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", "domain": "career", "date": "2019-07-01", "precision": "day"},
|
||||
{"id": "0ef52e51-ab5f-453b-81e5-adb44a929224", "domain": "relationship", "date": "2021", "precision": "year"},
|
||||
],
|
||||
@@ -447,3 +453,32 @@ def test_high_rigor_event_rectification_queues_vedastro_packet(monkeypatch) -> N
|
||||
assert contract["decision"] == "continue_rectification"
|
||||
assert contract["canonical_input_hash"]
|
||||
assert contract["gates"]["public_holdout_release"]["status"] == "blocked"
|
||||
assert result["calculation_contract"]["events"][0]["summary"] == "2011 年 9 月离开家乡开始大学生活"
|
||||
|
||||
|
||||
def test_active_rectification_events_accepts_product_limit_of_eight() -> None:
|
||||
events = [
|
||||
{
|
||||
"id": f"00000000-0000-4000-a000-{index:012d}",
|
||||
"domain": "education" if index % 2 == 0 else "career",
|
||||
"date": f"{2011 + index}",
|
||||
"precision": "year",
|
||||
"summary": f"event {index}",
|
||||
}
|
||||
for index in range(8)
|
||||
]
|
||||
|
||||
result = _handler()._compute_active_rectification_events(
|
||||
{
|
||||
"birth_date": "1993-04-17",
|
||||
"start_time": "14:29",
|
||||
"end_time": "14:31",
|
||||
"lat": 36.683333,
|
||||
"lon": 114.35,
|
||||
"tz": 8,
|
||||
"events": events,
|
||||
}
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["event_count"] == 8
|
||||
|
||||
@@ -177,6 +177,45 @@ def test_real_local_scoring_uses_dated_events_and_actual_candidate_minutes() ->
|
||||
assert len(result["stability_diagnostics"]["leave_one_event_out"]["runs"]) == 3
|
||||
|
||||
|
||||
def test_event_summary_is_fingerprinted_without_unlocking_minute_application() -> None:
|
||||
base_request = {
|
||||
"birth_date": "1993-04-17",
|
||||
"start_time": "14:29",
|
||||
"end_time": "14:30",
|
||||
"lat": 36.683333,
|
||||
"lon": 114.35,
|
||||
"tz": 8.0,
|
||||
"events": [{
|
||||
"id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5",
|
||||
"domain": "career",
|
||||
"date": "2019-07",
|
||||
"precision": "month",
|
||||
"summary": "2019 年 7 月第一次承担团队管理职责",
|
||||
}],
|
||||
}
|
||||
changed_request = {
|
||||
**base_request,
|
||||
"events": [{
|
||||
**base_request["events"][0],
|
||||
"summary": "2019 年 7 月离开原公司并开始独立创业",
|
||||
}],
|
||||
}
|
||||
|
||||
contract, input_hash = event_engine._canonical_input_contract(base_request)
|
||||
_, changed_hash = event_engine._canonical_input_contract(changed_request)
|
||||
result = event_engine.adjudicate_event_candidate_rows(
|
||||
base_request,
|
||||
[_row("14:29", 10), _row("14:30", 8)],
|
||||
)
|
||||
|
||||
assert contract["schema_version"] == "rectification-candidate-input-v2"
|
||||
assert contract["events"][0]["summary"] == "2019 年 7 月第一次承担团队管理职责"
|
||||
assert changed_hash != input_hash
|
||||
assert result["canonical_input_hash"] == input_hash
|
||||
assert result["can_apply"] is False
|
||||
assert "minute_holdout_not_ready" in result["reasons"]
|
||||
|
||||
|
||||
def test_finance_events_use_d2_d11_and_recompute_both_dashas_per_minute(monkeypatch) -> None:
|
||||
calls = {"vimshottari": 0, "narayana": 0, "varga_counts": []}
|
||||
original_score_event = event_engine._score_event
|
||||
|
||||
@@ -7,6 +7,7 @@ SCHEMA = MIGRATIONS / "20260720010000_conversational_rectification_schema.sql"
|
||||
BILLING = MIGRATIONS / "20260720020000_conversational_rectification_billing.sql"
|
||||
TRANSITIONS = MIGRATIONS / "20260720030000_conversational_rectification_transitions.sql"
|
||||
LEGACY_IMPORT = MIGRATIONS / "20260721010000_conversational_legacy_import_projection.sql"
|
||||
UNCONFIRMED_REFUND = MIGRATIONS / "20260722190000_refund_unconfirmed_rectification.sql"
|
||||
|
||||
|
||||
def _normalized(path: Path) -> str:
|
||||
@@ -117,6 +118,28 @@ def test_complete_never_debits_and_release_refunds_at_most_once() -> None:
|
||||
assert "'already_charged'" in release
|
||||
|
||||
|
||||
def test_range_only_completion_and_abandonment_refund_in_the_terminal_transaction() -> None:
|
||||
sql = _normalized(UNCONFIRMED_REFUND)
|
||||
refund = _function(sql, "conversational_rectification_refund_unconfirmed_case")
|
||||
completion = _function(sql, "complete_conversational_rectification_with_range")
|
||||
abandonment = _function(sql, "abandon_conversational_rectification_without_result")
|
||||
|
||||
for invariant in (
|
||||
"v_case.status not in ('completed', 'abandoned')",
|
||||
"v_case.turn_state #>> '{candidate,status}' = 'confirmed'",
|
||||
"v_billing.state = 'charged'",
|
||||
"set credits = profile.credits + v_billing.price",
|
||||
"'refund', v_billing.price",
|
||||
"set state = 'released'",
|
||||
):
|
||||
assert invariant in refund
|
||||
assert "save_conversational_rectification_turn" in completion
|
||||
assert "conversational_rectification_refund_unconfirmed_case" in completion
|
||||
assert "abandon_conversational_rectification_case" in abandonment
|
||||
assert "conversational_rectification_refund_unconfirmed_case" in abandonment
|
||||
assert "active_birth_time" not in sql
|
||||
|
||||
|
||||
def test_release_terminalizes_a_created_reserved_case() -> None:
|
||||
release = _function(_normalized(BILLING), "release_conversational_rectification_fee")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user