diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 50f8615c..8ba6644e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -409,3 +409,129 @@ - 相关记录:BUG-007、BUG-018、BUG-019 - 复发自:BUG-007 - 修复版本:待提交(测试 Supabase smoke 通过) + +## BUG-024 | 生时校正收到具体经历后仍重复泛问 + +- 状态: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-025 | 生时校正仍有未回答区分领域时提前结束 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正多领域追问、候选范围停滞终止策略 +- 用户现象:用户只回答两轮后,系统仍有财务或关系等可区分领域未询问,却直接保存宽候选范围并结束。 +- 触发条件:自然语言单轮抽取出多条可评分经历,使累计数量达到最低门槛;候选范围连续两轮未变化,同时技术包仍建议尚未回答的领域。 +- 根因:停滞终止只检查可评分经历数量和候选范围是否连续不变,没有检查当前技术包是否仍存在未回答的区分领域。 +- 修复:停滞结束前增加未回答建议领域门禁;仍有可区分领域时继续自然追问,全部建议领域已覆盖后才允许按范围停滞安全结束。证据数量上限和无可区分问题终止保持不变。 +- 验证:编排器回归锁定学业、搬迁、事业三轮后必须继续询问尚未覆盖的关系领域,补充关系事件后才保存未确认范围;本地真实流程修复前复现为两轮即结束且范围仍为 `04:30–06:30`。 +- 防复发:停滞终止测试必须同时断言候选范围未变化和技术包建议领域已全部回答,不能只按事件条数结束。 +- 相关记录:BUG-019、BUG-024 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-026 | 职位和管理职责变化未识别为事业证据 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正自然语言证据分类、事业领域覆盖判断、下一轮追问 +- 用户现象:用户已经说明“开始承担管理职责”或“职位发生明显变化”,系统记录事件后仍继续要求事业经历。 +- 触发条件:事业变化使用“职位”“任职”或“管理职责”描述,但没有出现“工作”“升职”“职业”等原有关键词。 +- 根因:事业领域分类词表缺少常见的岗位和职责变化表达,导致可评分事件被归入 `other`,无法计入事业领域覆盖。 +- 修复:将“职位”“任职”“管理职责”加入事业领域分类规则,保留既有日期、评分和持久化契约。 +- 验证:抽取器回归覆盖三种带年月的岗位与职责变化表达,均分类为 `career`、保留月份并可参与评分;本地真实流程已复现修复前的漏判行为。 +- 防复发:事业领域分类测试必须覆盖入离职、晋升以及不含“工作/职业”字样的职责变化表达。 +- 相关记录:BUG-024、BUG-025 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-027 | 生时纠正未收敛仍永久扣费且事件事实未进入评分契约 + +- 状态: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-024、BUG-025 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-028 | 生时校正覆盖历史回答且完成后无法返回原问题 + +- 状态: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-024 +- 复发自:BUG-018、BUG-024 +- 修复版本:待提交(本地可测) + +## BUG-029 | 生时校正缺少连续事件语义导致模板式跳问 + +- 状态: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-024、BUG-025、BUG-028 +- 复发自:BUG-024、BUG-028 +- 修复版本:待提交(本地可测) + +## BUG-030 | 生时校正 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-028、BUG-029 +- 修复版本:待提交(本地可测) + +## BUG-031 | 生时校正发送错误内容后无法撤回修改 + +- 状态: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-028 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index 0b549734..c26ab2b9 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -21,7 +21,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"; @@ -353,6 +356,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 +675,21 @@ async function productionNarrativeGenerator(): Promise()); - const continueRectificationQuestion = useRef<(question: string) => void>(() => undefined); - const automaticRectificationContinuation = useRef(""); const durableRectificationQuestionHandoff = useRef( createDurableRectificationQuestionHandoffClient(), ); @@ -2632,6 +2630,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); @@ -2647,12 +2658,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; @@ -2663,7 +2670,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); @@ -2702,22 +2709,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(); diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index 07e257e8..74fcb751 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -1,9 +1,8 @@ "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, useRef, useState } from "react"; +import { ChatMessageRow } from "./chat-message-row.tsx"; import { Button } from "./ui/button.tsx"; import { Textarea } from "./ui/textarea.tsx"; import { @@ -12,62 +11,6 @@ import { } 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>; - -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"); -} - type SurfaceProps = Readonly<{ controller: ConversationalRectificationController; pendingConsultationQuestion?: string | null; @@ -75,6 +18,8 @@ type SurfaceProps = Readonly<{ onContinueOriginalQuestion?: (question: string) => void; }>; +const ANSWER_UNDO_WINDOW_MS = 2_500; + function safely(request: Promise) { void request.catch(() => undefined); } @@ -93,7 +38,16 @@ export function ConversationalRectificationSurface({ onContinueOriginalQuestion, }: SurfaceProps) { const composer = useRef(null); + const undoTimer = useRef | null>(null); + const [submission, setSubmission] = useState | null>(null); const turn = controller.turn; + useEffect(() => () => { + if (undoTimer.current) clearTimeout(undoTimer.current); + }, []); const pendingQuestion = turn?.status === "completed" ? turn.pendingConsultationQuestion : turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; @@ -114,42 +68,59 @@ 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()); }; return ( -
+
- {controller.pending ? "Jyotisha 正在核对经历" : ""} - {turn.evidenceRecap.map((entry) => ( + {submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""} + {(controller.messages ?? [{ + role: "assistant" as const, + text: turn.narrative, + renderKey: `assistant-${turn.turnVersion}`, + }]).map((message) => ( ))} - {controller.pending && controller.draft.trim() && ( + {submission && turn.turnVersion === submission.turnVersion && ( )} -
- -
-
- -
+
{turn.candidate.representativeTime ? `当前候选 ${turn.candidate.representativeTime} · ${candidateStatus(turn)}` @@ -169,7 +140,7 @@ export function ConversationalRectificationSurface({ {canAnswer && (
-
-
-
+ {controller.pending && canAnswer && ( )} @@ -209,7 +177,7 @@ export function ConversationalRectificationSurface({ {canConfirm && (
)} -
{ event.preventDefault(); void submit(); }}> + { event.preventDefault(); submit(); }}> @@ -244,7 +212,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 +227,20 @@ export function ConversationalRectificationSurface({ } }} /> - + {submission?.phase === "undo" ? ( + + ) : ( + + )}
-

{controller.pending ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}

+

{submission?.phase === "undo" + ? "已发送,2.5 秒内可撤回修改,本次不会计入校正。" + : busy ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}

} } diff --git a/frontend/src/hooks/use-conversational-rectification.ts b/frontend/src/hooks/use-conversational-rectification.ts index 35929c4f..7deb4e43 100644 --- a/frontend/src/hooks/use-conversational-rectification.ts +++ b/frontend/src/hooks/use-conversational-rectification.ts @@ -18,8 +18,41 @@ 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: ConversationalRectificationTurn | null): ConversationalRectificationMessage[] { + if (!turn) return []; + if (turn.evidenceRecap.length === 0) { + return [{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }]; + } + return turn.evidenceRecap.flatMap((entry, index) => { + const user = { + role: "user" as const, + text: `${entry.dateLabel} · ${entry.summary}${entry.isCorrection ? "(已修订)" : ""}`, + renderKey: `user-${entry.id}`, + }; + const assistant = index === turn.evidenceRecap.length - 1 + ? assistantText(turn) + : `已记录这段经历:${entry.dateLabel} · ${entry.summary}。`; + return [user, { + role: "assistant" as const, + text: assistant, + renderKey: `assistant-history-${entry.id}`, + }]; + }); +} + export type ConversationalRectificationControllerSnapshot = Readonly<{ turn: ConversationalRectificationTurn | null; + messages?: readonly ConversationalRectificationMessage[]; draft: string; selectedDomain: EvidenceDomain | null; correctionTarget: EvidenceRecapEntry | null; @@ -57,6 +90,7 @@ type Mutation = Readonly<{ identity: ConversationalRectificationActionIdentity; command(actionId: string): ConversationalRectificationCommand; clearDraftOnSuccess?: boolean; + userMessage?: string; }>; type ActiveMutation = Readonly<{ @@ -103,6 +137,7 @@ 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, @@ -132,6 +167,7 @@ export function createConversationalRectificationController( turn: ConversationalRectificationTurn, clearDraft: boolean, expectedCaseContext: number, + userMessage?: string, ) => { const current = snapshot.turn; if (caseContext !== expectedCaseContext) return turn; @@ -148,6 +184,13 @@ 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: "", selectedDomain, correctionTarget, @@ -187,6 +230,7 @@ export function createConversationalRectificationController( turn, mutation.clearDraftOnSuccess === true, caseContextAtStart, + mutation.userMessage, )) .catch(async (error: unknown) => { if (turnAtStart && staleTurn(error) && caseContext === caseContextAtStart) { @@ -222,6 +266,7 @@ export function createConversationalRectificationController( payload: unknown, command: (turn: ConversationalRectificationTurn, actionId: string) => ConversationalRectificationCommand, clearDraftOnSuccess = false, + userMessage?: string, ): MutationResult => { const turn = currentTurn(); if (!turn) return Promise.resolve(null); @@ -234,11 +279,13 @@ 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; }, @@ -262,6 +309,7 @@ export function createConversationalRectificationController( } patch({ turn: null, + messages: [], draft: "", selectedDomain: null, correctionTarget: null, @@ -280,6 +328,7 @@ export function createConversationalRectificationController( } patch({ turn, + messages: initialMessages(turn), draft: "", selectedDomain: null, correctionTarget: null, @@ -291,6 +340,10 @@ export function createConversationalRectificationController( if (turn.turnVersion <= current.turnVersion) return; patch({ turn, + messages: [ + ...(snapshot.messages ?? []), + { role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }, + ], error: "", selectedDomain: snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain) @@ -360,7 +413,7 @@ export function createConversationalRectificationController( answer, ...(domain ? { domain } : {}), ...(correctsEvidenceId ? { correctsEvidenceId } : {}), - }), true); + }), true, answer); }, pause() { const turn = currentTurn(); diff --git a/frontend/src/lib/birth-time-evidence.ts b/frontend/src/lib/birth-time-evidence.ts index d41ab261..b64fe28d 100644 --- a/frontend/src/lib/birth-time-evidence.ts +++ b/frontend/src/lib/birth-time-evidence.ts @@ -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( diff --git a/frontend/src/lib/birth-time-journey-engine-model.ts b/frontend/src/lib/birth-time-journey-engine-model.ts index 4f1d817b..3966ff86 100644 --- a/frontend/src/lib/birth-time-journey-engine-model.ts +++ b/frontend/src/lib/birth-time-journey-engine-model.ts @@ -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; } diff --git a/frontend/src/lib/conversational-rectification/convergence.ts b/frontend/src/lib/conversational-rectification/convergence.ts index b0ac0036..586bedfb 100644 --- a/frontend/src/lib/conversational-rectification/convergence.ts +++ b/frontend/src/lib/conversational-rectification/convergence.ts @@ -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; } diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index 2e0d8c79..e3e0374f 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -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); } diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index a10d79b0..51a2450d 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -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>; -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; +export type RectificationNarrativeModelOutput = z.infer; 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 { 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, diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 4401a1e6..5e041a44 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -251,6 +251,43 @@ function evidenceRecap(evidence: ReadonlyArray) { })); } +function narrativeConversationContext(input: Readonly<{ + latestUserText: string; + allEvidence: ReadonlyArray; + newEvidence: ReadonlyArray; +}>) { + 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 +303,10 @@ function evidenceProgressNarrative(input: Readonly<{ previousCandidate: PrivateCandidateInput; packet: RectificationTechnicalPacket; newEvidence: ReadonlyArray; + allEvidence: ReadonlyArray; scoreableEventCount: number; willContinue: boolean; + authoredNarrative?: string | null; }>): string { const recorded = evidenceRecap(input.newEvidence); const acknowledgement = recorded.length === 0 @@ -283,8 +322,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 +332,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, +) { + 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, +) { + const unanswered = unansweredEvidenceDomains(packet, evidence); + return (unanswered.length > 0 ? unanswered : packet.suggestedDomains).slice(0, 2); +} + +function evidenceRequestForProgress(input: Readonly<{ + packet: RectificationTechnicalPacket; + evidence: ReadonlyArray; + 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 +484,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 +579,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 +602,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 +850,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 +1069,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, ); @@ -1041,6 +1172,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({ @@ -1149,12 +1285,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 +1307,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, diff --git a/frontend/src/lib/conversational-rectification/store.ts b/frontend/src/lib/conversational-rectification/store.ts index 1dfbffbc..8cb66776 100644 --- a/frontend/src/lib/conversational-rectification/store.ts +++ b/frontend/src/lib/conversational-rectification/store.ts @@ -372,7 +372,7 @@ export class ConversationalRectificationStore { async abandon( input: ConversationalRectificationTransitionInput, ): Promise { - return this.requireTransition("abandon_conversational_rectification_case", input); + return this.requireTransition("abandon_conversational_rectification_without_result", input); } private async requireTransition( diff --git a/frontend/src/lib/conversational-rectification/technical-packet.ts b/frontend/src/lib/conversational-rectification/technical-packet.ts index 6f7f751d..6fd4ca6e 100644 --- a/frontend/src/lib/conversational-rectification/technical-packet.ts +++ b/frontend/src/lib/conversational-rectification/technical-packet.ts @@ -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>; +}; + 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), }; } diff --git a/frontend/src/lib/conversational-rectification/visible-narrative.ts b/frontend/src/lib/conversational-rectification/visible-narrative.ts new file mode 100644 index 00000000..a3a2a562 --- /dev/null +++ b/frontend/src/lib/conversational-rectification/visible-narrative.ts @@ -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>; + +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"); +} diff --git a/frontend/supabase/migrations/20260722190000_refund_unconfirmed_rectification.sql b/frontend/supabase/migrations/20260722190000_refund_unconfirmed_rectification.sql new file mode 100644 index 00000000..ede87c9b --- /dev/null +++ b/frontend/supabase/migrations/20260722190000_refund_unconfirmed_rectification.sql @@ -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; diff --git a/frontend/tests/birth-time-journey-engine.test.ts b/frontend/tests/birth-time-journey-engine.test.ts index f669377f..db6098dc 100644 --- a/frontend/tests/birth-time-journey-engine.test.ts +++ b/frontend/tests/birth-time-journey-engine.test.ts @@ -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" }, ], diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index 04c775f1..95943fb3 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -180,13 +180,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/); }); diff --git a/frontend/tests/conversational-convergence.test.ts b/frontend/tests/conversational-convergence.test.ts new file mode 100644 index 00000000..35972db5 --- /dev/null +++ b/frontend/tests/conversational-convergence.test.ts @@ -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"); +}); diff --git a/frontend/tests/conversational-evidence-extractor.test.ts b/frontend/tests/conversational-evidence-extractor.test.ts index c445548c..73cc9dec 100644 --- a/frontend/tests/conversational-evidence-extractor.test.ts +++ b/frontend/tests/conversational-evidence-extractor.test.ts @@ -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({ diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts index f43f85fc..a8912c95 100644 --- a/frontend/tests/conversational-narrative-agent.test.ts +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -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]; diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index 161a6ffa..0766644a 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -122,6 +122,29 @@ test("a resumed legacy turn replaces repeated technical prose with actionable gu assert.doesNotMatch(markup, /D1 保持稳定/); }); +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", + }], + 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", () => { const emptyController = controller({ turn: null, @@ -773,6 +796,26 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro "correction cancellation", ); + 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(`document.querySelector('[aria-label="撤回发送,本次不计入校正"]') !== null`) ?? Promise.resolve(false), + "rectification undo window", + ); + await cdp.evaluate("document.querySelector('[aria-label=\"撤回发送,本次不计入校正\"]').click()"); + await waitFor( + () => cdp?.evaluate(`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("globalThis.__rectificationHarness.events.some((event) => event.endsWith(':answer'))"), false); + await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')"); await waitFor( () => cdp?.evaluate(`(() => { diff --git a/frontend/tests/conversational-rectification-controller.test.ts b/frontend/tests/conversational-rectification-controller.test.ts index 3113693d..c969604b 100644 --- a/frontend/tests/conversational-rectification-controller.test.ts +++ b/frontend/tests/conversational-rectification-controller.test.ts @@ -114,6 +114,27 @@ test("controller admits only one in-flight mutation and clears text only after s assert.deepEqual(pendingChanges, [true, false]); }); +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 preserves the exact draft and stable action id across failures", async () => { const commands: ConversationalRectificationCommand[] = []; const controller = createConversationalRectificationController({ @@ -393,6 +414,11 @@ 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, @@ -437,6 +463,7 @@ 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, @@ -448,6 +475,7 @@ 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, diff --git a/frontend/tests/conversational-rectification-e2e.test.ts b/frontend/tests/conversational-rectification-e2e.test.ts index 373de847..cdd8095b 100644 --- a/frontend/tests/conversational-rectification-e2e.test.ts +++ b/frontend/tests/conversational-rectification-e2e.test.ts @@ -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"; diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index f70e6a1a..ca0e3429 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -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, "candidate"> & { candidate: ReturnType["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(); const receipts = new Map "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"); diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index 7c150353..5227e5b8 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -25,8 +25,10 @@ test("production narrator loads the Jyotish Skill without overriding packet trut 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/); }); @@ -655,6 +657,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)), diff --git a/frontend/tests/conversational-rectification-store.test.ts b/frontend/tests/conversational-rectification-store.test.ts index 897e5c4b..54acf52b 100644 --- a/frontend/tests/conversational-rectification-store.test.ts +++ b/frontend/tests/conversational-rectification-store.test.ts @@ -342,7 +342,7 @@ test("save, pause, abandon, confirm, and import carry owner/version/action guard assert.deepEqual(calls.map(([name]) => name), [ "save_conversational_rectification_turn", "pause_conversational_rectification_case", - "abandon_conversational_rectification_case", + "abandon_conversational_rectification_without_result", "confirm_conversational_rectification_candidate", "import_legacy_conversational_rectification_case", ]); diff --git a/frontend/tests/conversational-technical-packet.test.ts b/frontend/tests/conversational-technical-packet.test.ts index 3763610b..badd2173 100644 --- a/frontend/tests/conversational-technical-packet.test.ts +++ b/frontend/tests/conversational-technical-packet.test.ts @@ -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, diff --git a/frontend/tests/conversational-visible-narrative.test.ts b/frontend/tests/conversational-visible-narrative.test.ts new file mode 100644 index 00000000..ed705664 --- /dev/null +++ b/frontend/tests/conversational-visible-narrative.test.ts @@ -0,0 +1,84 @@ +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 { + 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 the controller's alternating message history", () => { + 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\)/); + const messageHistoryIndex = source.indexOf("controller.messages"); + const progressDetailsIndex = source.indexOf("rectification-progress-details"); + const evidenceRecapIndex = source.indexOf("turn.evidenceRecap.map"); + assert.ok(messageHistoryIndex >= 0 && messageHistoryIndex < progressDetailsIndex); + assert.ok(evidenceRecapIndex > progressDetailsIndex); +}); diff --git a/scripts/active_rectification_event_engine.py b/scripts/active_rectification_event_engine.py index 7bd7defd..a0e386c0 100644 --- a/scripts/active_rectification_event_engine.py +++ b/scripts/active_rectification_event_engine.py @@ -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, diff --git a/scripts/active_rectification_events.py b/scripts/active_rectification_events.py index b7545f8d..323bc2f8 100644 --- a/scripts/active_rectification_events.py +++ b/scripts/active_rectification_events.py @@ -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): diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index d710122a..dbd6fa36 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -6944,8 +6944,11 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 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({ diff --git a/tests/test_active_rectification_api.py b/tests/test_active_rectification_api.py index 4f93c683..396a9219 100644 --- a/tests/test_active_rectification_api.py +++ b/tests/test_active_rectification_api.py @@ -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,4 @@ 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 月离开家乡开始大学生活" diff --git a/tests/test_active_rectification_events.py b/tests/test_active_rectification_events.py index 4198d0f1..8caa2679 100644 --- a/tests/test_active_rectification_events.py +++ b/tests/test_active_rectification_events.py @@ -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 diff --git a/tests/test_conversational_rectification_contract.py b/tests/test_conversational_rectification_contract.py index f86135c8..8bd66b1c 100644 --- a/tests/test_conversational_rectification_contract.py +++ b/tests/test_conversational_rectification_contract.py @@ -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")