diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index aaedfb88..50f8615c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -313,3 +313,99 @@ - 相关记录:BUG-008、BUG-009、BUG-016、ERR-085、ERR-091 - 复发自:无 - 修复版本:待提交(生产 smoke 待当前精确 SHA 发布后补齐) + +## BUG-018 | 生时校正把语言问答包装成高阻力卡片表单 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:首页生时校正会话、经历录入、候选核对与更正 +- 用户现象:每轮同时出现候选卡、领域按钮、年份与月份下拉、经历回顾卡和确认卡;用户需要理解多层控件才能回答一个问题,移动端尤为费力。 +- 触发条件:进入生时校正并提交或更正一条真实经历。 +- 根因:后端已有自由文本证据契约,但前端仍用结构化表单和多卡片包装;校正叙事 Agent 也未加载 Jyotish Skill 来生成自然、逐问式的取证措辞。 +- 修复:改为一段助理提问加一个自然语言输入框;候选状态和证据历史收进可展开进度区;保留明确确认、更正、持久化、计费、计算和分钟验证门禁;叙事 Agent 加载 Jyotish Skill,但服务端技术包仍是候选事实和确认权限的唯一来源。 +- 验证:语言优先组件静态与真实 Chromium 390px 回归 8/8 通过;校正路由与叙事契约 23/23 通过;目标文件 ESLint 和 Next.js production build 通过。 +- 防复发:语言问答不得重新拆成领域按钮或日期下拉;Skill 只能改进问题策略与措辞,不得生成、重算或确认候选时间。 +- 相关记录:BUG-008、BUG-009、BUG-015、BUG-016、BUG-017 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-019 | 自然语言真实事件在分钟评分入口被静默丢弃 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正自然语言抽取、历史事件持久化、分钟评分、首轮与兜底叙事 +- 用户现象:用户已经提供带日期的真实经历,系统仍反复索取证据;首轮回答展示完整 D 层技术清单,却没有用一个具体问题推进取证。19 世纪事件、疾病、手术、事故和丧亲尤其容易无法参与评分。 +- 触发条件:日期早于 1900 年;事件属于健康或重大压力;累计有效事件超过 6 条;或叙事模型未满足首轮全量技术层合同而进入确定性兜底。 +- 根因:前端日期正则写死 1900–2099;健康类关键词落入 `other`,并在路由中被过滤,尽管后端已有 `health_pressure` 与 D30 评分支持;路由另有独立的 6 条截断;叙事校验强制首轮正文列出全部稳定层、敏感层和值。 +- 修复:日期抽取支持 1000–2099 的四位历史年份;健康、事故、丧亲映射到既有 `health_pressure`/D30 合同并贯通持久化、旧数据导入和公开 turn schema;评分截断统一复用 8 条收敛上限;技术包和 validation receipt 继续完整保存,但非最终用户正文只呈现候选范围、未确认边界和一个高信息量问题,确定性兜底也遵守相同语言交互。 +- 验证:抽取、叙事、路由聚焦测试 59/59 通过;真实案例回放、编排和端到端契约 65/65 通过;目标文件 ESLint、Python replay/compilation、`git diff --check` 和 Next.js production build 通过。公开 smoke 中 Steve Jobs、Einstein、Marie Curie 分别保留 4、3、4 条可评分事件,产品过滤结果与结构化 oracle 一致。 +- 防复发:新增 18xx 中文与 ISO 日期、健康/事故/丧亲、8 条事件上限和单问题兜底断言;技术层可进入服务端证据包,不得重新进入普通会话正文;family/other 仍是持久化背景,不得伪装成已评分领域。 +- 相关记录:BUG-008、BUG-009、BUG-015、BUG-016、BUG-018 +- 复发自:BUG-018 +- 修复版本:待提交(本地可测) + +## BUG-020 | 生时校正入口等待首轮请求完成后才切换会话 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:首页生时校正入口、普通咨询中的“先完成生时校正”、侧栏恢复校正会话 +- 用户现象:点击生时校正后长时间停留在首页或原咨询 session,直到恢复、建案、计算和会话持久化全部完成才突然跳转,用户容易误以为点击无效并重复操作。 +- 触发条件:`start`、`resume`、durable handoff 或 session persistence 任一请求响应较慢。 +- 根因:`openBirthTimeRectification` 虽然先设置了 loading,但专属 session 的插入、激活和校正 surface 的开放都位于首个异步请求之后;surface 还要求首个 turn 已存在,因此 loading 状态无法在目标会话中显示。 +- 修复:创建或复用专属 session 后,在任何 `await` 之前立即插入并激活该 session;校正 surface 允许以空 turn 渲染既有“正在建立校正记录…”状态;增加同步 in-flight 门禁防止快速重复点击发出多个启动请求;失败时新建 session 回滚到来源会话,复用 session 则保留可见错误与重试入口。 +- 验证:`frontend/tests/consultation-entrypoint.test.ts` 21/21 通过,锁定 session 插入、激活和 loading surface 必须早于首个异步请求,并锁定单请求门禁;目标文件 ESLint、`git diff --check` 和 Next.js production build 通过;真实应用内 Chromium 从首页点击时,在后台恢复完成前已经显示“正在建立校正记录…”,从普通会话切回校正 session 也立即显示目标会话。 +- 防复发:首页卡片、普通 session 建议和侧栏恢复必须继续共用同一启动函数;不得重新用首个 turn 是否返回作为 session 可见性的条件。 +- 相关记录:BUG-016、BUG-018、BUG-019 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-021 | 初始化地址保存后的加载提示仍指向生时评估 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:新用户初始化流程、出生地点保存后的全屏加载提示 +- 用户现象:用户填完出生地址后,页面实际准备进入首页,但加载提示仍显示正在生成生时评估,造成流程去向与界面文案不一致。 +- 触发条件:初始化流程完成出生时间填写,并提交最后一步出生地点。 +- 根因:出生时间保存和出生地点保存共用 `saving_profile` 展示阶段;该阶段文案仍按旧流程描述为即将生成生时评估。 +- 修复:新增独立的 `entering_home` 展示阶段,仅在 `saveOnboardingPlace` 提交地址时使用,显示“正在进入首页 / 出生资料已保存,正在为你准备首页。”;出生时间保存继续使用原 `saving_profile` 阶段。 +- 验证:聚焦测试锁定地址提交与 `entering_home` 的绑定及目标文案;目标文件 ESLint 与 `git diff --check` 通过。 +- 防复发:初始化步骤的加载文案必须绑定实际导航结果;不得通过修改共享 `saving_profile` 文案改变出生时间提交阶段的语义。 +- 相关记录:BUG-020 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-022 | 侧栏会话标题与更多操作被拆成两块 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:聊天记录侧栏、当前会话选中态与更多操作入口 +- 用户现象:会话标题显示在一块选中背景中,右侧更多操作却以独立圆形按钮悬在外侧,看起来像两个不一致的控件。 +- 触发条件:侧栏展开并选中任意会话。 +- 根因:选中背景只应用在左侧 `.session-main`,右侧 `.session-menu-trigger` 又单独使用 `50%` 圆角和悬停背景。 +- 修复:把选中、悬停和聚焦背景统一应用到整行 `.session-row`;子按钮保持透明,并移除更多操作按钮的独立圆形底色。 +- 验证:侧栏契约测试锁定整行选中背景、透明标题按钮和非圆形更多操作按钮;目标文件 ESLint 与 `git diff --check` 通过。 +- 防复发:会话标题和行内操作必须共享同一个行级状态面,不得分别绘制互相竞争的选中背景。 +- 相关记录:无 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-023 | 健康压力追问被数据库误判为校正操作冲突 + +- 状态:resolved +- 首次发现:2026-07-22 +- 最近更新:2026-07-22 +- 影响面:生时校正首条自然语言回答、`POST /api/birth-time-conversation`、Supabase 对话持久化契约 +- 用户现象:用户提交一条带年月的真实经历后等待约一分钟,接口返回 `409 action_conflict`,页面提示加载最新进度后重试;重复提交仍无法进入下一问。 +- 触发条件:技术包为下一轮选择 `health_pressure`(健康与重大压力)作为待补证据领域。 +- 根因:应用层已把 `health_pressure` 作为正式领域,并允许语言问答每轮只追问一个重点领域;durable SQL 既缺少该领域,又要求 evidence request 至少包含两个领域。完整计算和叙事生成完成后,`save_conversational_rectification_turn` 才以 `conversational_action_conflict` 拒绝公开 turn。 +- 修复:新增向前迁移,将 `health_pressure` 同步加入四个 durable validator 和事件证据表约束,并把 evidence request 基数从 2–4 对齐为应用契约的 1–4;保留现有幂等、版本和候选确认门禁。 +- 验证:真实浏览器复现确认只有 `conversational_rectification_valid_evidence_request` 失败,其余公开 turn 子结构、事件、回执和私有候选均通过;迁移应用到测试 Supabase 后,用同一条“2023 年 3 月离家来北京工作”重放,`POST /api/birth-time-conversation` 返回 `200`,日志为 `actionKind: answer`、`resultCategory: success`,事件规范化保存为 `2023-03 · 离家来北京工作`,页面继续自然追问学业经历且输入框恢复可用;聚焦迁移测试锁定四个 validator 与表约束必须共同支持该领域。 +- 防复发:应用 evidence domain 枚举新增值时,必须同时更新 TypeScript schema、公开 recap/request、私有候选、事件表约束和迁移契约测试;不得把正常领域扩展映射为通用 409。 +- 相关记录:BUG-007、BUG-018、BUG-019 +- 复发自:BUG-007 +- 修复版本:待提交(测试 Supabase smoke 通过) diff --git a/docs/research/conversational_minute_rectification_change_plan_2026_07_22.md b/docs/research/conversational_minute_rectification_change_plan_2026_07_22.md new file mode 100644 index 00000000..258e276a --- /dev/null +++ b/docs/research/conversational_minute_rectification_change_plan_2026_07_22.md @@ -0,0 +1,131 @@ +# 对话式生时纠正修改方案与真实回放门槛 + +日期:2026-07-22 + +## 结论先行 + +当前系统可以改成普通 session 式的一问一答,但不能把“交互更顺”描述成“已经能准确到分钟”。现有公开开发案例的分钟评分仍不稳定;产品入口还会丢弃健康、事故、家庭类证据,并且实际只把最近 6 条事件送入评分。 + +本方案把修改拆成两个互不混淆的目标: + +1. 让用户通过自然语言持续补充经历,系统每轮只问一个最有区分力的问题。 +2. 用开发集和独立冻结盲测证明分钟排序是否真的收敛;没有通过门槛时只返回候选范围和证据缺口。 + +## 已确认的问题 + +### 1. 首轮正文被技术回执合同绑架 + +`narrative-agent.ts` 当前要求首轮正文复述所有稳定层、敏感层、具体值和领域映射。模型未满足时,fallback 又会输出同一份 D 层清单。技术包本身可以保留,但不应该成为用户正文。 + +### 2. 自然语言证据域不完整 + +`evidence-extractor.ts` 只显式识别事业、学业、迁移、关系、家庭、财富。疾病、手术、事故和丧亲会落到 `other`;`route.ts` 又会排除 `family` 和 `other`,所以 D30、家庭与部分重大人生事件无法参与评分。 + +日期正则还被限制为 1900–2099 年。公开历史人物案例中的 18xx 年事件会被保存成“日期未知”,无法评分;这也意味着现有历史 AA 开发案例并没有真正通过当前产品的自然语言入口。 + +### 3. 事件数量合同互相矛盾 + +- 会话收敛层:3 条开始排序,8 条停止继续取证。 +- 路由评分层:只保留最近 6 条。 +- Jyotish Skill:至少 5 条 dated events 才适合定框;高置信度通常需要 10–15 条多领域事件。 + +3 条可以产生早期候选反馈,但不得升级成准确分钟。停止条件也不能仅由事件数量触发。 + +### 4. 当前问题选择仍偏“让用户交材料” + +系统应从候选差异中选择一个信息增益最高、用户容易回忆的问题,例如“第一次正式工作是哪年哪月”,而不是一次要求用户提交多个领域的事件。 + +## 最小实施方案 + +### 阶段 A:修正文合同,不改评分器 + +建议改动: + +- `frontend/src/lib/conversational-rectification/narrative-agent.ts` + - 首轮正文只要求:当前范围、尚未确认边界、证据进度、一条具体问题。 + - 取消首轮必须公开全部 stable/sensitive layer values 的校验。 + - fallback 同样只输出一条问题。 +- 技术层继续完整保存在 technical packet / receipt 中,在 UI 中放入可折叠“计算依据”。 +- 用户消息区、输入框、滚动和留白完全复用普通 session 布局,不保留卡片式问卷。 + +验收:首轮正文不出现 D1/D9/D10 技术 dump;每轮只有一个问号和一个待回答主题。 + +### 阶段 B:补齐事件入口 + +建议改动: + +- 在持久化和技术包域中新增 `health_pressure`,不要把它降级成 `other`。 +- 抽取器识别:确诊、住院、手术、事故、受伤、创伤、重大疾病、康复。 +- 日期解析支持出生之后的合法公历年份,而不是写死 19xx/20xx;继续用 `asOfDate` 排除未来事件。 +- 家庭事件不要整体丢弃;按父母、子女、生育、丧亲等映射到后端支持的主题证据,若评分器尚无对应合同则明确存储为 context-only,而不是静默消失。 +- 移除 `.slice(-6)` 的隐式截断。建立一个共享常量;建议开发回放先测 5、8、10、12 条,再决定产品上限。 +- UI 显示“已记录 / 可评分 / 需补日期 / 仅作背景”的数量,避免用户以为回答已评分。 + +验收:真实测试集中所有标记为可评分的自然语言回答都能保留正确日期、精度和领域;任何被排除的事件都有可见原因。 + +### 阶段 C:一次问一个高信息量问题 + +复用现有 `candidateDifferences`、`suggestedDomains` 和 fact difference opportunities: + +1. 过滤已经问过、用户表示不记得、或缺少后端支持的主题。 +2. 优先选择能把当前候选分成最均衡分区的事件领域。 +3. 将领域转成生活化问题,并要求“年份/月份 + 发生了什么”。 +4. 用户回答后重新排序,再选择下一题。 + +问题模板必须具体,例如: + +> 你第一次明显的工作转折发生在什么时候?可以是入职、离职、换行业、升职或创业。直接告诉我大约哪一年哪一月和发生了什么;记不清月份也可以先说年份。 + +### 阶段 D:安全收敛 + +每轮都可以展示“当前领先范围”,但只有同时满足以下条件才允许提示确认: + +- 至少 5 条可评分事件,且至少 3 个领域;高置信度目标使用 10–15 条。 +- 真实候选形成唯一分钟或预先规定的窄范围。 +- ±1/2/5 分钟邻近稳定性通过。 +- leave-one-event-out 通过,不能由单一事件决定结果。 +- 没有缺失的强制计算层。 +- 独立冻结盲测达到发布指标。 + +未满足时输出:候选范围、支持事件、冲突事件、未评分事件和下一条问题,不允许写“准确时间为”。 + +## 测试集分层 + +### 对话开发回放集 + +文件:`references/real_case_calibration/conversational_rectification_development_v1.json` + +当前 v1 是入口 smoke fixture,不是分钟收敛 benchmark:TypeScript 测试验证 fixture 中自然语言在现有抽取器下的日期、精度和领域;Python 回放使用同一 fixture 的人工 `expected_route_scoreable` 标签模拟现有路由过滤,再测试逐轮排名。它不是自然语言到评分的端到端回放,且每例不足 5 条可评分事件,不能用于判断 Skill 级收敛。 + +下一版 development benchmark 必须把每例扩充至至少 5 条、目标 10–15 条多领域事件,并直接调用抽取器和路由投影,覆盖记不清、拒答、否定、纠正、重复和一条消息多事件。它引用仓库已有公开 AA 开发案例,永久排除在 holdout 之外,可以用于修 bug 和调问题排序。 + +### 独立冻结 holdout v4 + +开发集不能证明发布准确率。正式验证需要另一位 reviewer 收集至少 20 个未参与调参的公开 AA 案例,在评分前完成:出生来源复核、事件来源复核、假分钟承诺、manifest hash、实现 hash 和真值密封。只允许盲回放一次;失败后整批降为 development,下一版重新收集 fresh holdout。 + +## 指标 + +对话入口: + +- 日期和精度提取准确率。 +- 领域提取准确率。 +- 应评分事件保留率。 +- 每轮问题数必须等于 1。 +- 不能回答、记不清、纠正旧答案时仍能继续。 + +分钟能力: + +- 3、5、8、10 条事件时的 Top-1 / Top-3。 +- 逐轮真实分钟排名和误差轨迹。 +- 唯一分钟率、邻近稳定性、leave-one-out。并列范围未破时 `predicted_time` 必须为空;如保留确定性 tie-break,只能单列为诊断 MAE,不能当成分钟能力。 +- false confirmation rate 和 confirmation coverage 必须同时报告。 + +建议继续沿用当前发布门槛:Top-1 ≥ 60%,Top-3 ≥ 85%,MAE ≤ 2 分钟,false confirmation ≤ 5%,insufficient-evidence rejection ≥ 90%。confirmation coverage 不能为 0,否则只是“永不确认”而非可用收敛。 + +## 实施顺序 + +1. 先合并阶段 A 与入口可观测性,不改变“准确分钟”声明。 +2. 用本开发回放集修阶段 B,确保事件没有在入口丢失。 +3. 接入阶段 C 的单问题排序并做多轮 replay。 +4. 当开发集达到稳定目标后,冻结实现并收集 fresh holdout v4。 +5. 只有 holdout 通过后才开放分钟确认;否则产品始终停在候选范围。 diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index 30d4299e..0b549734 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import path from "node:path"; import { conversationalRectificationCommandSchema, type ConversationalRectificationCommand, @@ -25,6 +26,7 @@ import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../. import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts"; import type { CandidateDifferenceBuild } from "../../../lib/birth-time-dynamic-choice-internal.ts"; import { conversationalRectificationCreationPolicyFromEnvironment } from "../../../lib/conversational-rectification/creation-policy.ts"; +import { MAXIMUM_SCOREABLE_EVENTS } from "../../../lib/conversational-rectification/convergence.ts"; import { conversationalRectificationLatencyBucket, createConversationalRectificationTelemetry, @@ -37,6 +39,9 @@ import { export const runtime = "nodejs"; export const maxDuration = 60; +const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim() + || path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology"); + type AuthenticatedRequest = Readonly<{ userId: string; context: unknown; @@ -349,7 +354,7 @@ function scoreableLifeEvents( precision: item.datePrecision as "day" | "month" | "year", date: item.dateValue, } as LifeEvent]; - }).slice(-6); + }).slice(-MAXIMUM_SCOREABLE_EVENTS); } function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] { @@ -665,7 +670,8 @@ async function productionNarrativeGenerator(): Promise svg { width: var(--space-3); height: var(--space-3); flex: 0 0 auto; color: var(--sidebar-ring); } .session-main small { color: inherit; line-height: 1.3; opacity: .72; font-size: var(--type-overline); } -.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; opacity: .64; transition: opacity 120ms ease-out, background-color 120ms ease-out, color 120ms ease-out; } +.session-menu-trigger { width: 44px; height: 44px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 0; background: transparent; color: inherit; cursor: pointer; opacity: .64; transition: opacity 120ms ease-out, color 120ms ease-out; } .session-menu-trigger > svg { width: 18px; height: 18px; } .session-row:hover .session-menu-trigger, .session-row:focus-within .session-menu-trigger, .session-menu-trigger[aria-expanded="true"] { opacity: 1; } -.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } +.session-menu-trigger:hover, .session-menu-trigger[aria-expanded="true"] { background: transparent; color: inherit; } .session-actions { position: absolute; z-index: 4; top: calc(100% - var(--space-1)); right: 0; min-width: calc(var(--space-24) + var(--space-12)); display: grid; gap: var(--space-1); padding: var(--space-2); border: 1px solid var(--sidebar-border); border-radius: var(--radius-md); background: var(--color-canvas); box-shadow: var(--shadow-elevated); } .session-actions button { width: 100%; min-height: 44px; display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-3); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; text-align: left; font-size: var(--type-body-sm); } .session-actions button > svg { width: 16px; height: 16px; } @@ -464,13 +466,6 @@ button:disabled { cursor: default; opacity: .45; } .conversational-narrative .message-markdown > :first-child { margin-top: 0; } .conversational-narrative .message-markdown > :last-child { margin-bottom: 0; } .conversational-narrative .message-markdown h2, .conversational-narrative .message-markdown h3 { margin: var(--space-4) 0 var(--space-2); font-family: var(--font-sans); font-size: var(--type-body-md); font-weight: 600; letter-spacing: 0; } -.conversational-domain-picker { display: grid; gap: var(--space-3); margin: 0; padding: 0; border: 0; } -.conversational-domain-picker .conversational-domain-question { margin-bottom: var(--space-2); color: var(--color-ink); font-size: var(--type-body-md); font-weight: 600; line-height: 1.5; } -.conversational-domain-picker > div { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--space-2); } -.conversational-domain-picker button { display: grid; gap: var(--space-1); padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); cursor: pointer; text-align: left; } -.conversational-domain-picker button > span { font-size: var(--type-body-sm); font-weight: 600; } -.conversational-domain-picker button > small { color: var(--color-ink-secondary); font-size: var(--type-overline); font-weight: 400; } -.conversational-domain-picker button[aria-pressed="true"] { border-color: var(--color-action); background: var(--color-action-soft); color: var(--color-action); } .conversational-answer-pending { min-width: 0; display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); border: 1px solid color-mix(in srgb, var(--color-action) 24%, var(--color-border)); border-radius: var(--radius-lg); background: var(--color-action-soft); } .conversational-answer-pending .app-loading-symbol { width: 40px; height: 40px; } .conversational-answer-pending .app-loading-mark { width: 22px; height: 22px; } @@ -480,8 +475,6 @@ button:disabled { cursor: default; opacity: .45; } .conversational-composer { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); } .conversational-composer label { display: grid; gap: var(--space-1); font-size: var(--type-body-sm); font-weight: 600; } .conversational-composer label span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 400; line-height: 1.5; } -.conversational-event-date { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } -.conversational-event-date label { gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 500; } .conversational-composer textarea { width: 100%; min-width: 0; min-height: 112px; resize: vertical; padding: var(--space-3); border: 1px solid var(--color-border-strong); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); line-height: 1.55; } .conversational-composer-footer { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } .conversational-composer-footer small { color: var(--color-ink-tertiary); font-size: var(--type-overline); } @@ -781,10 +774,31 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: [data-sonner-toaster] { right: var(--space-4) !important; left: var(--space-4) !important; width: auto !important; } .conversational-rectification { width: 100%; max-width: 100%; gap: var(--space-4); overflow-x: clip; } .conversational-narrative-body, .conversational-composer, .conversational-evidence-recap, .conversational-candidate, .conversational-confirmation, .conversational-original-question, .conversational-empty-state { padding: var(--space-3); } - .conversational-domain-picker > div, .conversational-candidate dl { grid-template-columns: minmax(0, 1fr); } + .conversational-candidate dl { grid-template-columns: minmax(0, 1fr); } .conversational-composer-footer { align-items: stretch; flex-direction: column; } .conversational-composer-footer button, .conversational-confirmation button, .conversational-original-question button { width: 100%; } } + +/* Conversational rectification shell */ +.conversation.is-rectification { overflow: hidden; padding-bottom: 0; } +.rectification-chat { height: 100%; min-height: 0; display: flex; flex-direction: column; color: var(--color-ink); } +.rectification-message-list { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--space-6); } +.rectification-message-details { margin-top: var(--space-3); padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-secondary); font-size: var(--type-caption); } +.rectification-message-details summary { min-height: 44px; display: flex; align-items: center; cursor: pointer; font-size: var(--type-body-sm); font-weight: 600; } +.rectification-message-details p { margin: 0 0 var(--space-2); font-size: var(--type-caption); line-height: 1.6; } +.rectification-message-details ul { display: grid; gap: var(--space-2); margin: var(--space-3) 0 0; padding: 0; list-style: none; } +.rectification-message-details li { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: var(--space-3); padding-top: var(--space-2); border-top: 1px solid var(--color-border); } +.rectification-message-details button, .rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; } +.rectification-composer-wrap { position: static; flex: 0 0 auto; } +.rectification-correction-target { width: min(760px, 100%); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: 0 auto var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); } +.rectification-correction-target p { margin: 0; } +.conversational-empty-state { width: min(760px, 100%); margin: var(--space-8) auto; color: var(--color-ink-secondary); } + +@media (max-width: 430px) { + .rectification-message-details li { align-items: stretch; grid-template-columns: minmax(0, 1fr); } + .rectification-correction-target { align-items: stretch; display: grid; } +} + /* Starter workbench */ .welcome { width: min(1040px, 100%); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e4c07f5b..964cc3ca 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -836,6 +836,7 @@ export default function Home() { createDurableRectificationQuestionHandoffClient(), ); const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined); + const rectificationOpenInFlight = useRef(false); const rectificationContinuationInFlight = useRef(false); const uiPreview = useRef(false); const uiPreviewMode = useRef(null); @@ -853,7 +854,8 @@ export default function Home() { const visibleRectificationTurn = activeSession?.id === rectificationSessionId ? rectificationInitialTurn : null; - const rectificationSurfaceOpen = activeRectificationSession && visibleRectificationTurn !== null; + const rectificationSurfaceOpen = activeRectificationSession + && activeSession.id === rectificationSessionId; const visibleSessions = sessions .filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id)) .sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id))); @@ -1759,7 +1761,7 @@ export default function Home() { event.preventDefault(); if (!selectedBirthPlace(profileDraft) || !account || profileSaving) return; setProfileSaving(true); - setBirthTimeAssessmentPhase("saving_profile"); + setBirthTimeAssessmentPhase("entering_home"); setAccountError(""); try { await persistProfile(profileDraft); @@ -1906,7 +1908,8 @@ export default function Home() { pendingConsultationQuestion: string | null = null, sourceSessionOverride: ChatSession | null = null, ) { - if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationMutationPending + if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current + || rectificationMutationPending || rectificationContinuationInFlight.current) return; const sourceSession = sourceSessionOverride ?? activeSession; if (!sourceSession) return; @@ -1936,6 +1939,7 @@ export default function Home() { ? null : rectificationQuestionHandoff.current.peek()?.question) ?? null; + rectificationOpenInFlight.current = true; setDraft(""); setDraftTheme(null); setDraftEntrypoint(null); @@ -1943,6 +1947,18 @@ export default function Home() { setRectificationInitialTurn(null); setRectificationError(""); setRectificationLoading(true); + if (!reusingRectificationSession) { + setSessions((current) => [ + rectificationSession, + ...current.filter((session) => session.id !== rectificationSession.id), + ]); + } + if (sourceSession.id !== rectificationSession.id) { + setRectificationReturnSessionId(sourceSession.id); + } + setRectificationSessionId(rectificationSession.id); + activeSessionIdRef.current = rectificationSession.id; + setActiveSessionId(rectificationSession.id); try { let turn: ConversationalRectificationTurn; if (action !== "resume" || !account.rectificationCase) { @@ -2008,12 +2024,6 @@ export default function Home() { } setRectificationInitialTurn(turn); synchronizeRectificationQuestion(turn, sourceSession); - if (sourceSession.id !== boundSession.id) { - setRectificationReturnSessionId(sourceSession.id); - } - setRectificationSessionId(boundSession.id); - activeSessionIdRef.current = boundSession.id; - setActiveSessionId(boundSession.id); setComposerNotice(sessionSyncFailed ? "校正已经开始,但会话关联暂时未同步到云端。" : ""); } catch (caught) { const message = caught instanceof Error @@ -2021,7 +2031,14 @@ export default function Home() { : "生时校正暂时无法继续,请稍后重试。"; setRectificationError(message); setComposerNotice(message); + if (!reusingRectificationSession) { + setSessions((current) => current.filter((session) => session.id !== rectificationSession.id)); + setRectificationSessionId(null); + activeSessionIdRef.current = sourceSession.id; + setActiveSessionId(sourceSession.id); + } } finally { + rectificationOpenInFlight.current = false; setRectificationLoading(false); } } @@ -2874,7 +2891,7 @@ export default function Home() { -
+
{!rectificationSurfaceOpen && (!activeSession?.messages.length ? (
{!profileComplete ? ( @@ -3049,18 +3066,23 @@ export default function Home() {
))} - {rectificationSurfaceOpen && visibleRectificationTurn && ( -
- void continueRectificationOriginalQuestion(question)} - /> -
- )} + {rectificationSurfaceOpen && (!visibleRectificationTurn && rectificationError ? ( +
+

{rectificationError}

+ +
+ ) : ( + void continueRectificationOriginalQuestion(question)} + /> + ))}
{!rectificationSurfaceOpen &&
diff --git a/frontend/src/components/birth-time-assessment-overlay.tsx b/frontend/src/components/birth-time-assessment-overlay.tsx index f44a23a7..4496f734 100644 --- a/frontend/src/components/birth-time-assessment-overlay.tsx +++ b/frontend/src/components/birth-time-assessment-overlay.tsx @@ -1,4 +1,4 @@ -export type BirthTimeAssessmentPhase = "saving_profile" | "assessing"; +export type BirthTimeAssessmentPhase = "saving_profile" | "assessing" | "entering_home"; const progressCopy = { saving_profile: { @@ -9,6 +9,10 @@ const progressCopy = { title: "正在生成生时评估", detail: "正在读取已有资料并准备下一步,不需要重复操作。", }, + entering_home: { + title: "正在进入首页", + detail: "出生资料已保存,正在为你准备首页。", + }, } as const satisfies Record; export function BirthTimeAssessmentOverlay({ phase }: { readonly phase: BirthTimeAssessmentPhase | null }) { diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx index f5d3703a..07e257e8 100644 --- a/frontend/src/components/conversational-birth-time-rectification.tsx +++ b/frontend/src/components/conversational-birth-time-rectification.tsx @@ -1,11 +1,11 @@ "use client"; -import { - useEffect, - useRef, - useState, -} from "react"; +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 { Button } from "./ui/button.tsx"; +import { Textarea } from "./ui/textarea.tsx"; import { useConversationalRectification, type ConversationalRectificationController, @@ -20,46 +20,52 @@ const domainLabels = { career: "事业与身份", education: "学业与学习", finance: "收入与资产", + health_pressure: "健康与重大压力", relocation: "搬迁与居住地", relationship: "重要关系", family: "家庭变化", other: "其他关键经历", } as const satisfies Readonly>; -function orderedEvidenceDomains(turn: ConversationalRectificationTurn): EvidenceDomain[] { +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))); + return [...(turn.evidenceRequest?.domains ?? [])] + .sort((left, right) => Number(provided.has(left)) - Number(provided.has(right))) + .slice(0, 2); } function visibleTurnNarrative(turn: ConversationalRectificationTurn): string { - if ( - turn.status !== "active" - || turn.evidenceRecap.length === 0 - || /(?:已记录|已修订):/.test(turn.narrative) - ) { + if (["paused", "abandoned", "completed"].includes(turn.status)) { return turn.narrative; } - const latestEvidence = turn.evidenceRecap.at(-1)!; - const candidate = turn.candidate; - const candidateRange = candidate.rangeStart && candidate.rangeEnd - ? `${candidate.rangeStart}–${candidate.rangeEnd}` - : candidate.representativeTime ?? "当前候选范围"; - const nextDomains = orderedEvidenceDomains(turn) - .slice(0, 2) - .map((domain) => domainLabels[domain]); - const nextStep = candidate.status === "ready_for_confirmation" - ? "当前证据已形成候选总结;请先核对上方候选时间,再决定是否确认或继续补充经历。" - : nextDomains.length > 0 - ? `下一步:请优先补充一件${nextDomains.join("或")}领域已经发生的事件,并选择发生年月。` - : "下一步:请继续补充一件已经发生并带有年月的真实经历。"; + 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};范围暂未变化不代表提交失败,系统会结合后续经历继续比较相邻分钟。`, - nextStep, - ].join("\n"); + `候选范围现在是 ${candidateRange}。范围暂未变化不代表提交失败,我会结合后续经历继续比较相邻分钟。`, + nextQuestion, + ].join("\n\n"); } type SurfaceProps = Readonly<{ @@ -73,53 +79,11 @@ function safely(request: Promise) { void request.catch(() => undefined); } -function CandidateSummary({ turn }: { readonly turn: ConversationalRectificationTurn }) { - const candidate = turn.candidate; - const confirmed = candidate.status === "confirmed" && turn.status === "completed"; - const status = confirmed - ? "已明确确认" - : turn.status === "completed" - ? "范围已保存 · 分钟未确认" - : candidate.status === "ready_for_confirmation" - ? "待确认 · 未验证" - : "待验证 · 未确认"; - const recordedCount = turn.evidenceRecap.length; - const nextDomains = orderedEvidenceDomains(turn) - .slice(0, 2) - .map((domain) => domainLabels[domain]); - - return ( -
-
-

候选时间

- {status} -
- {candidate.representativeTime ? ( -
-
-
代表时间
-
-
-
-
候选范围
-
{candidate.rangeStart && candidate.rangeEnd - ? `${candidate.rangeStart}—${candidate.rangeEnd}` - : "尚未缩小"}
-
-
- ) :

尚未形成可供确认的具体时间。

} - {!confirmed &&

候选仍待真实经历验证,未经你的明确确认不会成为当前排盘时间。

} - {!confirmed && recordedCount > 0 && turn.status !== "completed" && ( -
- 已记录 {recordedCount} 条经历 - 系统至少需要 3 条时间明确、可评分的经历后才会可靠缩小范围;范围不变不代表提交失败。 - {nextDomains.length > 0 && ( - 下一步优先补充:{nextDomains.join("或")}领域已经发生的事件。 - )} -
- )} -
- ); +function candidateStatus(turn: ConversationalRectificationTurn): string { + if (turn.status === "completed" && turn.candidate.status === "confirmed") return "已确认"; + if (turn.status === "completed") return "范围已保存,分钟未确认"; + if (turn.candidate.status === "ready_for_confirmation") return "待确认,尚未验证"; + return "待验证"; } export function ConversationalRectificationSurface({ @@ -129,14 +93,11 @@ export function ConversationalRectificationSurface({ onContinueOriginalQuestion, }: SurfaceProps) { const composer = useRef(null); - const [eventYear, setEventYear] = useState(""); - const [eventMonth, setEventMonth] = useState(""); - const currentYear = new Date().getFullYear(); - const eventYears = Array.from({ length: 101 }, (_, index) => currentYear - index); const turn = controller.turn; const pendingQuestion = turn?.status === "completed" ? turn.pendingConsultationQuestion : turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null; + if (!turn) { return (
@@ -147,262 +108,166 @@ export function ConversationalRectificationSurface({ } const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed"; - const requestedDomains = orderedEvidenceDomains(turn); - const providedDomains = new Set( - turn.evidenceRecap.flatMap((item) => item.domain ? [item.domain] : []), - ); - const priorityDomain = requestedDomains.find((domain) => !providedDomains.has(domain)) - ?? requestedDomains[0] - ?? null; + const canConfirm = turn.actions.includes("confirm") + && turn.candidate.status === "ready_for_confirmation" + && Boolean(turn.candidate.representativeTime); + const canContinue = turn.actions.includes("continue_original_question") + && Boolean(pendingQuestion) + && Boolean(onContinueOriginalQuestion); const submit = async () => { if (!canAnswer || !controller.draft.trim() || controller.pending) return; - const dateLabel = eventYear - ? `${eventYear} 年${eventMonth ? ` ${Number(eventMonth)} 月` : ""}` - : "时间不确定"; - const answer = `发生时间:${dateLabel}\n事件详情:${controller.draft.trim()}`; try { - await controller.answer(undefined, answer); - setEventYear(""); - setEventMonth(""); + await controller.answer(undefined, controller.draft.trim()); + composer.current?.focus(); } catch { - // The controller owns the visible request error and keeps the draft available for retry. + // The controller keeps the draft and owns the visible error. } }; - const focusComposer = () => composer.current?.focus(); return ( -
- - -
-
- -
-
- - {requestedDomains.length > 0 && ( -
- 这轮想先补充哪个领域? -
- {requestedDomains.map((domain) => ( - - ))} -
-
- )} - - {controller.pending && canAnswer && ( -
- -
- 正在核对这段经历 - 正在比较相邻候选分钟;完成后会继续提问或更新候选范围。 -
-
- )} - - {canAnswer &&
{ - event.preventDefault(); - void submit(); - }} - > - {controller.correctionTarget && ( -
-

- 正在更正:{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary} -

- 一次只更正一条事件。提交后会保留原记录用于审计,但候选评分只使用更正后的有效证据。 - -
+
+
+ {controller.pending ? "Jyotisha 正在核对经历" : ""} + {turn.evidenceRecap.map((entry) => ( + + ))} + {controller.pending && controller.draft.trim() && ( + )} - -
- - -
-