feat: streamline conversational birth-time rectification

This commit is contained in:
Jesse_Chen
2026-07-22 17:39:11 +08:00
parent d40c16369d
commit 3069a7c8a0
30 changed files with 2059 additions and 590 deletions
+96
View File
@@ -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 通过)
@@ -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 通过后才开放分钟确认;否则产品始终停在候选范围。
@@ -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<RectificationNarrativeGen
id: `conversational-rectification-${model.id}`,
name: "Conversational Rectification Narrator",
model: model.model,
instructions: "Return only the exact JSON object requested by the user prompt. Use only supplied packet facts. Never invent times, layers, references, scores, dates, or confirmation state.",
skills: [jyotishSkillPath],
instructions: "Return only the exact JSON object requested by the user prompt. Use the Jyotish Skill only to choose a natural, one-question-at-a-time evidence strategy and wording. Treat supplied packet facts as the exclusive source of candidate times, status, dates, layers, scores, references, and confirmation permissions. Never invent, recalculate, or confirm candidate data.",
});
return {
modelId: model.id,
+28 -14
View File
@@ -293,17 +293,19 @@ button:disabled { cursor: default; opacity: .45; }
.sidebar-label { min-height: 44px; display: flex; align-items: center; color: var(--sidebar-muted-foreground); font-size: var(--type-overline); font-weight: 500; letter-spacing: 1.5px; }
.session-nav-toggle { min-height: 44px; padding: 0 var(--space-2); border: 0; border-radius: var(--radius-md); background: transparent; color: var(--sidebar-muted-foreground); cursor: pointer; font-size: var(--type-overline); transition: background-color 120ms ease-out, color 120ms ease-out; }
.session-list { min-height: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-md); color: var(--sidebar-muted-foreground); }
.session-row { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 44px; align-items: center; border-radius: var(--radius-md); color: var(--sidebar-muted-foreground); transition: background-color 120ms ease-out, color 120ms ease-out; }
.session-row:hover, .session-row:focus-within { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.session-row:has(.session-main[data-active="true"]) { background: var(--sidebar-accent); color: var(--sidebar-accent-foreground); }
.session-main { position: relative; width: 100%; min-height: 52px; display: grid; gap: 2px; padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); border: 0; border-radius: var(--radius-md); background: transparent; color: inherit; cursor: pointer; text-align: left; transition: background-color 120ms ease-out, color 120ms ease-out, transform 120ms ease-out; }
.session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: var(--sidebar-accent); }
.session-main[data-active="true"] { color: var(--sidebar-accent-foreground); background: transparent; }
.session-main[data-active="true"]::before { position: absolute; border-radius: 3px; content: ""; top: var(--space-3); bottom: var(--space-3); left: var(--space-1); width: 2px; background: var(--sidebar-ring); }
.session-title { min-width: 0; display: flex; align-items: center; gap: var(--space-1); overflow: hidden; line-height: 1.35; font-size: var(--type-body-sm); font-weight: 500; }
.session-title > 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%);
+44 -22
View File
@@ -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<string | null>(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() {
</button>
</header>
<div className={`conversation ${activeSession?.messages.length ? "" : "is-empty"}`}>
<div className={`conversation ${activeSession?.messages.length ? "" : "is-empty"} ${rectificationSurfaceOpen ? "is-rectification" : ""}`}>
{!rectificationSurfaceOpen && (!activeSession?.messages.length ? (
<div className="welcome">
{!profileComplete ? (
@@ -3049,18 +3066,23 @@ export default function Home() {
<div ref={conversationEnd} />
</div>
))}
{rectificationSurfaceOpen && visibleRectificationTurn && (
<section className="rectification-session-surface" aria-label="生时校正">
<ConversationalBirthTimeRectification
initialTurn={visibleRectificationTurn}
pendingConsultationQuestion={rectificationPendingQuestion}
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)}
/>
</section>
)}
{rectificationSurfaceOpen && (!visibleRectificationTurn && rectificationError ? (
<div className="conversational-rectification-start-error" role="alert">
<p>{rectificationError}</p>
<button type="button" disabled={rectificationLoading} onClick={() => void openBirthTimeRectification(null, activeSession ?? null)}>
{rectificationLoading ? "正在重试…" : "重新建立校正记录"}
</button>
</div>
) : (
<ConversationalBirthTimeRectification
initialTurn={visibleRectificationTurn}
pendingConsultationQuestion={rectificationPendingQuestion}
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)}
/>
))}
</div>
{!rectificationSurfaceOpen && <div className={`composer-wrap ${starterHomeVisible ? "composer-wrap-starter" : ""}`}>
@@ -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<BirthTimeAssessmentPhase, { readonly title: string; readonly detail: string }>;
export function BirthTimeAssessmentOverlay({ phase }: { readonly phase: BirthTimeAssessmentPhase | null }) {
@@ -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<Record<EvidenceDomain, string>>;
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<unknown>) {
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 (
<section className="conversational-candidate" aria-labelledby="conversational-candidate-title">
<header>
<h3 id="conversational-candidate-title">候选时间</h3>
<span className={confirmed ? "is-confirmed" : "is-unverified"}>{status}</span>
</header>
{candidate.representativeTime ? (
<dl>
<div>
<dt>代表时间</dt>
<dd><time dateTime={candidate.representativeTime}>{candidate.representativeTime}</time></dd>
</div>
<div>
<dt>候选范围</dt>
<dd>{candidate.rangeStart && candidate.rangeEnd
? `${candidate.rangeStart}—${candidate.rangeEnd}`
: "尚未缩小"}</dd>
</div>
</dl>
) : <p>尚未形成可供确认的具体时间。</p>}
{!confirmed && <p>候选仍待真实经历验证,未经你的明确确认不会成为当前排盘时间。</p>}
{!confirmed && recordedCount > 0 && turn.status !== "completed" && (
<div className="conversational-candidate-progress" role="status">
<strong>已记录 {recordedCount} 条经历</strong>
<span>系统至少需要 3 条时间明确、可评分的经历后才会可靠缩小范围;范围不变不代表提交失败。</span>
{nextDomains.length > 0 && (
<span>下一步优先补充:{nextDomains.join("或")}领域已经发生的事件。</span>
)}
</div>
)}
</section>
);
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<HTMLTextAreaElement>(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 (
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
@@ -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 (
<section
className="conversational-rectification"
aria-busy={controller.pending}
aria-label="生时校正对话"
>
<CandidateSummary turn={turn} />
<article className="conversational-narrative" aria-label="校正分析">
<div className="conversational-narrative-body">
<ChatMessageContent text={visibleTurnNarrative(turn)} />
</div>
</article>
{requestedDomains.length > 0 && (
<fieldset className="conversational-domain-picker">
<legend className="conversational-domain-question">这轮想先补充哪个领域?</legend>
<div>
{requestedDomains.map((domain) => (
<button
aria-label={`${domainLabels[domain]},${domain === priorityDomain
? "下一步建议"
: providedDomains.has(domain) ? "已提供,可继续补充" : "可补充"}`}
aria-pressed={controller.selectedDomain === domain}
data-evidence-domain={domain}
disabled={controller.pending || !canAnswer}
key={domain}
type="button"
onClick={() => {
controller.selectDomain(domain);
focusComposer();
}}
>
<span>{domainLabels[domain]}</span>
<small>{domain === priorityDomain
? "下一步建议"
: providedDomains.has(domain) ? "已提供,可继续补充" : "可补充"}</small>
</button>
))}
</div>
</fieldset>
)}
{controller.pending && canAnswer && (
<div
aria-atomic="true"
aria-label="正在核对经历"
aria-live="polite"
className="conversational-answer-pending"
role="status"
>
<div className="app-loading-symbol" aria-hidden="true">
<span className="app-loading-orbit" />
<span className="app-loading-mark" />
</div>
<div>
<strong>正在核对这段经历</strong>
<span>正在比较相邻候选分钟;完成后会继续提问或更新候选范围。</span>
</div>
</div>
)}
{canAnswer && <form
className="conversational-composer"
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
{controller.correctionTarget && (
<div className="conversational-correction-target" role="status">
<p>
正在更正:<strong>{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</strong>
</p>
<span>一次只更正一条事件。提交后会保留原记录用于审计,但候选评分只使用更正后的有效证据。</span>
<button
className="button-secondary"
disabled={controller.pending}
type="button"
onClick={() => controller.cancelEvidenceCorrection()}
>
取消更正
</button>
</div>
<section className="rectification-chat" aria-busy={controller.pending} aria-label="生时校正对话">
<div className="message-list rectification-message-list">
<span className="sr-only" aria-live="polite">{controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
{turn.evidenceRecap.map((entry) => (
<ChatMessageRow
key={entry.id}
message={{
role: "user",
text: `${entry.dateLabel} · ${entry.summary}${entry.isCorrection ? "(已修订)" : ""}`,
renderKey: entry.id,
state: "settled",
}}
/>
))}
{controller.pending && controller.draft.trim() && (
<ChatMessageRow
message={{ role: "user", text: controller.draft.trim(), renderKey: "pending-evidence", state: "settled" }}
/>
)}
<label htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "填写更正后的真实经历" : "补充真实经历"}
<span>大概年份也可以;不确定的部分请直接说不确定。</span>
</label>
<div className="conversational-event-date" role="group" aria-label="经历发生时间">
<label>
年份
<select
aria-label="经历发生年份"
disabled={controller.pending || !canAnswer}
value={eventYear}
onChange={(event) => {
setEventYear(event.target.value);
if (!event.target.value) setEventMonth("");
}}
>
<option value="">不确定</option>
{eventYears.map((year) => <option key={year} value={year}>{year} 年</option>)}
</select>
</label>
<label>
月份(可选)
<select
aria-label="经历发生月份"
disabled={controller.pending || !canAnswer || !eventYear}
value={eventMonth}
onChange={(event) => setEventMonth(event.target.value)}
>
<option value="">不确定</option>
{Array.from({ length: 12 }, (_, index) => index + 1).map((month) => (
<option key={month} value={month}>{month} 月</option>
))}
</select>
</label>
</div>
<textarea
id="conversational-rectification-answer"
disabled={controller.pending || !canAnswer}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:其实是 2020 年 11 月离职"
: "请描述一件已经发生的具体事件"}
ref={composer}
rows={4}
value={controller.draft}
onChange={(event) => controller.setDraft(event.target.value)}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
void submit();
}
}}
/>
<div className="conversational-composer-footer">
<small>Ctrl/⌘ + Enter 发送</small>
<button
className="button-primary"
disabled={controller.pending || !canAnswer || !controller.draft.trim()}
type="submit"
>
{controller.pending ? "正在核对…" : "发送这段经历"}
</button>
</div>
</form>}
{turn.evidenceRecap.length > 0 && (
<section className="conversational-evidence-recap" aria-labelledby="conversational-evidence-title">
<header>
<h3 id="conversational-evidence-title">已记录的真实经历</h3>
<span>{turn.evidenceRecap.length} 条</span>
</header>
<ul>
{turn.evidenceRecap.map((entry) => (
<li key={entry.id}>
<div>
<time>{entry.dateLabel}</time>
<p>{entry.summary}</p>
{entry.isCorrection && <span className="conversational-correction-badge">已修订</span>}
</div>
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={controller.pending || !canAnswer}
type="button"
onClick={() => {
controller.beginEvidenceCorrection(entry.id);
focusComposer();
}}
>
更正
</button>
</li>
))}
</ul>
</section>
)}
{turn.actions.includes("confirm")
&& turn.candidate.status === "ready_for_confirmation"
&& turn.candidate.representativeTime && (
<section className="conversational-confirmation" aria-labelledby="conversational-confirmation-title">
<h3 id="conversational-confirmation-title">采用候选前请明确确认</h3>
<p>这一步不会自动采用候选。确认后,原始填报时间仍会保留。</p>
<button
className="button-primary"
disabled={controller.pending}
type="button"
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
>
{controller.pending
? "确认中…"
: `确认将 ${turn.candidate.representativeTime} 设为当前排盘时间`}
</button>
</section>
<article className="message message-assistant" aria-label="Jyotisha">
<AgentAvatar />
<div className="message-content">
<div className="message-bubble">
<ChatMessageContent text={visibleTurnNarrative(turn)} />
<details className="rectification-message-details">
<summary>
{turn.candidate.representativeTime
? `当前候选 ${turn.candidate.representativeTime} · ${candidateStatus(turn)}`
: `校正进度 · ${candidateStatus(turn)}`}
</summary>
<p>
{turn.candidate.rangeStart && turn.candidate.rangeEnd
? `候选范围 ${turn.candidate.rangeStart}—${turn.candidate.rangeEnd};已记录 ${turn.evidenceRecap.length} 条经历。`
: `已记录 ${turn.evidenceRecap.length} 条经历,尚未缩小候选范围。`}
</p>
<p>候选只用于继续验证;这一步不会自动采用候选,未经发布门禁与明确确认不会成为当前排盘时间。</p>
{turn.evidenceRecap.length > 0 && (
<ul aria-label="已记录的真实经历">
{turn.evidenceRecap.map((entry) => (
<li key={entry.id}>
<span>{entry.dateLabel} · {entry.summary}{entry.isCorrection ? "(已修订)" : ""}</span>
{canAnswer && (
<button
aria-label={`更正这条经历:${entry.summary}`}
disabled={controller.pending}
type="button"
onClick={() => {
controller.beginEvidenceCorrection(entry.id);
composer.current?.focus();
}}
>
更正
</button>
)}
</li>
))}
</ul>
)}
</details>
</div>
</div>
</article>
{controller.pending && canAnswer && (
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
)}
<div
aria-live="polite"
className="conversational-status"
>
{turn.status === "paused" && <p>校正已暂停,输入与现有证据都已保留。</p>}
{turn.status === "abandoned" && <p>本次校正已放弃,候选时间没有应用。</p>}
{turn.status === "completed" && turn.candidate.status === "confirmed"
&& <p>候选时间已经过你的明确确认。</p>}
{turn.status === "completed" && turn.candidate.status !== "confirmed"
&& <p>本次校正已结束并保存候选范围;没有把候选代表时间设为当前排盘时间。</p>}
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
{turn.status === "paused" && <ChatMessageRow message={{ role: "assistant", text: "校正已暂停,输入与现有证据都已保留。", renderKey: "rectification-paused", state: "settled" }} />}
{turn.status === "abandoned" && <ChatMessageRow message={{ role: "assistant", text: "本次校正已放弃,候选时间没有应用。", renderKey: "rectification-abandoned", state: "settled" }} />}
{turn.status === "completed" && <ChatMessageRow message={{
role: "assistant",
text: turn.candidate.status === "confirmed"
? "候选时间已经过你的明确确认。"
: "候选范围已保存,代表时间没有自动设为当前排盘时间。",
renderKey: "rectification-completed",
state: "settled",
}} />}
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
</div>
{turn.actions.includes("continue_original_question")
&& pendingQuestion
&& onContinueOriginalQuestion && (
<section className="conversational-original-question">
<p>原问题:{pendingQuestion}</p>
<button
className="button-primary"
disabled={controller.pending || continuationPending}
type="button"
onClick={() => onContinueOriginalQuestion?.(pendingQuestion)}
>
{continuationPending
? "正在继续回答原问题…"
: turn.candidate.status === "confirmed"
? "使用新确认时间继续回答原问题"
: "返回原对话并继续回答"}
</button>
</section>
)}
{(canAnswer || canConfirm || canContinue) && <div className="composer-wrap rectification-composer-wrap">
{(canConfirm || canContinue) && (
<div className="composer-suggestions" aria-label="生时校正操作">
{canConfirm && (
<button
aria-label={`确认将 ${turn.candidate.representativeTime} 设为当前排盘时间;当前分钟尚未验证`}
disabled={controller.pending}
type="button"
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
>
确认采用 {turn.candidate.representativeTime}(尚未验证)
</button>
)}
{canContinue && (
<button
disabled={controller.pending || continuationPending}
type="button"
onClick={() => onContinueOriginalQuestion?.(pendingQuestion!)}
>
{continuationPending ? "正在继续回答…" : "返回原问题"}
</button>
)}
</div>
)}
{canAnswer && <>
{controller.correctionTarget && (
<div className="rectification-correction-target" role="status">
<p>正在更正:{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</p>
<button disabled={controller.pending} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
取消更正
</button>
</div>
)}
<form className="composer" onSubmit={(event) => { event.preventDefault(); void submit(); }}>
<label className="sr-only" htmlFor="conversational-rectification-answer">
{controller.correctionTarget ? "输入更正后的经历" : "回答生时校正问题"}
</label>
<Textarea
id="conversational-rectification-answer"
ref={composer}
autoFocus
disabled={controller.pending}
maxLength={4_000}
placeholder={controller.correctionTarget
? "例如:更正为 2020 年 11 月离职"
: "像聊天一样回答即可,例如:2018 年 6 月去了上海工作"}
rows={2}
value={controller.draft}
onChange={(event) => controller.setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<Button aria-label={controller.pending ? "正在核对" : "发送"} disabled={controller.pending || !controller.draft.trim()} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
</form>
<div className="composer-footer">
<p>{controller.pending ? "正在核对这段经历…" : "Enter 发送 · Shift + Enter 换行"}</p>
</div>
</>}
</div>}
</section>
);
}
@@ -416,9 +281,7 @@ type ConversationalBirthTimeRectificationProps = Readonly<{
onContinueOriginalQuestion?: (question: string) => void;
}>;
export function ConversationalBirthTimeRectification(
props: ConversationalBirthTimeRectificationProps,
) {
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
const pendingChange = useRef(props.onPendingChange);
useEffect(() => {
pendingChange.current = props.onPendingChange;
@@ -9,6 +9,7 @@ const evidenceDomainSchema = z.enum([
"career",
"education",
"finance",
"health_pressure",
"relocation",
"relationship",
"family",
@@ -74,7 +75,7 @@ const technicalReceiptSchema = boundedJson(z.object({
}).strict(), 8_192);
const evidenceRequestSchema = boundedJson(z.object({
domains: z.array(evidenceDomainSchema).min(2).max(4),
domains: z.array(evidenceDomainSchema).min(1).max(4),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
freeTextAllowed: z.literal(true),
}).strict(), 2_048);
@@ -25,15 +25,15 @@ type ParsedDate = {
readonly precision: "day" | "month" | "year";
};
const chineseDatePattern = /(?:19|20)\d{2}\s*年(?:\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*(?:日|号))?)?/g;
const isoDatePattern = /(?:19|20)\d{2}-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?/g;
const chineseDatePattern = /(?:1\d{3}|20\d{2})\s*年(?:\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*(?:日|号))?)?/g;
const isoDatePattern = /(?:1\d{3}|20\d{2})-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?/g;
const unresolvedRelativeTimePattern = /(?:次年|第二年|后来|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)/;
const leadingRelativeTimePattern = /^\s*(?:(?:次年|第二年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/;
const missingEventSummary = "事件内容待补充";
function normalizedDate(value: string): ParsedDate | null {
const chinese = value.match(/^((?:19|20)\d{2})\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/);
const iso = value.match(/^((?:19|20)\d{2})-(\d{2})(?:-(\d{2}))?$/);
const chinese = value.match(/^((?:1\d{3}|20\d{2}))\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/);
const iso = value.match(/^((?:1\d{3}|20\d{2}))-(\d{2})(?:-(\d{2}))?$/);
const match = chinese ?? iso;
if (!match) return null;
const year = Number(match[1]);
@@ -81,7 +81,8 @@ function eventSummary(fragment: string): string {
}
function classifyDomain(summary: string): RectificationEvidenceDomain {
if (/毕业|入学|升学|转学|学校|专业|考试|留学|学业/.test(summary)) return "education";
if (/确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|去世|离世|死亡|丧亲|健康/.test(summary)) return "health_pressure";
if (/毕业|入学|升学|转学|学校|大学|专业|考试|留学|学业|学习/.test(summary)) return "education";
if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) return "relocation";
if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) return "relationship";
if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) return "family";
@@ -49,6 +49,7 @@ const domainLabels = {
career: "事业",
education: "学业",
finance: "财富",
health_pressure: "健康与重大压力",
relocation: "迁居",
relationship: "关系",
family: "家庭",
@@ -56,7 +57,7 @@ const domainLabels = {
} as const;
function importedDomain(domain: LifeEvent["domain"]): LifeEventEvidence["domain"] {
return domain === "health_pressure" ? "other" : domain;
return domain;
}
function eventIsWithinHistoricalWindow(
@@ -10,27 +10,16 @@ export type RectificationNarrativePhase = "first" | "intermediate" | "final";
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", "relocation", "relationship", "family", "other"]);
const stableSemanticsPattern = /(?:稳定|保持|不变|一致|stable|unchanged)/i;
const sensitiveSemanticsPattern = /(?:敏感|变化|差异|切换|不同|sensitive|changes?|differs?)/i;
const discriminationSemanticsPattern = /(?:区分|辨别|判别|验证|差异|变化|discriminat|distinguish)/i;
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 domainSemantics = {
career: /(?:事业|工作|职业|career)/i,
education: /(?:教育|学业|学校|education)/i,
finance: /(?:财富|财务|收入|投资|finance)/i,
relocation: /(?:搬迁|搬家|迁居|异地|居住|relocation)/i,
relationship: /(?:关系|婚恋|伴侣|relationship)/i,
family: /(?:家庭|家人|父母|孩子|family)/i,
other: /(?:其他|其它|other)/i,
} as const satisfies Readonly<Record<RectificationEvidenceDomain, RegExp>>;
const domainLabels = {
career: "事业",
education: "学业",
finance: "财富",
health_pressure: "健康与重大压力",
relocation: "迁居",
relationship: "关系",
family: "家庭",
@@ -52,7 +41,7 @@ const narrativeOutputSchema = z.object({
reason: z.string().trim().min(8).max(1_000),
}).strict()).max(6),
evidenceRequest: z.object({
domains: z.array(domainSchema).min(2).max(4),
domains: z.array(domainSchema).min(1).max(4),
datePrecision: z.enum(["month_preferred", "year_accepted"]),
prompt: z.string().trim().min(1).max(1_000),
}).strict().nullable(),
@@ -90,12 +79,6 @@ function unique(values: readonly string[]): string[] {
return [...new Set(values)];
}
function sameMembers(actual: readonly string[], expected: readonly string[]): boolean {
const left = [...new Set(actual)].sort();
const right = [...new Set(expected)].sort();
return left.length === right.length && left.every((value, index) => value === right[index]);
}
function parseModelOutput(text: string): RectificationNarrativeModelOutput {
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
const start = normalized.indexOf("{");
@@ -148,27 +131,6 @@ function pairKey(value: { readonly domain: RectificationEvidenceDomain; readonly
return `${value.domain}\0${value.layer}`;
}
function narrativeHasDomainDiscrimination(
narrative: string,
domain: RectificationEvidenceDomain,
layer: string,
): boolean {
return narrative.split(/[。!?!?;;\n]/).some((clause) => domainSemantics[domain].test(clause)
&& clause.includes(layer)
&& discriminationSemanticsPattern.test(clause));
}
function narrativeHasLayerEvidence(
narrative: string,
item: RectificationTechnicalPacket["stableLayers"][number],
semantics: RegExp,
requiredValueCount: number,
): boolean {
return narrative.split(/[。!?!?;;\n]/).some((clause) => clause.includes(item.layer)
&& item.values.filter((value) => clause.includes(value)).length >= requiredValueCount
&& semantics.test(clause));
}
export function validateNarrativeAgainstPacket(
output: RectificationNarrativeModelOutput,
packet: RectificationTechnicalPacket,
@@ -195,12 +157,6 @@ export function validateNarrativeAgainstPacket(
for (const layer of output.sensitiveLayers) {
if (!allowedSensitive.includes(layer)) issues.push(`sensitive layer ${layer} is not packet-grounded`);
}
if (phase === "first" && !sameMembers(output.stableLayers, allowedStable)) {
issues.push("first turn must carry every stable layer");
}
if (phase === "first" && !sameMembers(output.sensitiveLayers, allowedSensitive)) {
issues.push("first turn must carry every sensitive layer");
}
for (const reference of output.referenceIds) {
if (!packet.referenceIds.includes(reference)) issues.push(`reference ${reference} is not packet-grounded`);
@@ -215,23 +171,10 @@ export function validateNarrativeAgainstPacket(
issues.push(`domainReasons[${index}].reason must use the packet discrimination explanation`);
}
}
if (phase === "first") {
const expectedPairs = packet.suggestedDomains.map(pairKey);
const actualPairs = output.domainReasons.map(pairKey);
if (expectedPairs.length < 2 || !sameMembers(actualPairs, expectedPairs)) {
issues.push("first turn must carry at least two unique packet discrimination pairs");
}
}
if (output.evidenceRequest) {
for (const domain of output.evidenceRequest.domains) {
if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`);
}
if (phase === "first" && !sameMembers(
output.evidenceRequest.domains,
packet.suggestedDomains.map((item) => item.domain),
)) {
issues.push("first evidence request must match the packet discrimination domains");
}
if (!/(?:已经发生|已发生|过去)/.test(output.evidenceRequest.prompt)
|| !/年/.test(output.evidenceRequest.prompt)
|| !/月/.test(output.evidenceRequest.prompt)) {
@@ -260,37 +203,21 @@ export function validateNarrativeAgainstPacket(
issues.push(`${field.path} is a forbidden generic broad-year choice questionnaire`);
}
}
if (phase === "first") {
if (!output.narrative.includes(candidate.representativeTime)
|| !/(?:待验证|候选)/.test(output.narrative)) {
issues.push("first narrative must state the pending candidate time");
if (phase !== "final") {
if (!output.narrative.includes(candidate.range.startTime)
|| !output.narrative.includes(candidate.range.endTime)
|| !/(?:待验证|候选|核对)/.test(output.narrative)) {
issues.push("first narrative must state the pending candidate range");
}
for (const item of packet.stableLayers) {
if (!narrativeHasLayerEvidence(output.narrative, item, stableSemanticsPattern, 1)) {
issues.push(`first narrative lacks stable evidence semantics for ${item.layer}`);
}
}
for (const item of packet.sensitiveLayers) {
if (!narrativeHasLayerEvidence(
output.narrative,
item,
sensitiveSemanticsPattern,
Math.min(2, item.values.length),
)) {
issues.push(`first narrative lacks sensitive evidence semantics for ${item.layer}`);
}
}
for (const item of packet.suggestedDomains) {
if (!narrativeHasDomainDiscrimination(output.narrative, item.domain, item.layer)) {
issues.push(`first narrative must explain how ${item.domain}/${item.layer} discriminates`);
}
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)) {
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)) {
if (!/(?:不是[\s\S]*确认|不能[\s\S]*(?:确定|确认)|仅[\s\S]*候选|必须[\s\S]*确认)/.test(output.narrative)) {
issues.push("first narrative must state the candidate use boundary");
}
}
@@ -333,7 +260,8 @@ function promptFor(
candidateFactsMustMatch: true,
onlyListedLayersAndReferences: true,
everyAuthoredStringMustBeGrounded: true,
includeStableAndSensitiveLayerValues: phase === "first",
keepTechnicalLayerValuesOutOfVisibleNarrative: phase !== "final",
askExactlyOneHighInformationQuestion: phase !== "final",
usePacketDomainReasonTextExactly: true,
requestRealPastEventsByYearAndMonth: phase !== "final",
futureWindowsAreContextOnly: true,
@@ -345,24 +273,14 @@ function promptFor(
function fallbackNarrative(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase): string {
const candidate = packet.candidate;
const stable = packet.stableLayers
.map((item) => `${item.layer}(${item.values.join(" / ")})保持稳定`)
.join(";");
const sensitive = packet.sensitiveLayers
.map((item) => `${item.layer}(${item.values.join(" / ")})呈现分钟敏感差异`)
.join(";");
const reasons = packet.suggestedDomains
.map((item) => `${domainLabels[item.domain]}事件可区分 ${item.layer}`)
.join(";");
const nextDomain = packet.suggestedDomains[0]?.domain;
const nextLabel = nextDomain ? domainLabels[nextDomain] : "重要经历";
const phaseLine = phase === "final"
? "当前证据已形成候选总结,但仍有残余不确定性;只有明确确认后才会替换当前排盘时间。"
: `下一步请提供上述领域已经发生的真实事件,尽量写明哪一年、哪一月以及发生了什么;${reasons}。`;
: `先说一件已经发生的${nextLabel}事件好吗?尽量写明哪一年、哪一月以及发生了什么。`;
return [
`${candidate.representativeTime} 是 ${candidate.range.startTime}–${candidate.range.endTime} 范围内的待验证候选。`,
packet.useBoundary,
`${stable || "D1 稳定性暂不可用"};${sensitive} 是当前支持的分钟敏感层。`,
`当前仍在核对 ${candidate.range.startTime}–${candidate.range.endTime} 的候选范围,还不能把其中某一分钟当作确定出生时间。`,
phaseLine,
"未来窗口只能作为背景,不能计入既成事件评分。",
].join("\n");
}
@@ -382,7 +300,7 @@ function fallbackOutput(
referenceIds: [],
domainReasons: packet.suggestedDomains.map((item) => ({ ...item })),
evidenceRequest: phase === "final" ? null : {
domains: packet.suggestedDomains.slice(0, 4).map((item) => item.domain),
domains: packet.suggestedDomains.slice(0, 1).map((item) => item.domain),
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实事件,并尽量写明哪一年、哪一月以及发生了什么。",
},
@@ -255,6 +255,7 @@ const progressDomainLabels = {
career: "事业",
education: "学业",
finance: "财务",
health_pressure: "健康与重大压力",
relocation: "搬迁",
relationship: "重要关系",
family: "家庭",
@@ -122,6 +122,7 @@ const evidenceDomainSchema = z.enum([
"career",
"education",
"finance",
"health_pressure",
"relocation",
"relationship",
"family",
@@ -6,6 +6,7 @@ export type RectificationEvidenceDomain =
| "career"
| "education"
| "finance"
| "health_pressure"
| "relocation"
| "relationship"
| "family"
@@ -120,6 +121,7 @@ const domainByLayer = {
D11: "finance",
D24: "education",
D4: "relocation",
D30: "health_pressure",
A7: "relationship",
UL: "relationship",
A10: "career",
@@ -129,6 +131,7 @@ const domainLabels = {
career: "事业",
education: "教育",
finance: "财富",
health_pressure: "健康与重大压力",
relocation: "迁居",
relationship: "关系",
family: "家庭",
@@ -211,7 +214,7 @@ function candidateWeights(model: Readonly<Record<string, unknown>>): Readonly<Re
}
function eventDomain(domain: CandidateResult["evidence"][number]["domain"]): RectificationEvidenceDomain {
return domain === "health_pressure" ? "other" : domain;
return domain;
}
function layerEvidence(
@@ -0,0 +1,49 @@
begin;
-- The language-first evidence extractor and question planner use
-- health_pressure as a first-class rectification domain. The durable SQL
-- contract still rejected that domain in a public evidence request, so the
-- first scored answer could finish all calculation/model work and then fail
-- at save_conversational_rectification_turn with action_conflict.
do $migration$
declare
v_signature text;
v_definition text;
v_updated_definition text;
v_old_domains constant text := '''finance'', ''relocation''';
v_new_domains constant text := '''finance'', ''health_pressure'', ''relocation''';
begin
foreach v_signature in array array[
'public.conversational_rectification_valid_evidence_request(jsonb)',
'public.conversational_rectification_valid_evidence_recap(jsonb)',
'public.conversational_rectification_valid_life_event_evidence(jsonb)',
'public.conversational_rectification_valid_private_candidate(jsonb)'
] loop
select pg_catalog.pg_get_functiondef(v_signature::regprocedure)
into v_definition;
v_updated_definition := pg_catalog.replace(
v_definition,
v_old_domains,
v_new_domains
);
if v_updated_definition is not distinct from v_definition then
raise exception 'health pressure domain migration could not update %', v_signature;
end if;
execute v_updated_definition;
end loop;
end;
$migration$;
alter table public.birth_time_rectification_event_evidence
drop constraint if exists birth_time_rectification_event_evidence_domain_check;
alter table public.birth_time_rectification_event_evidence
add constraint birth_time_rectification_event_evidence_domain_check
check (
domain in (
'career', 'education', 'finance', 'health_pressure', 'relocation',
'relationship', 'family', 'other'
)
);
commit;
@@ -0,0 +1,28 @@
begin;
-- Language-first rectification asks one focused domain at a time. The
-- application contract already permits that, while durable SQL still required
-- at least two domains and rejected otherwise-valid turns as action_conflict.
do $migration$
declare
v_signature constant text :=
'public.conversational_rectification_valid_evidence_request(jsonb)';
v_definition text;
v_updated_definition text;
begin
select pg_catalog.pg_get_functiondef(v_signature::regprocedure)
into v_definition;
v_updated_definition := pg_catalog.replace(
v_definition,
'between 2 and 4',
'between 1 and 4'
);
if v_updated_definition is not distinct from v_definition then
raise exception 'evidence request cardinality migration could not update %',
v_signature;
end if;
execute v_updated_definition;
end;
$migration$;
commit;
@@ -18,4 +18,7 @@ test("onboarding keeps the current card visible while birth-time assessment is p
assert.match(source, /className="birth-time-assessment-overlay"/);
assert.match(source, /aria-busy=\{birthTimeAssessmentPhase !== null\}/);
assert.match(source, /previewMode === "birth-time-assessment-loading"/);
assert.match(source, /saveOnboardingBirth[\s\S]*?setBirthTimeAssessmentPhase\("saving_profile"\)/);
assert.match(source, /saveOnboardingPlace[\s\S]*?setBirthTimeAssessmentPhase\("entering_home"\)/);
assert.match(source, /entering_home:[\s\S]*?title: "正在进入首页"[\s\S]*?detail: "出生资料已保存,正在为你准备首页。"/);
});
+10 -8
View File
@@ -100,22 +100,24 @@ test("homepage birth-time card starts the first rectification turn without a sec
assert.match(handler, /sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"/);
});
test("homepage birth-time card waits for the first turn before opening its dedicated session", () => {
test("homepage birth-time card opens its dedicated session before the first turn resolves", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
const start = source.indexOf("async function openBirthTimeRectification");
const end = source.indexOf("function handleConversationalRectificationTurn", start);
const handler = source.slice(start, end);
const create = handler.indexOf('createSession(modelCatalog.defaultModelId, "birth_time_rectification")');
const request = handler.indexOf("sendConversationalRectificationCommand");
const reveal = handler.indexOf("setActiveSessionId(boundSession.id)");
const firstAwait = handler.indexOf("await ");
const reveal = handler.indexOf("setActiveSessionId(rectificationSession.id)");
assert.ok(create >= 0);
assert.ok(request > create);
assert.ok(reveal > request);
assert.doesNotMatch(handler.slice(0, request), /setActiveSessionId\(/);
assert.doesNotMatch(handler.slice(0, request), /setSessions\(/);
assert.ok(firstAwait > create);
assert.ok(reveal > create && reveal < firstAwait);
assert.ok(handler.indexOf("setSessions((current) => [") < firstAwait);
assert.match(handler, /rectificationOpenInFlight\.current/);
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
assert.match(handler, /setRectificationInitialTurn\(turn\);[\s\S]*?setActiveSessionId\(boundSession\.id\)/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*?<ConversationalBirthTimeRectification[\s\S]*?initialTurn=\{visibleRectificationTurn\}/);
});
test("rectification cards render only inside the active rectification session", () => {
@@ -72,6 +72,42 @@ test("never invents a missing month or day", () => {
assert.equal(evidence?.eventSummary, "毕业");
});
test("accepts nineteenth-century Chinese and ISO dates as scoreable historical evidence", () => {
const [education] = extractLifeEventEvidence({
rawText: "1891年11月进入巴黎大学学习",
sourceTurnId,
asOfDate: "2026-07-20",
});
const [relationship] = extractLifeEventEvidence({
rawText: "1895-07-26结婚",
sourceTurnId,
asOfDate: "2026-07-20",
});
assert.deepEqual(
[education?.dateValue, education?.domain, education?.scoreable],
["1891-11", "education", true],
);
assert.deepEqual(
[relationship?.dateValue, relationship?.domain, relationship?.scoreable],
["1895-07-26", "relationship", true],
);
});
for (const rawText of [
"2003年确诊癌症并接受手术",
"2006年丈夫因交通事故去世",
]) {
test(`classifies dated health, accident, and bereavement evidence for D30 scoring: ${rawText}`, () => {
const evidence = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" });
assert.ok(evidence.length > 0);
assert.ok(evidence.every((item) => item.domain === "health_pressure"));
assert.ok(evidence.every((item) => item.scoreable));
assert.ok(evidence.every((item) => lifeEventEvidenceSchema.safeParse(item).success));
});
}
test("classifies dated income and asset changes as finance evidence", () => {
const [evidence] = extractLifeEventEvidence({
rawText: "2022年8月收入大幅增加并开始投资",
@@ -60,10 +60,8 @@ function richOutput(): RectificationNarrativeModelOutput {
const packet = syntheticTechnicalPacket();
return {
narrative: [
"05:20 是 05:16–05:24 范围内的待验证候选,不是已经确认的出生分钟。",
"D1 上升在范围内保持 Cancer,属于稳定层【consult-d1-ascendant】;D9 与 D10 分别出现 Leo/Virgo、Libra/Scorpio 的分钟敏感变化。",
"因此关系事件可区分 D9,事业事件可区分 D10。请提供已经发生的真实事件,尽量写明哪一年、哪一月以及发生了什么。",
"未来窗口只能作为背景,不能计入既成事件评分。",
"当前仍在核对 05:16–05:24 的候选范围,还不能把其中某一分钟当作确定出生时间。",
"先说一件已经发生的重要关系经历好吗?尽量写明哪一年、哪一月以及发生了什么。",
].join("\n"),
candidateStatus: "pending_validation",
representativeTime: "05:20",
@@ -78,7 +76,7 @@ function richOutput(): RectificationNarrativeModelOutput {
{ domain: "career", layer: "D10", reason: packet.suggestedDomains[1]?.reason ?? "" },
],
evidenceRequest: {
domains: ["relationship", "career"],
domains: ["relationship"],
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实关系或事业事件,并尽量说明哪一年、哪一月以及发生了什么。",
},
@@ -114,10 +112,9 @@ test("validates a rich first-turn narrative against the technical packet", async
assert.equal(result.allowEvidenceScoringAdvance, true);
assert.equal(validationReceiptSchema.safeParse(result.validationReceipt).success, true);
assert.equal(result.output.candidateStatus, "pending_validation");
assert.match(result.narrative, /待验证候选/);
assert.match(result.narrative, /D1/);
assert.match(result.narrative, /D9[\s\S]*D10/);
assert.match(result.narrative, /关系[\s\S]*D9[\s\S]*事业[\s\S]*D10/);
assert.match(result.narrative, /05:16–05:24[\s\S]*不能/);
assert.doesNotMatch(result.narrative, /\bD\d+\b/);
assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1);
assert.match(result.narrative, /已经发生[\s\S]*哪一年[\s\S]*哪一月/);
assert.doesNotMatch(result.narrative, /^哪一个时间段[\s\S]*\d{4}[–—-]\d{4}/);
});
@@ -138,24 +135,18 @@ test("rejects invented representative times, layers, and references", () => {
assert.ok(result.issues.some((issue) => issue.includes("invented-reference")));
});
test("rejects a first turn that names layer tokens without their stable and sensitive values", () => {
test("accepts a concise first turn without exposing technical layer values", () => {
const packet = syntheticTechnicalPacket();
const invalid = {
...richOutput(),
narrative: [
"05:20 是 05:16–05:24 范围内的待验证候选,不能视为已经确认的出生分钟。",
"D1 是稳定层,D9 和 D10 是分钟敏感层。",
"关系事件可区分 D9,事业事件可区分 D10。请提供已经发生的真实事件,写明哪一年、哪一月。",
].join("\n"),
narrative: "当前仍在核对 05:16–05:24 的候选范围,不能视为已经确认的出生分钟。先说一件过去的重要关系经历好吗?请写明哪一年、哪一月。",
} satisfies RectificationNarrativeModelOutput;
const result = validateNarrativeAgainstPacket(invalid, packet, "first");
assert.equal(result.valid, false);
assert.ok(result.issues.some((issue) => issue.includes("stable evidence semantics")));
assert.ok(result.issues.some((issue) => issue.includes("sensitive evidence semantics")));
assert.deepEqual(result, { valid: true, issues: [] });
});
test("rejects duplicated generic domain reasons that omit a packet discrimination pair", () => {
test("rejects duplicated generic domain reasons that are not packet-grounded", () => {
const packet = syntheticTechnicalPacket();
const relationshipReason = richOutput().domainReasons[0];
assert.ok(relationshipReason);
@@ -169,7 +160,7 @@ test("rejects duplicated generic domain reasons that omit a packet discriminatio
const result = validateNarrativeAgainstPacket(invalid, packet, "first");
assert.equal(result.valid, false);
assert.ok(result.issues.some((issue) => issue.includes("packet discrimination pairs")));
assert.ok(result.issues.some((issue) => issue.includes("packet discrimination explanation")));
});
test("rejects a generic broad-year choice questionnaire and uses a safe scoring-compatible fallback", async () => {
@@ -320,7 +311,7 @@ test("retries expression exactly once with the same grounded packet", async () =
assert.equal(prompts.some((prompt) => prompt.includes("private-partition")), false);
});
test("uses a deterministic rich Chinese fallback after the second mismatch without blocking scoring", async () => {
test("uses a deterministic one-question conversational fallback after the second mismatch", async () => {
const packet = syntheticTechnicalPacket();
const invalid = { ...richOutput(), sensitiveLayers: ["D60"] };
const first = await generateRectificationNarrative({
@@ -338,11 +329,11 @@ test("uses a deterministic rich Chinese fallback after the second mismatch witho
assert.equal(first.fallbackUsed, true);
assert.equal(first.allowEvidenceScoringAdvance, true);
assert.equal(first.narrative, second.narrative);
assert.match(first.narrative, /05:20[\s\S]*待验证/);
assert.match(first.narrative, /D1[\s\S]*稳定/);
assert.match(first.narrative, /D9[\s\S]*D10[\s\S]*敏感/);
assert.match(first.narrative, /05:16–05:24[\s\S]*不能/);
assert.doesNotMatch(first.narrative, /\bD\d+\b/);
assert.match(first.narrative, /已经发生[\s\S]*年[\s\S]*月/);
assert.match(first.narrative, /未来[\s\S]*不能[\s\S]*评分/);
assert.equal((first.narrative.match(/[??]/g) ?? []).length, 1);
assert.deepEqual(first.output.evidenceRequest?.domains, ["relationship"]);
});
test("bounds fallback validation issues for the durable receipt", async () => {
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { resolve } from "node:path";
import { extractLifeEventEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts";
type ReplayManifest = {
readonly cases: readonly {
readonly case_id: string;
readonly disclosure_order: readonly {
readonly event_id: string;
readonly user_utterance: string;
readonly expected_extraction: {
readonly date_value: string | null;
readonly date_precision: "day" | "month" | "year" | "unknown";
readonly domain: string;
readonly scoreable: boolean;
};
}[];
}[];
};
const manifestPath = resolve(
process.cwd(),
"../references/real_case_calibration/conversational_rectification_development_v1.json",
);
const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as ReplayManifest;
for (const replayCase of manifest.cases) {
for (const [index, disclosure] of replayCase.disclosure_order.entries()) {
test(`extracts public replay utterance ${replayCase.case_id}/${disclosure.event_id}`, () => {
const evidence = extractLifeEventEvidence({
rawText: disclosure.user_utterance,
sourceTurnId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}`,
asOfDate: "2026-07-22",
});
assert.equal(evidence.length, 1);
assert.equal(evidence[0]?.dateValue, disclosure.expected_extraction.date_value);
assert.equal(evidence[0]?.datePrecision, disclosure.expected_extraction.date_precision);
assert.equal(evidence[0]?.domain, disclosure.expected_extraction.domain);
assert.equal(evidence[0]?.scoreable, disclosure.expected_extraction.scoreable);
});
}
}
@@ -85,28 +85,21 @@ function controller(overrides: Partial<ConversationalRectificationController> =
};
}
test("rich narrative precedes 2–4 domain choices while free text remains available", () => {
test("rectification is a language-first exchange with one free-text answer path", () => {
const markup = renderToStaticMarkup(React.createElement(
ConversationalRectificationSurface,
{ controller: controller() },
));
assert.match(markup, /<h2>当前判断<\/h2>/);
assert.match(markup, /<strong>05:18<\/strong>/);
assert.ok(markup.indexOf("候选时间") < markup.indexOf("当前判断"));
assert.ok(markup.indexOf("当前判断") < markup.indexOf('data-evidence-domain="relationship"'));
assert.equal((markup.match(/data-evidence-domain=/g) ?? []).length, 3);
assert.match(markup, /aria-label="重要关系,下一步建议"/);
assert.match(markup, /aria-label="事业与身份,已提供,可继续补充"/);
assert.ok(
markup.indexOf('data-evidence-domain="relationship"')
< markup.indexOf('data-evidence-domain="career"'),
);
assert.match(markup, /已记录:2021 年 7 月 · 开始第一份长期工作/);
assert.match(markup, /目前已经形成一个待确认候选/);
assert.ok(markup.indexOf("待确认候选") < markup.indexOf("当前候选 05:18"));
assert.doesNotMatch(markup, /D9 与 D10|当前判断/);
assert.match(markup, /<textarea[^>]+id="conversational-rectification-answer"/);
assert.match(markup, /aria-label="经历发生年份"/);
assert.match(markup, /aria-label="经历发生月份"/);
assert.match(markup, /<option value=""[^>]*>不确定<\/option>/);
assert.match(markup, /Ctrl\/⌘ \+ Enter/);
assert.match(markup, /像聊天一样回答即可/);
assert.match(markup, /2018 年 6 月去了上海工作/);
assert.equal((markup.match(/<textarea/g) ?? []).length, 1);
assert.doesNotMatch(markup, /data-evidence-domain=|<select|<fieldset/);
assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/);
});
@@ -125,7 +118,7 @@ test("a resumed legacy turn replaces repeated technical prose with actionable gu
assert.match(markup, /已记录:2021 年 7 月 · 开始第一份长期工作/);
assert.match(markup, /范围暂未变化不代表提交失败/);
assert.match(markup, /下一步:请优先补充一件重要关系或搬迁与居住地领域/);
assert.match(markup, /接下来请说一件重要关系或搬迁与居住地方面已经发生的事/);
assert.doesNotMatch(markup, /D1 保持稳定/);
});
@@ -162,12 +155,13 @@ test("evidence is correctable, secondary controls stay hidden, and confirmation
assert.match(markup, /更正这条经历:开始第一份长期工作/);
assert.doesNotMatch(markup, /本轮分析|等待经历验证/);
assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/);
assert.match(markup, /待确认 · 未验证/);
assert.match(markup, /确认将 05:18 设为当前排盘时间/);
assert.match(markup, /当前候选 05:18/);
assert.match(markup, /待确认,尚未验证/);
assert.match(markup, /确认采用 05:18(尚未验证)/);
assert.match(markup, /aria-label="确认将 05:18 设为当前排盘时间;当前分钟尚未验证"/);
assert.match(markup, /已记录 1 条经历/);
assert.match(markup, /至少需要 3 条/);
assert.match(markup, /下一步优先补充/);
assert.match(markup, /不会自动采用/);
assert.match(markup, /候选只用于继续验证/);
assert.match(markup, /这一步不会自动采用候选/);
assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/);
});
@@ -196,9 +190,8 @@ test("correction mode identifies its durable target, can be cancelled, and marks
assert.match(markup, /正在更正/);
assert.match(markup, /开始第一份长期工作/);
assert.match(markup, /一次只更正一条事件/);
assert.match(markup, /取消更正/);
assert.match(markup, /已修订/);
assert.match(markup, /(已修订)/);
});
test("pending markup and responsive CSS expose accessibility contracts", () => {
@@ -223,28 +216,33 @@ test("pending markup and responsive CSS expose accessibility contracts", () => {
new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url),
"utf8",
);
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(markup, /aria-busy="true"/);
assert.match(markup, /aria-label="正在核对经历"/);
assert.match(markup, /Jyotisha 正在核对经历/);
assert.match(markup, /正在核对这段经历/);
assert.match(markup, /app-loading-orbit/);
assert.match(markup, /Jyotisha 正在分析/);
assert.match(markup, /<textarea[^>]+disabled=""[^>]*>保留中的文字<\/textarea>/);
assert.match(markup, /aria-label="生时校正对话"/);
assert.match(markup, /role="alert"|aria-live="polite"/);
assert.match(css, /\.conversational-rectification[\s\S]*min-width:\s*0/);
assert.match(css, /\.conversational-rectification[^}]*overflow-wrap:\s*anywhere/);
assert.match(css, /\.conversational-rectification button[^}]*min-height:\s*44px/);
assert.match(css, /\.conversational-rectification[^}]*:focus-visible/);
assert.match(css, /:where\(button, textarea\):focus-visible/);
assert.match(css, /\.conversational-answer-pending[\s\S]*grid-template-columns:\s*40px minmax\(0, 1fr\)/);
assert.match(css, /\.conversational-domain-picker button\[aria-pressed="true"\]/);
assert.match(css, /\.conversation\.is-rectification[^}]*padding-bottom:\s*0/);
assert.match(css, /\.rectification-chat[^}]*height:\s*100%[^}]*display:\s*flex/);
assert.match(css, /\.rectification-message-list[^}]*flex:\s*1[^}]*overflow-y:\s*auto/);
assert.match(css, /\.rectification-message-details button[^}]*min-height:\s*44px/);
assert.match(css, /\.composer:focus-within[^}]*border-color:/);
assert.match(css, /\.composer textarea[^}]*border:\s*0/);
assert.match(css, /\.rectification-composer-wrap[^}]*position:\s*static/);
assert.match(page, /rectificationSurfaceOpen \? "is-rectification"/);
assert.doesNotMatch(css, /\.conversational-domain-picker|\.conversational-event-date/);
assert.doesNotMatch(css, /button\[aria-label\$="下一步建议"\]/);
assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/);
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.conversational-rectification/);
assert.match(css, /@media\s*\(max-width:\s*430px\)[\s\S]*\.rectification-message-details/);
assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/);
assert.match(component, /controller\.answer\(undefined, controller\.draft\.trim\(\)\)/);
assert.match(component, /event\.key === "Enter" && !event\.shiftKey/);
assert.match(component, /onPendingChange/);
assert.match(component, /onPendingChange:\s*props\.onPendingChange/);
assert.match(component, /&& onContinueOriginalQuestion &&/);
assert.match(component, /onContinueOriginalQuestion\?\./);
});
type CdpResponse = Readonly<{
@@ -727,7 +725,7 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')");
await waitFor(
() => cdp?.evaluate<boolean>(`document.querySelector('.conversational-candidate time')?.textContent === '05:18'
() => cdp?.evaluate<boolean>(`document.body.textContent.includes('当前候选 05:18')
&& document.body.textContent.includes('已记录:2021-07')`) ?? Promise.resolve(false),
"streamlined async initial turn",
);
@@ -737,25 +735,28 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
scrollWidth: number;
surfaceWidth: number;
shortestButton: number;
dateFieldsShareRow: boolean;
selectCount: number;
domainChoiceCount: number;
}>(`(() => {
const buttons = [...document.querySelectorAll('.conversational-rectification button')];
const year = document.querySelector('[aria-label="经历发生年份"]').getBoundingClientRect();
const month = document.querySelector('[aria-label="经历发生月份"]').getBoundingClientRect();
const buttons = [...document.querySelectorAll('.rectification-chat button')]
.filter((button) => button.getBoundingClientRect().height > 0);
return {
viewport: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
surfaceWidth: document.querySelector('.conversational-rectification').getBoundingClientRect().width,
surfaceWidth: document.querySelector('.rectification-chat').getBoundingClientRect().width,
shortestButton: Math.min(...buttons.map((button) => button.getBoundingClientRect().height)),
dateFieldsShareRow: Math.abs(year.top - month.top) < 2,
selectCount: document.querySelectorAll('.rectification-chat select').length,
domainChoiceCount: document.querySelectorAll('[data-evidence-domain]').length,
};
})()`);
assert.equal(layout.viewport, 390);
assert.ok(layout.scrollWidth <= 390, `page overflowed: ${layout.scrollWidth}px`);
assert.ok(layout.surfaceWidth <= 366, `surface overflowed padded viewport: ${layout.surfaceWidth}px`);
assert.ok(layout.shortestButton >= 44, `shortest button was ${layout.shortestButton}px`);
assert.equal(layout.dateFieldsShareRow, true, "year and month should stay on one row at 390px");
assert.equal(layout.selectCount, 0, "language-first flow should not render date selects");
assert.equal(layout.domainChoiceCount, 0, "language-first flow should not render domain buttons");
await cdp.evaluate("document.querySelector('.rectification-message-details').open = true");
await cdp.evaluate("document.querySelector('[aria-label^=\"更正这条经历\"]').click()");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
@@ -772,17 +773,12 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro
"correction cancellation",
);
await cdp.evaluate("document.querySelector('[data-evidence-domain=career]').click()");
await waitFor(
() => cdp?.evaluate<boolean>("document.activeElement?.id === 'conversational-rectification-answer'") ?? Promise.resolve(false),
"domain-to-composer focus",
);
await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA3')");
await waitFor(
() => cdp?.evaluate<boolean>(`(() => {
const text = document.body.textContent;
return text.includes('候选时间')
return text.includes('当前候选')
&& !text.includes('候选时间')
&& !text.includes('本轮技术回执')
&& !text.includes('暂停,稍后继续')
&& !text.includes('放弃本次校正')
@@ -2,13 +2,27 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const migration = readFileSync(
const financeMigration = readFileSync(
new URL(
"../supabase/migrations/20260721150000_align_conversational_finance_domain.sql",
import.meta.url,
),
"utf8",
);
const healthPressureMigration = readFileSync(
new URL(
"../supabase/migrations/20260722170000_align_conversational_health_pressure_domain.sql",
import.meta.url,
),
"utf8",
);
const requestCardinalityMigration = readFileSync(
new URL(
"../supabase/migrations/20260722180000_align_conversational_evidence_request_cardinality.sql",
import.meta.url,
),
"utf8",
);
test("durable rectification SQL accepts every application evidence domain", () => {
for (const validator of [
@@ -16,17 +30,46 @@ test("durable rectification SQL accepts every application evidence domain", () =
"conversational_rectification_valid_life_event_evidence(jsonb)",
"conversational_rectification_valid_private_candidate(jsonb)",
]) {
assert.match(migration, new RegExp(validator.replace(/[()]/g, "\\$&")));
assert.match(financeMigration, new RegExp(validator.replace(/[()]/g, "\\$&")));
}
assert.match(migration, /'education', 'finance', 'relocation'/);
assert.match(migration, /birth_time_rectification_event_evidence_domain_check/);
assert.match(financeMigration, /'education', 'finance', 'relocation'/);
assert.match(financeMigration, /birth_time_rectification_event_evidence_domain_check/);
});
test("durable public recap accepts and validates its optional domain", () => {
assert.match(
migration,
financeMigration,
/array\['id', 'summary', 'dateLabel', 'domain', 'isCorrection'\]/,
);
assert.match(migration, /item \? 'domain'/);
assert.match(migration, /item ->> 'domain' not in/);
assert.match(financeMigration, /item \? 'domain'/);
assert.match(financeMigration, /item ->> 'domain' not in/);
});
test("durable public turns accept health pressure follow-up requests", () => {
for (const validator of [
"conversational_rectification_valid_evidence_request(jsonb)",
"conversational_rectification_valid_evidence_recap(jsonb)",
"conversational_rectification_valid_life_event_evidence(jsonb)",
"conversational_rectification_valid_private_candidate(jsonb)",
]) {
assert.match(
healthPressureMigration,
new RegExp(validator.replace(/[()]/g, "\\$&")),
);
}
assert.match(healthPressureMigration, /'finance'', ''health_pressure'', ''relocation'/);
assert.match(
healthPressureMigration,
/'finance', 'health_pressure', 'relocation'/,
);
assert.match(healthPressureMigration, /birth_time_rectification_event_evidence_domain_check/);
});
test("durable evidence requests allow one focused follow-up domain", () => {
assert.match(
requestCardinalityMigration,
/conversational_rectification_valid_evidence_request\(jsonb\)/,
);
assert.match(requestCardinalityMigration, /'between 2 and 4'/);
assert.match(requestCardinalityMigration, /'between 1 and 4'/);
});
@@ -143,15 +143,11 @@ function narrativeGenerator() {
};
const packet = request.packet;
const final = request.phase === "final";
const nextDomain = packet.suggestedDomains[0]?.domain ?? "career";
return { text: JSON.stringify({
narrative: [
`${packet.candidate.representativeTime} 是待验证候选,范围为 ${packet.candidate.rangeStart} 至 ${packet.candidate.rangeEnd}。`,
"D1 的 Cancer 在范围内保持稳定。",
"D9 的 Aries / Leo 存在分钟敏感差异,搬迁事件可以区分 D9。",
"D10 的 Taurus / Libra 存在分钟敏感差异,事业事件可以区分 D10。",
"D24 的 Gemini / Virgo 存在分钟敏感差异,学业事件可以区分 D24。",
final ? "现有已发生事件支持进入候选确认。" : "请写一件已经发生的真实事件,注明哪一年、哪一月以及发生了什么。",
packet.useBoundary,
`当前仍在核对 ${packet.candidate.rangeStart}–${packet.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
final ? "现有已发生事件支持进入候选确认。" : "先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
].join(""),
candidateStatus: packet.candidate.status,
representativeTime: packet.candidate.representativeTime,
@@ -163,7 +159,7 @@ function narrativeGenerator() {
referenceIds: [],
domainReasons: packet.suggestedDomains.map((item) => ({ ...item })),
evidenceRequest: final ? null : {
domains: packet.suggestedDomains.map((item) => item.domain),
domains: [nextDomain],
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实事件,并写明哪一年、哪一月以及发生了什么。",
},
@@ -472,12 +468,11 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato
let turn = await post(handler, { type: "start", actionId: caseId, pendingConsultationQuestion: originalQuestion });
assert.equal(turn.pendingConsultationQuestion, originalQuestion);
assert.equal(turn.status, "active");
assert.match(turn.narrative, /05:30.*待验证候选/);
assert.match(turn.narrative, /D1.*稳定/);
assert.match(turn.narrative, /D9.*敏感差异/);
assert.match(turn.narrative, /D10.*敏感差异/);
assert.match(turn.narrative, /05:00–06:00.*不能/);
assert.doesNotMatch(turn.narrative, /\bD\d+\b/);
assert.equal((turn.narrative.match(/[??]/g) ?? []).length, 1);
assert.match(turn.narrative, /哪一年、哪一月/);
assert.deepEqual(turn.evidenceRequest?.domains, ["career", "education", "relocation"]);
assert.deepEqual(turn.evidenceRequest?.domains, ["career"]);
assert.equal(turn.evidenceRequest?.freeTextAllowed, true);
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
assert.equal(JSON.stringify(turn).includes("private-synthetic-partition"), false);
@@ -120,15 +120,12 @@ function validGenerator(
};
};
const value = request.packet;
const domains = value.suggestedDomains.map((item) => item.domain);
const domains = value.suggestedDomains.slice(0, 1).map((item) => item.domain);
const nextDomain = domains[0] === "relationship" ? "重要关系" : "事业";
const narrative = [
`${value.candidate.representativeTime} 是待验证候选。`,
"D1(Cancer)保持稳定。",
"D9(Aries / Leo)呈现分钟敏感差异,关系事件可区分 D9。",
"D10(Taurus / Libra)呈现分钟敏感差异,事业事件可区分 D10。",
`当前仍在核对 ${value.candidate.rangeStart}–${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
varyNarrative ? `这是第 ${generation} 次合成措辞。` : "",
request.phase === "final" ? "当前证据已形成候选总结。" : "请提供已经发生的真实事件,写明哪一年、哪一月以及发生了什么。",
"这仅是候选,必须由你确认后才会替换当前排盘时间。",
request.phase === "final" ? "当前证据已形成候选总结。" : `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`,
].join("");
return { text: JSON.stringify({
narrative,
@@ -144,7 +141,7 @@ function validGenerator(
evidenceRequest: request.phase === "final" ? null : {
domains,
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实事件,并写明哪一年、哪一月以及发生了什么。",
prompt: `请说一件已经发生的${nextDomain}经历,并写明哪一年、哪一月以及发生了什么。`,
},
}) };
},
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
buildProductionConversationalRectificationPacket,
@@ -20,6 +21,15 @@ const actionId = "00000000-0000-4000-8000-000000000712";
const caseId = "00000000-0000-4000-8000-000000000713";
const requestId = "00000000-0000-4000-8000-000000000714";
test("production narrator loads the Jyotish Skill without overriding packet truth", () => {
const source = readFileSync(new URL("../src/app/api/birth-time-conversation/route.ts", import.meta.url), "utf8");
assert.match(source, /skills:\s*\[jyotishSkillPath\]/);
assert.match(source, /Use the Jyotish Skill only to choose a natural, one-question-at-a-time evidence strategy and wording/);
assert.match(source, /supplied packet facts as the exclusive source/);
assert.match(source, /Never invent, recalculate, or confirm candidate data/);
});
const turn = {
caseId,
journeyProtocol: "conversational-evidence-v3" as const,
@@ -139,7 +149,7 @@ function packetEngine(options: {
},
async score() { throw new Error("unexpected questionnaire score"); },
async scoreEvents(input) {
assert.ok(input.events.length >= 3 && input.events.length <= 6);
assert.ok(input.events.length >= 3 && input.events.length <= 8);
for (const event of input.events) {
const birthBoundary = event.precision === "year"
? input.birthDate.slice(0, 4)
@@ -889,11 +899,11 @@ test("production rescans the declared range after correction while ordinary evid
assert.equal(ordinary.packet.sensitivityScope.sampleTimes.includes("04:50"), false);
});
test("production packet deterministically sends only the latest six supported events", async () => {
test("production packet sends health evidence and uses the shared eight-event convergence limit", async () => {
const scoreCalls: LifeEvent[][] = [];
const differenceCalls: DifferencePacketInput[] = [];
const engine = packetEngine({ scoreCalls, differenceCalls });
const domains = ["education", "relocation", "career", "relationship"] as const;
const domains = ["education", "relocation", "career", "relationship", "health_pressure"] as const;
const evidence = Array.from({ length: 8 }, (_, index) =>
syntheticEvidence(index + 1, domains[index % domains.length] ?? "career"));
@@ -917,12 +927,13 @@ test("production packet deterministically sends only the latest six supported ev
assert.equal(scoreCalls.length, 1);
assert.deepEqual(
scoreCalls[0]?.map((event) => event.id),
evidence.slice(-6).map((item) => item.id),
evidence.map((item) => item.id),
);
assert.deepEqual(
differenceCalls.at(-1)?.events.map((event) => event.id),
evidence.slice(-6).map((item) => item.id),
evidence.map((item) => item.id),
);
assert.ok(scoreCalls[0]?.some((event) => event.domain === "health_pressure"));
});
test("persistable future background evidence never reaches the production scorer", async () => {
+8
View File
@@ -257,6 +257,14 @@ test("makes SidebarContent the only sidebar scroll owner", () => {
assert.match(globalStyles, /\[data-active="true"\][^{]*\{[^}]*background:\s*var\(--sidebar-accent\)/);
});
test("renders each session title and menu as one unified row surface", () => {
assert.match(cssBlock(".session-row"), /grid-template-columns:\s*minmax\(0,\s*1fr\)\s+44px/);
assert.match(globalStyles, /\.session-row:has\(\.session-main\[data-active="true"\]\)[^{]*\{[^}]*background:\s*var\(--sidebar-accent\)/);
assert.match(cssBlock('.session-main[data-active="true"]'), /background:\s*transparent/);
assert.match(cssBlock(".session-menu-trigger"), /border-radius:\s*0/);
assert.doesNotMatch(cssBlock(".session-menu-trigger"), /border-radius:\s*50%/);
});
test("styles the non-mobile collapsed rail without repeated session rows", () => {
assert.match(globalStyles, /@media\s*\(min-width:\s*768px\)[\s\S]*\[data-state="collapsed"\][^{]*\.brand-row/);
assert.match(globalStyles, /\[data-state="collapsed"\][^{]*\[data-sidebar="menu-button"\][^{]*\{[^}]*width:\s*44px/);
@@ -0,0 +1,93 @@
{
"schema_version": "conversational-minute-rectification-development-v1",
"benchmark_id": "conversational_rectification_development_v1",
"source_manifest": "references/real_case_calibration/minute_rectification_development_v1.json",
"partition": "tuning_and_diagnostics",
"excluded_from_holdout": true,
"allowed_for_tuning": true,
"as_of_date": "2026-07-22",
"cases": [
{
"case_id": "steve_jobs_1955_development",
"disclosure_order": [
{
"event_id": "jobs_apple_founding_1976",
"user_utterance": "1976年4月1日我创业成立了公司。",
"expected_extraction": {"date_value": "1976-04-01", "date_precision": "day", "domain": "career", "scoreable": true},
"expected_route_scoreable": true
},
{
"event_id": "jobs_marriage_1991",
"user_utterance": "1991年3月18日我结婚了。",
"expected_extraction": {"date_value": "1991-03-18", "date_precision": "day", "domain": "relationship", "scoreable": true},
"expected_route_scoreable": true
},
{
"event_id": "jobs_cancer_diagnosis_2003",
"user_utterance": "2003年我确诊了胰腺神经内分泌肿瘤。",
"expected_extraction": {"date_value": "2003", "date_precision": "year", "domain": "health_pressure", "scoreable": true},
"expected_route_scoreable": true
},
{
"event_id": "jobs_resigned_apple_ceo_2011",
"user_utterance": "2011年8月24日我辞去公司CEO职务。",
"expected_extraction": {"date_value": "2011-08-24", "date_precision": "day", "domain": "career", "scoreable": true},
"expected_route_scoreable": true
}
]
},
{
"case_id": "albert_einstein_1879_development",
"disclosure_order": [
{
"event_id": "einstein_marriage_1903",
"user_utterance": "1903年我结婚了。",
"expected_extraction": {"date_value": "1903", "date_precision": "year", "domain": "relationship", "scoreable": true},
"expected_route_scoreable": true
},
{
"event_id": "einstein_nobel_award_1922",
"user_utterance": "1922年我的工作成就获得了诺贝尔物理学奖。",
"expected_extraction": {"date_value": "1922", "date_precision": "year", "domain": "career", "scoreable": true},
"expected_route_scoreable": true
},
{
"event_id": "einstein_left_germany_1933",
"user_utterance": "1933年我移居国外。",
"expected_extraction": {"date_value": "1933", "date_precision": "year", "domain": "relocation", "scoreable": true},
"expected_route_scoreable": true
}
]
},
{
"case_id": "marie_curie_1867_development",
"disclosure_order": [
{
"event_id": "curie_moved_to_paris_1891",
"user_utterance": "1891年我移居巴黎继续学业。",
"expected_extraction": {"date_value": "1891", "date_precision": "year", "domain": "education", "scoreable": true},
"expected_route_scoreable": true
},
{
"event_id": "curie_marriage_1895",
"user_utterance": "1895年7月26日我结婚了。",
"expected_extraction": {"date_value": "1895-07-26", "date_precision": "day", "domain": "relationship", "scoreable": true},
"expected_route_scoreable": true
},
{
"event_id": "curie_nobel_physics_1903",
"user_utterance": "1903年我的工作成果获得了诺贝尔物理学奖。",
"expected_extraction": {"date_value": "1903", "date_precision": "year", "domain": "career", "scoreable": true},
"expected_route_scoreable": true
},
{
"event_id": "curie_widowed_1906",
"user_utterance": "1906年4月19日我的丈夫因交通事故去世。",
"expected_extraction": {"date_value": "1906-04-19", "date_precision": "day", "domain": "health_pressure", "scoreable": true},
"expected_route_scoreable": true
}
]
}
],
"boundary": "Visible public entry smoke fixtures. TypeScript tests assert extractor behavior and Python uses fixture labels to simulate route filtering; the fixed cases now cover pre-1900 dates and health-pressure routing, but this is not end-to-end transport and cannot evaluate Skill-level convergence or open the release gate."
}
@@ -0,0 +1,906 @@
{
"benchmark_id": "conversational_rectification_development_v1",
"boundary": "This is a structured-event smoke replay. TypeScript tests separately assert the fixture's current extractor behavior; the Python filter is not an end-to-end product transport. Cases below five scoreable events cannot evaluate Skill-level convergence.",
"cases": [
{
"case_id": "steve_jobs_1955_development",
"final_oracle_structured": {
"deterministic_tiebreak_error": 8,
"deterministic_tiebreak_time": "19:23",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 4,
"top_score_class_size": 4,
"true_rank": 12,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "19:23",
"representative_time": "19:21",
"start_time": "19:20",
"width_minutes": 4
}
},
"final_simulated_current_transport_filter": {
"deterministic_tiebreak_error": 8,
"deterministic_tiebreak_time": "19:23",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 4,
"top_score_class_size": 4,
"true_rank": 12,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "19:23",
"representative_time": "19:21",
"start_time": "19:20",
"width_minutes": 4
}
},
"published_time": "19:15",
"turns": [
{
"event_id": "jobs_apple_founding_1976",
"oracle_structured": {
"deterministic_tiebreak_error": 8,
"deterministic_tiebreak_time": "19:23",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 1,
"top_score_class_size": 4,
"true_rank": 5,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "19:23",
"representative_time": "19:21",
"start_time": "19:20",
"width_minutes": 4
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 8,
"deterministic_tiebreak_time": "19:23",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 1,
"top_score_class_size": 4,
"true_rank": 5,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "19:23",
"representative_time": "19:21",
"start_time": "19:20",
"width_minutes": 4
}
},
"turn": 1,
"user_utterance": "1976年4月1日我创业成立了公司。"
},
{
"event_id": "jobs_marriage_1991",
"oracle_structured": {
"deterministic_tiebreak_error": 1,
"deterministic_tiebreak_time": "19:16",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 2,
"top_score_class_size": 15,
"true_rank": 1,
"truth_in_top_score_tie": true,
"winning_segment": {
"end_time": "19:19",
"representative_time": "19:12",
"start_time": "19:05",
"width_minutes": 15
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 1,
"deterministic_tiebreak_time": "19:16",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 2,
"top_score_class_size": 15,
"true_rank": 1,
"truth_in_top_score_tie": true,
"winning_segment": {
"end_time": "19:19",
"representative_time": "19:12",
"start_time": "19:05",
"width_minutes": 15
}
},
"turn": 2,
"user_utterance": "1991年3月18日我结婚了。"
},
{
"event_id": "jobs_cancer_diagnosis_2003",
"oracle_structured": {
"deterministic_tiebreak_error": 10,
"deterministic_tiebreak_time": "19:05",
"leave_one_event_out_passed": false,
"minute_error": 10,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": "19:05",
"result_reasons": [
"insufficient_events",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 3,
"top_score_class_size": 1,
"true_rank": 10,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "19:05",
"representative_time": "19:05",
"start_time": "19:05",
"width_minutes": 1
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 10,
"deterministic_tiebreak_time": "19:05",
"leave_one_event_out_passed": false,
"minute_error": 10,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": "19:05",
"result_reasons": [
"insufficient_events",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 3,
"top_score_class_size": 1,
"true_rank": 10,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "19:05",
"representative_time": "19:05",
"start_time": "19:05",
"width_minutes": 1
}
},
"turn": 3,
"user_utterance": "2003年我确诊了胰腺神经内分泌肿瘤。"
},
{
"event_id": "jobs_resigned_apple_ceo_2011",
"oracle_structured": {
"deterministic_tiebreak_error": 8,
"deterministic_tiebreak_time": "19:23",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 4,
"top_score_class_size": 4,
"true_rank": 12,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "19:23",
"representative_time": "19:21",
"start_time": "19:20",
"width_minutes": 4
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 8,
"deterministic_tiebreak_time": "19:23",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 4,
"top_score_class_size": 4,
"true_rank": 12,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "19:23",
"representative_time": "19:21",
"start_time": "19:20",
"width_minutes": 4
}
},
"turn": 4,
"user_utterance": "2011年8月24日我辞去公司CEO职务。"
}
]
},
{
"case_id": "albert_einstein_1879_development",
"final_oracle_structured": {
"deterministic_tiebreak_error": 9,
"deterministic_tiebreak_time": "11:39",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 3,
"top_score_class_size": 2,
"true_rank": 3,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "11:40",
"representative_time": "11:39",
"start_time": "11:39",
"width_minutes": 2
}
},
"final_simulated_current_transport_filter": {
"deterministic_tiebreak_error": 9,
"deterministic_tiebreak_time": "11:39",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 3,
"top_score_class_size": 2,
"true_rank": 3,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "11:40",
"representative_time": "11:39",
"start_time": "11:39",
"width_minutes": 2
}
},
"published_time": "11:30",
"turns": [
{
"event_id": "einstein_marriage_1903",
"oracle_structured": {
"deterministic_tiebreak_error": 8,
"deterministic_tiebreak_time": "11:38",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 1,
"top_score_class_size": 5,
"true_rank": 6,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "11:40",
"representative_time": "11:38",
"start_time": "11:36",
"width_minutes": 5
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 8,
"deterministic_tiebreak_time": "11:38",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 1,
"top_score_class_size": 5,
"true_rank": 6,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "11:40",
"representative_time": "11:38",
"start_time": "11:36",
"width_minutes": 5
}
},
"turn": 1,
"user_utterance": "1903年我结婚了。"
},
{
"event_id": "einstein_nobel_award_1922",
"oracle_structured": {
"deterministic_tiebreak_error": 10,
"deterministic_tiebreak_time": "11:40",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 2,
"top_score_class_size": 2,
"true_rank": 6,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "11:40",
"representative_time": "11:39",
"start_time": "11:39",
"width_minutes": 2
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 10,
"deterministic_tiebreak_time": "11:40",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 2,
"top_score_class_size": 2,
"true_rank": 6,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "11:40",
"representative_time": "11:39",
"start_time": "11:39",
"width_minutes": 2
}
},
"turn": 2,
"user_utterance": "1922年我的工作成就获得了诺贝尔物理学奖。"
},
{
"event_id": "einstein_left_germany_1933",
"oracle_structured": {
"deterministic_tiebreak_error": 9,
"deterministic_tiebreak_time": "11:39",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 3,
"top_score_class_size": 2,
"true_rank": 3,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "11:40",
"representative_time": "11:39",
"start_time": "11:39",
"width_minutes": 2
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 9,
"deterministic_tiebreak_time": "11:39",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 3,
"top_score_class_size": 2,
"true_rank": 3,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "11:40",
"representative_time": "11:39",
"start_time": "11:39",
"width_minutes": 2
}
},
"turn": 3,
"user_utterance": "1933年我移居国外。"
}
]
},
{
"case_id": "marie_curie_1867_development",
"final_oracle_structured": {
"deterministic_tiebreak_error": 4,
"deterministic_tiebreak_time": "12:04",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 4,
"top_score_class_size": 2,
"true_rank": 3,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "12:05",
"representative_time": "12:04",
"start_time": "12:04",
"width_minutes": 2
}
},
"final_simulated_current_transport_filter": {
"deterministic_tiebreak_error": 4,
"deterministic_tiebreak_time": "12:04",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 4,
"top_score_class_size": 2,
"true_rank": 3,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "12:05",
"representative_time": "12:04",
"start_time": "12:04",
"width_minutes": 2
}
},
"published_time": "12:00",
"turns": [
{
"event_id": "curie_moved_to_paris_1891",
"oracle_structured": {
"deterministic_tiebreak_error": 9,
"deterministic_tiebreak_time": "12:09",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 1,
"top_score_class_size": 21,
"true_rank": 1,
"truth_in_top_score_tie": true,
"winning_segment": {
"end_time": "12:10",
"representative_time": "12:00",
"start_time": "11:50",
"width_minutes": 21
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 9,
"deterministic_tiebreak_time": "12:09",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 1,
"top_score_class_size": 21,
"true_rank": 1,
"truth_in_top_score_tie": true,
"winning_segment": {
"end_time": "12:10",
"representative_time": "12:00",
"start_time": "11:50",
"width_minutes": 21
}
},
"turn": 1,
"user_utterance": "1891年我移居巴黎继续学业。"
},
{
"event_id": "curie_marriage_1895",
"oracle_structured": {
"deterministic_tiebreak_error": 5,
"deterministic_tiebreak_time": "11:55",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 2,
"top_score_class_size": 21,
"true_rank": 1,
"truth_in_top_score_tie": true,
"winning_segment": {
"end_time": "12:10",
"representative_time": "12:00",
"start_time": "11:50",
"width_minutes": 21
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 5,
"deterministic_tiebreak_time": "11:55",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"insufficient_domains",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 2,
"top_score_class_size": 21,
"true_rank": 1,
"truth_in_top_score_tie": true,
"winning_segment": {
"end_time": "12:10",
"representative_time": "12:00",
"start_time": "11:50",
"width_minutes": 21
}
},
"turn": 2,
"user_utterance": "1895年7月26日我结婚了。"
},
{
"event_id": "curie_nobel_physics_1903",
"oracle_structured": {
"deterministic_tiebreak_error": 3,
"deterministic_tiebreak_time": "11:57",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 3,
"top_score_class_size": 16,
"true_rank": 1,
"truth_in_top_score_tie": true,
"winning_segment": {
"end_time": "12:05",
"representative_time": "11:57",
"start_time": "11:50",
"width_minutes": 16
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 3,
"deterministic_tiebreak_time": "11:57",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"insufficient_events",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 3,
"top_score_class_size": 16,
"true_rank": 1,
"truth_in_top_score_tie": true,
"winning_segment": {
"end_time": "12:05",
"representative_time": "11:57",
"start_time": "11:50",
"width_minutes": 16
}
},
"turn": 3,
"user_utterance": "1903年我的工作成果获得了诺贝尔物理学奖。"
},
{
"event_id": "curie_widowed_1906",
"oracle_structured": {
"deterministic_tiebreak_error": 4,
"deterministic_tiebreak_time": "12:04",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 4,
"top_score_class_size": 2,
"true_rank": 3,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "12:05",
"representative_time": "12:04",
"start_time": "12:04",
"width_minutes": 2
}
},
"route_scoreable": true,
"simulated_current_transport_filter": {
"deterministic_tiebreak_error": 4,
"deterministic_tiebreak_time": "12:04",
"leave_one_event_out_passed": false,
"minute_error": null,
"neighbor_stability_passed": false,
"offline_truth_qualified_success": false,
"predicted_time": null,
"result_reasons": [
"unique_minute_not_found",
"neighbor_stability_not_passed",
"leave_one_event_out_not_passed",
"top_candidate_feature_not_unique",
"fact_ranker_v4_holdout_not_ready"
],
"runtime_confirmation_candidate": false,
"scored_event_count": 4,
"top_score_class_size": 2,
"true_rank": 3,
"truth_in_top_score_tie": false,
"winning_segment": {
"end_time": "12:05",
"representative_time": "12:04",
"start_time": "12:04",
"width_minutes": 2
}
},
"turn": 4,
"user_utterance": "1906年4月19日我的丈夫因交通事故去世。"
}
]
}
],
"excluded_from_holdout": true,
"may_open_release_gate": false,
"scope": "conversational_minute_rectification_entry_smoke_replay",
"status": "diagnostics_available",
"summary": {
"oracle_structured": {
"case_count": 3,
"cases_reaching_five_scoreable_events": 0,
"deterministic_tiebreak_mean_absolute_error": 7.0,
"leave_one_event_out_pass_count": 0,
"mean_winning_segment_width_minutes": 2.6667,
"neighbor_stability_pass_count": 0,
"offline_truth_qualified_success_count": 0,
"runtime_confirmation_candidate_count": 0,
"truth_in_top_score_tie_rate": 0.0,
"truth_rank_le_3_rate": 0.6667,
"unique_minute_count": 0
},
"simulated_current_transport_filter": {
"case_count": 3,
"cases_reaching_five_scoreable_events": 0,
"deterministic_tiebreak_mean_absolute_error": 7.0,
"leave_one_event_out_pass_count": 0,
"mean_winning_segment_width_minutes": 2.6667,
"neighbor_stability_pass_count": 0,
"offline_truth_qualified_success_count": 0,
"runtime_confirmation_candidate_count": 0,
"truth_in_top_score_tie_rate": 0.0,
"truth_rank_le_3_rate": 0.6667,
"unique_minute_count": 0
}
}
}
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""Replay public structured events and a fixture-labeled transport filter."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from scripts.minute_rectification_blind_eval import ( # noqa: E402
_candidate_moments,
_clock_distance,
_opaque_winner,
_request,
)
from scripts.minute_rectification_fact_ranker_v4 import ( # noqa: E402
rank_fact_rows,
score_fact_ranker_v4,
)
from scripts.minute_rectification_feature_facts_v4 import build_feature_fact_rows # noqa: E402
DEFAULT_MANIFEST = ROOT / "references/real_case_calibration/conversational_rectification_development_v1.json"
def _load(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _source_cases(manifest: dict[str, Any], manifest_path: Path) -> dict[str, dict[str, Any]]:
source_path = ROOT / manifest["source_manifest"]
source = _load(source_path)
return {case["case_id"]: case for case in source["cases"]}
def _step_result(benchmark_id: str, case: dict[str, Any], events: list[dict[str, Any]]) -> dict[str, Any]:
if not events:
return {
"scored_event_count": 0,
"true_rank": None,
"predicted_time": None,
"minute_error": None,
"winning_segment": None,
"would_safely_converge": False,
"result_reasons": ["no_scoreable_events"],
}
request = _request(case, events)
candidates = _candidate_moments(case)
fact_rows = build_feature_fact_rows(request, candidates=candidates)
ranked_rows, _ = rank_fact_rows(fact_rows, request["events"])
result = score_fact_ranker_v4(fact_rows, request["events"])
truth = case["birth"]["time"]
truth_row = next(row for row in ranked_rows if row["time"] == truth)
top_score = max(row["score"] for row in ranked_rows)
top_score_class_size = sum(row["score"] == top_score for row in ranked_rows)
event_signature = ",".join(event["id"] for event in events)
deterministic_tiebreak = _opaque_winner(
f"{benchmark_id}:{event_signature}", case["case_id"], ranked_rows
)
true_rank = 1 + sum(row["score"] > truth_row["score"] for row in ranked_rows)
segment = result["winning_segment"]
neighbor_passed = result["stability_diagnostics"]["neighbor_stability"]["all_required_passed"]
leave_one_out_passed = result["stability_diagnostics"]["leave_one_event_out"]["status"] == "pass"
blocking_reasons = [reason for reason in result["reasons"] if reason != "fact_ranker_v4_holdout_not_ready"]
runtime_confirmation_candidate = (
len(events) >= 5
and len({event["domain"] for event in events}) >= 3
and segment is not None
and segment["width_minutes"] == 1
and neighbor_passed
and leave_one_out_passed
and not blocking_reasons
)
predicted = deterministic_tiebreak if segment and segment["width_minutes"] == 1 else None
offline_truth_qualified_success = (
runtime_confirmation_candidate
and true_rank == 1
and predicted is not None
and _clock_distance(predicted, truth) <= 2
)
return {
"scored_event_count": len(events),
"true_rank": true_rank,
"truth_in_top_score_tie": truth_row["score"] == top_score,
"top_score_class_size": top_score_class_size,
"predicted_time": predicted,
"minute_error": _clock_distance(predicted, truth) if predicted else None,
"deterministic_tiebreak_time": deterministic_tiebreak,
"deterministic_tiebreak_error": _clock_distance(deterministic_tiebreak, truth),
"winning_segment": segment,
"neighbor_stability_passed": neighbor_passed,
"leave_one_event_out_passed": leave_one_out_passed,
"runtime_confirmation_candidate": runtime_confirmation_candidate,
"offline_truth_qualified_success": offline_truth_qualified_success,
"result_reasons": result["reasons"],
}
def run(manifest_path: Path = DEFAULT_MANIFEST) -> dict[str, Any]:
manifest = _load(manifest_path)
sources = _source_cases(manifest, manifest_path)
case_results = []
for replay_case in manifest["cases"]:
case = sources[replay_case["case_id"]]
by_event_id = {event["id"]: event for event in case["events"]}
oracle_events: list[dict[str, Any]] = []
product_events: list[dict[str, Any]] = []
turns = []
for turn_index, disclosure in enumerate(replay_case["disclosure_order"], start=1):
event = by_event_id[disclosure["event_id"]]
oracle_events.append(event)
if disclosure["expected_route_scoreable"]:
product_events.append(event)
turns.append({
"turn": turn_index,
"event_id": event["id"],
"user_utterance": disclosure["user_utterance"],
"route_scoreable": disclosure["expected_route_scoreable"],
"oracle_structured": _step_result(
manifest["benchmark_id"], case, oracle_events,
),
"simulated_current_transport_filter": _step_result(
manifest["benchmark_id"], case, product_events,
),
})
final_oracle = turns[-1]["oracle_structured"]
final_product = turns[-1]["simulated_current_transport_filter"]
case_results.append({
"case_id": case["case_id"],
"published_time": case["birth"]["time"],
"turns": turns,
"final_oracle_structured": final_oracle,
"final_simulated_current_transport_filter": final_product,
})
def summary(key: str) -> dict[str, Any]:
finals = [case[key] for case in case_results]
segments = [item["winning_segment"] for item in finals if item["winning_segment"]]
return {
"case_count": len(finals),
"cases_reaching_five_scoreable_events": sum(
item["scored_event_count"] >= 5 for item in finals
),
"truth_rank_le_3_rate": round(
sum((item["true_rank"] or 999) <= 3 for item in finals) / len(finals), 4
),
"deterministic_tiebreak_mean_absolute_error": round(
sum(item["deterministic_tiebreak_error"] for item in finals) / len(finals), 4
),
"truth_in_top_score_tie_rate": round(
sum(item["truth_in_top_score_tie"] for item in finals) / len(finals), 4
),
"unique_minute_count": sum(
segment["width_minutes"] == 1 for segment in segments
),
"mean_winning_segment_width_minutes": round(
sum(segment["width_minutes"] for segment in segments) / len(segments), 4
) if segments else None,
"neighbor_stability_pass_count": sum(item["neighbor_stability_passed"] for item in finals),
"leave_one_event_out_pass_count": sum(item["leave_one_event_out_passed"] for item in finals),
"runtime_confirmation_candidate_count": sum(
item["runtime_confirmation_candidate"] for item in finals
),
"offline_truth_qualified_success_count": sum(
item["offline_truth_qualified_success"] for item in finals
),
}
return {
"scope": "conversational_minute_rectification_entry_smoke_replay",
"benchmark_id": manifest["benchmark_id"],
"status": "diagnostics_available",
"cases": case_results,
"summary": {
"oracle_structured": summary("final_oracle_structured"),
"simulated_current_transport_filter": summary(
"final_simulated_current_transport_filter"
),
},
"excluded_from_holdout": True,
"may_open_release_gate": False,
"boundary": (
"This is a structured-event smoke replay. TypeScript tests separately assert the "
"fixture's current extractor behavior; the Python filter is not an end-to-end product "
"transport. Cases below five scoreable events cannot evaluate Skill-level convergence."
),
}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
result = run(args.manifest)
rendered = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)
if args.output:
args.output.write_text(rendered + "\n", encoding="utf-8")
else:
print(rendered)