From 2ca245d643d13f4f3a9a2e2dec7e79f388e593e2 Mon Sep 17 00:00:00 2001
From: Jesse_Chen
Date: Mon, 27 Jul 2026 09:57:14 +0800
Subject: [PATCH] feat: rebuild birth time rectification workflow
---
deploy/docker-compose.staging.yml | 18 +
deploy/run-staging-deploy.sh | 4 +-
docs/BUG_HISTORY.md | 14 +
...time_rectification_v4_design_2026_07_26.md | 383 +++++++
frontend/package-lock.json | 31 +-
frontend/package.json | 5 +-
frontend/scripts/rectification-v4-worker.mts | 23 +
frontend/src/app/api/account/route.ts | 27 +-
frontend/src/app/api/consult/route.ts | 222 +++-
.../src/app/api/rectification/v4/_server.ts | 98 ++
.../v4/cases/[caseId]/abandon/route.ts | 12 +
.../v4/cases/[caseId]/accept-range/route.ts | 22 +
.../v4/cases/[caseId]/answers/route.ts | 18 +
.../events/[eventId]/revisions/route.ts | 36 +
.../v4/cases/[caseId]/pause/route.ts | 12 +
.../v4/cases/[caseId]/resume/route.ts | 12 +
.../rectification/v4/cases/[caseId]/route.ts | 14 +
.../app/api/rectification/v4/cases/_action.ts | 18 +
.../rectification/v4/cases/active/route.ts | 14 +
.../app/api/rectification/v4/cases/route.ts | 19 +
.../app/api/rectification/v4/handoff/route.ts | 30 +
.../rectification/v4/jobs/[jobId]/route.ts | 14 +
frontend/src/app/globals.css | 31 +
frontend/src/app/page.tsx | 274 +----
...onversational-birth-time-rectification.tsx | 27 +-
.../src/components/rectification-v4-panel.tsx | 204 ++++
frontend/src/hooks/use-rectification-v4.ts | 156 +++
.../src/lib/account-rectification-case.ts | 26 +
.../lib/birth-time-consultation-consent.ts | 7 +-
.../src/lib/consultation-route-service.ts | 38 +-
.../src/lib/rectification-handoff-service.ts | 139 ++-
.../rectification-v4/candidate-clusters.ts | 41 +
.../lib/rectification-v4/candidate-engine.ts | 90 ++
.../src/lib/rectification-v4/case-service.ts | 117 +++
frontend/src/lib/rectification-v4/client.ts | 160 +++
.../src/lib/rectification-v4/contracts.ts | 241 +++++
.../src/lib/rectification-v4/date-range.ts | 66 ++
.../src/lib/rectification-v4/decision-gate.ts | 27 +
.../lib/rectification-v4/domain-scorers.ts | 33 +
.../lib/rectification-v4/evidence-ledger.ts | 39 +
.../src/lib/rectification-v4/extraction.ts | 65 ++
.../src/lib/rectification-v4/fingerprints.ts | 31 +
.../src/lib/rectification-v4/handoff-route.ts | 80 ++
.../src/lib/rectification-v4/memory-store.ts | 225 ++++
.../lib/rectification-v4/question-planner.ts | 82 ++
frontend/src/lib/rectification-v4/store.ts | 99 ++
.../lib/rectification-v4/supabase-store.ts | 321 ++++++
frontend/src/lib/rectification-v4/worker.ts | 115 ++
.../src/lib/supabase/admin-client-core.ts | 36 +
frontend/src/lib/supabase/admin.ts | 40 +-
frontend/src/mastra/index.ts | 3 +
...0726020000_birth_time_rectification_v4.sql | 987 ++++++++++++++++++
.../tests/consultation-entrypoint.test.ts | 134 ++-
.../tests/consultation-route-service.test.ts | 48 +
...ersational-rectification-component.test.ts | 338 +++---
.../conversational-rectification-e2e.test.ts | 90 +-
.../tests/database-local-business.test.ts | 10 +
.../tests/rectification-v4-domain.test.ts | 134 +++
.../tests/rectification-v4-handoff.test.ts | 110 ++
.../tests/rectification-v4-migration.test.ts | 38 +
.../tests/rectification-v4-replay.test.ts | 124 +++
.../tests/rectification-v4-service.test.ts | 216 ++++
.../tests/staging-backend-workflows.test.ts | 17 +-
scripts/active_rectification_events_v4.py | 221 ++++
scripts/jyotish_api_server.py | 102 ++
tests/test_active_rectification_events_v4.py | 97 ++
66 files changed, 5826 insertions(+), 699 deletions(-)
create mode 100644 docs/research/birth_time_rectification_v4_design_2026_07_26.md
create mode 100644 frontend/scripts/rectification-v4-worker.mts
create mode 100644 frontend/src/app/api/rectification/v4/_server.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/[caseId]/abandon/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/[caseId]/accept-range/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/[caseId]/answers/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/[caseId]/pause/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/[caseId]/resume/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/[caseId]/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/_action.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/active/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/cases/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/handoff/route.ts
create mode 100644 frontend/src/app/api/rectification/v4/jobs/[jobId]/route.ts
create mode 100644 frontend/src/components/rectification-v4-panel.tsx
create mode 100644 frontend/src/hooks/use-rectification-v4.ts
create mode 100644 frontend/src/lib/rectification-v4/candidate-clusters.ts
create mode 100644 frontend/src/lib/rectification-v4/candidate-engine.ts
create mode 100644 frontend/src/lib/rectification-v4/case-service.ts
create mode 100644 frontend/src/lib/rectification-v4/client.ts
create mode 100644 frontend/src/lib/rectification-v4/contracts.ts
create mode 100644 frontend/src/lib/rectification-v4/date-range.ts
create mode 100644 frontend/src/lib/rectification-v4/decision-gate.ts
create mode 100644 frontend/src/lib/rectification-v4/domain-scorers.ts
create mode 100644 frontend/src/lib/rectification-v4/evidence-ledger.ts
create mode 100644 frontend/src/lib/rectification-v4/extraction.ts
create mode 100644 frontend/src/lib/rectification-v4/fingerprints.ts
create mode 100644 frontend/src/lib/rectification-v4/handoff-route.ts
create mode 100644 frontend/src/lib/rectification-v4/memory-store.ts
create mode 100644 frontend/src/lib/rectification-v4/question-planner.ts
create mode 100644 frontend/src/lib/rectification-v4/store.ts
create mode 100644 frontend/src/lib/rectification-v4/supabase-store.ts
create mode 100644 frontend/src/lib/rectification-v4/worker.ts
create mode 100644 frontend/src/lib/supabase/admin-client-core.ts
create mode 100644 frontend/supabase/migrations/20260726020000_birth_time_rectification_v4.sql
create mode 100644 frontend/tests/rectification-v4-domain.test.ts
create mode 100644 frontend/tests/rectification-v4-handoff.test.ts
create mode 100644 frontend/tests/rectification-v4-migration.test.ts
create mode 100644 frontend/tests/rectification-v4-replay.test.ts
create mode 100644 frontend/tests/rectification-v4-service.test.ts
create mode 100644 scripts/active_rectification_events_v4.py
create mode 100644 tests/test_active_rectification_events_v4.py
diff --git a/deploy/docker-compose.staging.yml b/deploy/docker-compose.staging.yml
index aab684bb..c1dde3ed 100644
--- a/deploy/docker-compose.staging.yml
+++ b/deploy/docker-compose.staging.yml
@@ -4,5 +4,23 @@ services:
- default
- app
+ rectification-v4-worker:
+ image: ${WEB_IMAGE:-jyotisha-web:local}
+ restart: unless-stopped
+ env_file:
+ - ${APP_ENV_FILE:-../.env.staging}
+ environment:
+ GITHUB_SHA: ${GITHUB_SHA}
+ JYOTISH_API_BASE: http://api:5200
+ working_dir: /app/frontend
+ command: ["npm", "run", "worker:rectification-v4"]
+ depends_on:
+ api:
+ condition: service_healthy
+ postgres:
+ condition: service_healthy
+ networks:
+ - app
+
networks:
app:
diff --git a/deploy/run-staging-deploy.sh b/deploy/run-staging-deploy.sh
index 3d69f44b..580c8dfc 100755
--- a/deploy/run-staging-deploy.sh
+++ b/deploy/run-staging-deploy.sh
@@ -156,7 +156,8 @@ rollback() {
echo "staging verification failed; restoring prior application images" >&2
API_IMAGE="$previous_api_target" WEB_IMAGE="$previous_web_target" \
GITHUB_SHA="$current_sha" \
- "${compose[@]}" up -d --no-build --remove-orphans api web caddy || true
+ "${compose[@]}" up -d --no-build --remove-orphans \
+ api web rectification-v4-worker caddy || true
fi
exit "$status"
}
@@ -179,6 +180,7 @@ verify_container_image() {
}
verify_container_image api "$API_IMAGE"
verify_container_image web "$WEB_IMAGE"
+verify_container_image rectification-v4-worker "$WEB_IMAGE"
"${compose[@]}" exec -T \
-e EXPECTED_SHA="$DEPLOY_SHA" -e STAGING_URL="$STAGING_URL" \
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index 70ea2bca..aea79b3c 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -1503,3 +1503,17 @@
- 防复发:开放叙事不等于被动倾听;事件轮默认负责自然推进,但不得恢复固定问卷模板。
- 相关记录:BUG-075、BUG-080
- 修复版本:本次修复提交
+
+## BUG-082 | 生时校正缺少独立证据状态机并把分钟峰值误导为产品答案
+
+- 状态:resolved
+- 首次发现:2026-07-26
+- 最近更新:2026-07-26
+- 影响面:生时校正建案、事件采集、日期修订、候选评分、暂停恢复、范围保存、原咨询问题 handoff 与扣费幂等
+- 用户现象:专业 Agent 可以持续收集跨领域事件、逐步补充日期并做稳定性复核,但 Web 流程把对话、抽取、评分和叙事绑在单次请求中;容易重复追问同一事件、出现等待状态无下一题、在低置信度下突出单个峰值分钟,并且刷新或多设备继续时缺少独立业务真源。
+- 根因:旧 `conversational-evidence-v3` 同时承担聊天体验和校时状态机;人生事件没有稳定的 `eventId + revision` 修订链,问题没有持久化目标事件,分钟扫描结果也没有强制经过范围聚类、日期敏感性、逐项排除和邻近分钟门控。聊天会话、计算任务和咨询 handoff 的生命周期耦合,无法独立恢复和仲裁并发。
+- 修复:新增 `rectification-evidence-v4` 独立领域模型、Case/Turn/Event Revision/Snapshot/Job/Handoff 持久化状态机和后台 Worker。先完成教育、迁移、关系、事业、财务、健康压力、家庭七领域覆盖,再对非日精度事件按 `targetEventId` 追加同一事件 revision;已追问或跳过的事件不循环。候选分钟先聚类,再通过事件数、领域数、范围宽度、邻近分钟、leave-one-out、日期敏感性和计算口径 hash 门;公开合同固定 `canConfirmExactMinute=false`,只允许用户主动保存候选范围,不更新 `profiles.active_birth_time`。Handoff 和扣费由 PostgreSQL lease、幂等 action 与 settlement receipt 保护。
+- 验证:前端 V4/domain/service/migration/replay/handoff/consultation continuation 33 个测试通过;Python 2 个测试通过;目标 ESLint 与 Webpack 生产构建通过。隔离 PostgreSQL 12 项不变量通过;真实 PostgreSQL + Python E2E 完成七领域与定向日期修订后进入 `range_ready`,主要范围为 `05:26–05:30`,无空问题死状态,原 `active_birth_time` 不变且未主动保存前 `acceptedRange` 为空。静态浏览器验收确认日期修订输入框、范围保存按钮、非确认分钟文案和 390px 无横向溢出;真实登录态 UI 因隔离服务未连接认证配置,本轮未声称已验收。
+- 防复发:生时校正业务真源必须是 V4 Case 和 append-only 事件台账,不得从聊天正文反向恢复;任何公开结果只能显示通过稳定性门的范围,单分钟峰值不得成为可保存结果;证据不足必须生成下一题或明确暂停,不能进入 `awaiting_answer + currentQuestion=null`;V4 路径不得写 `profiles.active_birth_time`。
+- 相关记录:BUG-067、BUG-069、BUG-072、BUG-074、BUG-075、BUG-080、BUG-081
+- 修复版本:待提交(本地可测)
diff --git a/docs/research/birth_time_rectification_v4_design_2026_07_26.md b/docs/research/birth_time_rectification_v4_design_2026_07_26.md
new file mode 100644
index 00000000..468d6fa9
--- /dev/null
+++ b/docs/research/birth_time_rectification_v4_design_2026_07_26.md
@@ -0,0 +1,383 @@
+# 生时校正 V4 重构设计 — 2026-07-26
+
+## 1. 结论
+
+生时校正不应继续作为“聊天接口里顺便跑一次模型和分钟扫描”的功能维护。V4 将它重构为独立、可恢复、可审计的证据工作流:
+
+1. 先收集跨领域、带日期精度的人生事件;
+2. 再对已经存在但日期较粗的事件做定向修订;
+3. 后台 Worker 对冻结的计算口径和证据集合评分;
+4. 只输出通过稳定性门槛的候选时间范围;
+5. 用户主动保存后,才把该范围交给原咨询问题继续使用。
+
+产品永久边界:**V4 不确认单分钟,不把峰值分钟显示为真实出生时间,不修改 `profiles.active_birth_time`。**
+
+本文记录已经落地到本地可测试工作树的目标架构,而不是对旧实现的小修补。
+
+## 2. 交互记录暴露的旧架构问题
+
+用户提供的 Agent 交互记录说明,专业校时需要的不只是一个聊天框,而是一套持续数十轮仍保持一致的证据系统。旧 Web 流程无法稳定复现该过程,主要原因不是文案不够像 Agent,而是职责没有拆开。
+
+### 2.1 预设年份问卷替代了真实证据
+
+旧流程先给出宽年份选项,再不断要求把同一事件从年份段缩到年份、季度、月份。这样会产生三个问题:
+
+- 问题本身暗示事件应该发生在哪个窗口,带来确认偏差;
+- 用户已经给过事件后,系统仍可能把它当成下一条新事件;
+- 为了进入评分而取区间中点,会制造不存在的日期精度。
+
+V4 改为先接收用户自己声明的事件和日期,再把后续日期补充写成同一事件的新 revision。
+
+### 2.2 对话、抽取、评分和结果表达在同一请求中耦合
+
+旧流程把事件抽取、技术计算、叙事生成和下一题规划放在一个请求生命周期里。任一模型超时、跨语言 schema 漂移或计算耗时,都可能让已提交的经历看起来没有保存。
+
+V4 的同步请求只负责持久化答案并创建 Job;Worker 异步完成抽取、评分、稳定性检查和下一题规划。用户提交成功后可以离开页面,稍后继续。
+
+### 2.3 “第一名分钟”被误当成产品答案
+
+分钟扫描必然会产生一个最高分,但最高分不等于真实出生分钟。旧交互即使同时声明低置信度,仍会把一个具体分钟写成“建议暂用时间”,视觉上压过候选范围和不确定性说明。
+
+V4 将 `representativeTime` 限定为内部聚类数据。公开快照中的 `canConfirmExactMinute` 是字面量 `false`;UI 只显示 `startTime–endTime`,不显示峰值分钟。
+
+### 2.4 没有稳定的事件身份和修订链
+
+用户可能先说“某年发生”,后续补成“某年某月”,也可能纠正原先年份。覆盖旧记录会丢失审计信息;新建一条记录又会重复计分。
+
+V4 使用 `eventId + revision`:
+
+- `eventId` 表示同一人生事件;
+- 每次补充创建 append-only revision;
+- `supersedesRevisionId` 指向上一版本;
+- 评分只读取每个 `eventId` 的最新 revision。
+
+### 2.5 会话状态不足以承担业务状态
+
+浏览器会话可能被删除、刷新或从另一设备继续。旧流程把关键进度附着在聊天消息上,难以保证幂等、恢复、并发提交、扣费和原问题 handoff。
+
+V4 将 Case、Turn、Event Revision、Snapshot、Job 和 Handoff 独立持久化;聊天会话只是入口和展示容器,不再是生时校正业务真源。
+
+## 3. 产品合同
+
+### 3.1 输入
+
+- 已保存出生日期;
+- 用户声明的候选时间和不确定范围;
+- 出生地点坐标与时区;
+- 固定计算口径:Lahiri、Mean Node、一分钟步长;
+- 用户主动提供的人生事件。
+
+### 3.2 证据领域
+
+| 领域 | 典型事件 | 评分状态 |
+| --- | --- | --- |
+| `education` | 升学、复读、毕业、转学 | scoreable |
+| `relocation` | 搬家、长期迁居、离乡 | scoreable |
+| `relationship` | 重要关系开始或结束 | scoreable |
+| `career` | 入职、离职、转行、职责突变 | scoreable |
+| `finance` | 收入、负债、资产明显变化 | scoreable |
+| `health_pressure` | 疾病、手术、事故、长期压力起点 | scoreable |
+| `family` | 家庭结构或亲属重大事件 | context-only |
+| `other` | 其他明确、重要、可核对事件 | 按领域能力决定 |
+
+家庭事件先保留为上下文,不应为了“凑够领域”接入没有可靠 scorer 的技术层。
+
+### 3.3 输出
+
+- 一个主要候选范围;
+- 可选的次级候选范围;
+- 支持该范围的事件;
+- 冲突或区分力不足的事件;
+- 稳定性门结果;
+- 用户是否已主动保存该范围。
+
+### 3.4 永久禁止
+
+- 不确认单分钟;
+- 不把 `representativeTime` 作为公开结论;
+- 不自动保存候选范围;
+- 不修改 `profiles.active_birth_time`;
+- 不因聊天文案或模型失败丢弃已经持久化的答案;
+- 不用区间中点伪造事件日期;
+- 不把同一事件的日期补充重复计分。
+
+## 4. 两阶段问题规划
+
+### 4.1 阶段一:领域覆盖
+
+按低回忆成本优先收集七个领域。每次只问一个开放问题,要求用户自己提供事件与尽可能准确的年月,不先展示系统猜测的年份窗口。
+
+当前确定性顺序为:
+
+```text
+education → relocation → relationship → career → finance → health_pressure → family
+```
+
+规划器已经保留 `candidateSplitByDomain` 输入;未来只有当评分引擎能给出可解释的领域区分力时,才允许在不增加模型自由度的前提下动态排序。
+
+### 4.2 阶段二:日期精度修订
+
+完成领域覆盖后,对最新 revision 仍不是 `day` 精度的 scoreable 事件定向追问:
+
+- 问题保存 `targetEventId`;
+- Turn 保存 `questionTargetEventId`;
+- Worker 用该 ID 将回答追加到原事件;
+- 如果回答没有可解析日期,不创建伪 revision;
+- 已经追问过或用户跳过的事件写入尝试集合,不循环追问。
+
+如果所有可修订事件都已处理但仍未通过稳定性门,则询问新的、日期明确的重要事件;用户可以暂停或结束。
+
+## 5. 状态机
+
+### 5.1 Case 状态
+
+```mermaid
+stateDiagram-v2
+ [*] --> awaiting_answer: create case
+ awaiting_answer --> processing: submit answer
+ processing --> awaiting_answer: worker plans next question
+ processing --> range_ready: range passes gate
+ awaiting_answer --> paused: pause
+ paused --> awaiting_answer: resume
+ awaiting_answer --> abandoned: abandon
+ range_ready --> abandoned: abandon without save
+ range_ready --> range_ready: explicitly save accepted range
+```
+
+| 状态 | 含义 | `currentQuestion` |
+| --- | --- | --- |
+| `awaiting_answer` | 等待用户回答 | 必须非空 |
+| `processing` | 答案已保存,后台处理中 | 空 |
+| `range_ready` | 有候选范围;可能尚未保存 | 可空;稳定范围就绪时为空 |
+| `paused` | 用户主动暂停 | 保留可恢复进度 |
+| `abandoned` | 用户结束本次校正 | 空 |
+
+关键不变量:证据不足时必须生成下一题,不能出现 `status=awaiting_answer` 且 `currentQuestion=null`。
+
+### 5.2 Worker 阶段
+
+```text
+extracting_evidence
+ → scoring_candidates
+ → checking_robustness
+ → planning_question
+ → collecting_evidence | complete
+```
+
+Case 状态描述用户能做什么,Phase 描述系统正在做什么。两者不能混用。
+
+### 5.3 Job 状态
+
+```text
+pending → processing → completed
+ └→ failed
+pending/expired processing → processing by another worker
+obsolete input → stale
+```
+
+Job 使用十分钟 lease。只有持有有效 lease 且输入 case version、证据 hash、计算口径 hash 仍匹配的 Worker 可以完成任务。
+
+### 5.4 Handoff 状态
+
+```text
+pending → claimed → executing → consumed
+```
+
+过期的 `claimed/executing` lease 可恢复为 `pending`。Handoff 使用 `requestId`、`claimActionId` 和 settlement receipt 保证多设备重试不会重复执行或重复扣费。
+
+## 6. 数据模型
+
+### 6.1 Case
+
+Case 冻结一次校正的业务口径:
+
+- `calculationSpec`;
+- `calculationSpecHash`;
+- `evidenceSetHash`;
+- `version`;
+- `currentQuestion`;
+- `latestSnapshot`;
+- `acceptedRange`。
+
+`acceptedRange` 在用户点击“保存这个范围”前始终为 `null`。
+
+### 6.2 Turn
+
+Turn 是用户回答的不可变输入记录:
+
+- 本轮问题 ID、领域和目标事件 ID;
+- 可见问题文本;
+- 用户原始回答;
+- `actionId`;
+- 提交时的 case version。
+
+失败重试时可以恢复同一个问题,不需要从聊天文本反向猜测当时问了什么。
+
+### 6.3 Event 与 Event Revision
+
+Event 提供稳定身份;Revision 保存领域、事件类型、摘要、原文、日期范围、精度和评分资格。
+
+评分前执行 `latestEventRevisions()`,确保同一事件只使用最新版本。原始回答保留,技术评分不依赖经过润色的叙事文案。
+
+### 6.4 Candidate Snapshot
+
+每次评分都生成不可变 Snapshot:
+
+- 输入证据 hash;
+- 计算口径 hash;
+- 算法版本;
+- 全部分钟候选分数;
+- 候选聚类;
+- 稳定性结果;
+- 决策门原因。
+
+Snapshot 使刷新、复算和算法升级可审计,不用覆盖上一轮结果。
+
+## 7. 候选聚类与稳定性门
+
+### 7.1 聚类
+
+当前算法取峰值分数的相对 `0.97` 以上候选,将相邻分钟合并为 cluster,并按峰值和 score mass 排序。
+
+`representativeTime` 仅用于内部描述 cluster 峰值。公开结果使用 `startTime` 和 `endTime`。
+
+### 7.2 可保存范围门槛
+
+主要范围必须同时满足:
+
+- 至少 5 个 scoreable 事件;
+- 至少 3 个 scoreable 领域;
+- cluster 宽度至少 2 分钟,拒绝单分钟结果;
+- cluster 宽度不超过 15 分钟;
+- 邻近分钟支持至少 2 分钟;
+- leave-one-out 保留率至少 0.8;
+- 日期敏感性保留率至少 0.8;
+- 计算口径 hash 与建案时一致。
+
+无论是否通过,`canConfirmExactMinute` 永远为 `false`。
+
+## 8. 请求与后台执行边界
+
+### 8.1 同步 API 负责
+
+- 认证和输入校验;
+- 幂等 action;
+- optimistic case version 检查;
+- 保存 Turn;
+- 创建 pending Job;
+- 返回 `202` 和可轮询 Job。
+
+### 8.2 Worker 负责
+
+- 抽取新事件或定向修订;
+- 计算 evidence hash;
+- 调用 Python 候选引擎;
+- 聚类与稳定性门;
+- 生成 Snapshot;
+- 规划下一题;
+- 原子完成 Job 和 Case 状态迁移。
+
+非关键叙事模型不在 V4 完成链路上。确定性问题和业务状态在模型不可用时仍可继续。
+
+## 9. 数据库不变量
+
+迁移 `20260726020000_birth_time_rectification_v4.sql` 将关键规则下沉到 PostgreSQL:
+
+1. 同一用户最多一个未结束、未接受范围的活动 Case;
+2. `actionId` 幂等,同一 action 不能绑定不同问题;
+3. `expectedCaseVersion` 不匹配时拒绝陈旧写入;
+4. 未通过快照门或范围与最新主要 cluster 不一致时不能保存;
+5. 有效 Worker lease 不能被抢占,过期 lease 可以接管;
+6. Worker 只能完成仍匹配输入 hash 和 version 的 Job;
+7. Handoff claim、begin、refund 和 settlement 可重试;
+8. 一次 Handoff 最多产生一次成功扣费;
+9. 取消的请求可以重新生成 request key;
+10. 整个 V4 migration 不更新 `profiles.active_birth_time`。
+
+## 10. UI 设计
+
+### 10.1 首屏
+
+必须同时告诉用户:
+
+- 正在比较的声明候选边界;
+- 当前流程先核对经历;
+- 结果只会是候选范围,不是已确认分钟;
+- 原咨询问题已保留。
+
+### 10.2 处理中
+
+提交后立即显示“回答已经保存,计算在后台继续”。允许用户离开页面,不用让浏览器请求一直等待。
+
+### 10.3 日期修订
+
+日期修订问题必须有可见 label 和输入框,不得只显示空卡片。问题明确允许“不记得/跳过”,避免为了通过流程而编造日期。
+
+### 10.4 结果
+
+只显示:
+
+- 主要/次级候选范围;
+- 支持经历;
+- 冲突或区分力不足;
+- 不确定性说明;
+- “保存这个范围”操作。
+
+不显示内部事件 ID、hash、技术 packet、模型错误、评分明细或峰值分钟。
+
+### 10.5 保存与继续咨询
+
+点击“保存这个范围”只写 `acceptedRange`。之后用户可以把该范围带回原问题;咨询服务将其作为“未验证候选范围”使用,而不是已确认出生时间。
+
+## 11. 错误与恢复策略
+
+- 输入不合法:同步返回稳定中文错误,不创建 Job;
+- 陈旧版本:返回冲突,客户端刷新 Case;
+- Worker 失败:Job 标记 failed,恢复原问题;
+- Worker 崩溃:lease 到期后其他 Worker 接管;
+- 证据不足:生成下一题,不伪装成技术异常;
+- 用户暂停:保留 Case、事件和问题;
+- 用户结束:Case 进入 abandoned,出生资料不改写;
+- 页面刷新:从活动 Case、Job 和事件台账恢复;
+- 多设备 handoff:由数据库 lease 和 settlement receipt 仲裁。
+
+## 12. 已落地代码边界
+
+```text
+frontend/src/lib/rectification-v4/ 领域模型、规划、评分适配、Store、Worker
+frontend/src/app/api/rectification/v4/ HTTP API
+frontend/src/components/rectification-v4-panel.tsx
+frontend/src/hooks/use-rectification-v4.ts
+frontend/scripts/rectification-v4-worker.mts
+scripts/active_rectification_events_v4.py
+frontend/supabase/migrations/20260726020000_birth_time_rectification_v4.sql
+frontend/tests/rectification-v4-*.test.ts
+tests/test_active_rectification_events_v4.py
+```
+
+首页旧聊天入口继续保留外壳和历史兼容,但新的活动流程由 `RectificationV4Panel` 和 V4 API 驱动。
+
+## 13. 本地验收结果
+
+截至 2026-07-26:
+
+- 前端 V4、handoff、replay、consultation continuation:33 个测试通过;
+- Python 引擎:2 个测试通过;
+- 目标 ESLint:通过;
+- `npm run build -- --webpack`:通过,27 个页面生成成功;
+- PostgreSQL:12 项迁移和并发/扣费不变量通过;
+- 真实 PostgreSQL + Python Engine E2E:最终进入 `range_ready`,主要范围 `05:26–05:30`;
+- E2E 后 `active_birth_time` 保持原值,`acceptedRange` 保持空值;
+- 静态浏览器预览:日期修订输入框、范围保存按钮、不确认分钟文案和 390px 无横向溢出均通过。
+
+本轮没有提交、推送或部署。由于当前本地认证浏览器与隔离 V4 数据库/服务环境没有安全地连在一起,尚未声称“真实登录态端到端 UI”已验收;发布前仍需在正确的本地或 staging 认证环境执行一次完整用户操作 smoke。
+
+## 14. 发布前必须补齐
+
+1. 应用迁移并核对 migration ledger;
+2. 启动独立 Worker,确认部署环境包含数据库和 Python API 配置;
+3. 用测试账户完成:建案 → 七领域 → 日期修订 → 范围就绪 → 主动保存 → 原问题 handoff;
+4. 证明刷新和另一设备可恢复;
+5. 证明重复提交、过期版本和 Worker 接管不重复事件、不重复扣费;
+6. 再次核对 `profiles.active_birth_time` 未被 V4 路径更新;
+7. 将验收绑定到精确部署 Git SHA,而不是只看 HTTP 200。
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 4527f605..acb1727f 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -32,6 +32,7 @@
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"thinking-orbs": "^0.1.1",
+ "tsx": "^4.23.1",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.76"
},
@@ -43,7 +44,6 @@
"eslint": "^9",
"eslint-config-next": "16.2.10",
"supabase": "^2.109.1",
- "tsx": "^4.23.1",
"typescript": "^5"
}
},
@@ -671,7 +671,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -688,7 +687,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -705,7 +703,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -722,7 +719,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -739,7 +735,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -756,7 +751,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -773,7 +767,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -790,7 +783,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -807,7 +799,6 @@
"cpu": [
"arm"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -824,7 +815,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -841,7 +831,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -858,7 +847,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -875,7 +863,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -892,7 +879,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -909,7 +895,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -926,7 +911,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -943,7 +927,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -960,7 +943,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -977,7 +959,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -994,7 +975,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1011,7 +991,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1028,7 +1007,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1045,7 +1023,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1062,7 +1039,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1079,7 +1055,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1096,7 +1071,6 @@
"cpu": [
"x64"
],
- "dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -5477,7 +5451,6 @@
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -6321,7 +6294,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
- "dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -11008,7 +10980,6 @@
"version": "4.23.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
diff --git a/frontend/package.json b/frontend/package.json
index bff01bc2..3309278f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,7 +13,8 @@
"db:migrate": "node scripts/db-migrate.mjs",
"db:migrate:check": "node scripts/db-migrate.mjs --check",
"lint": "eslint",
- "data:china": "node scripts/pull-china-locations.mjs"
+ "data:china": "node scripts/pull-china-locations.mjs",
+ "worker:rectification-v4": "tsx scripts/rectification-v4-worker.mts"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
@@ -40,6 +41,7 @@
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2",
"thinking-orbs": "^0.1.1",
+ "tsx": "^4.23.1",
"tw-animate-css": "^1.4.0",
"zod": "^3.25.76"
},
@@ -51,7 +53,6 @@
"eslint": "^9",
"eslint-config-next": "16.2.10",
"supabase": "^2.109.1",
- "tsx": "^4.23.1",
"typescript": "^5"
}
}
diff --git a/frontend/scripts/rectification-v4-worker.mts b/frontend/scripts/rectification-v4-worker.mts
new file mode 100644
index 00000000..72cac9cd
--- /dev/null
+++ b/frontend/scripts/rectification-v4-worker.mts
@@ -0,0 +1,23 @@
+import { setTimeout as sleep } from "node:timers/promises";
+import { createRectificationV4CandidateEngine } from "../src/lib/rectification-v4/candidate-engine.ts";
+import { createRectificationV4SupabaseStore } from "../src/lib/rectification-v4/supabase-store.ts";
+import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts";
+import { createAdminSupabaseClient } from "../src/lib/supabase/admin-client-core.ts";
+
+const intervalMs = Number(process.env.RECTIFICATION_V4_WORKER_POLL_MS ?? "1000");
+const once = process.argv.includes("--once");
+let stopped = false;
+for (const signal of ["SIGINT", "SIGTERM"] as const) process.once(signal, () => { stopped = true; });
+
+const worker = createRectificationV4Worker({
+ store: createRectificationV4SupabaseStore(createAdminSupabaseClient()),
+ engine: createRectificationV4CandidateEngine({
+ apiBase: process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200",
+ }),
+});
+
+do {
+ const worked = await worker.runOnce();
+ if (once) break;
+ if (!worked) await sleep(Number.isFinite(intervalMs) && intervalMs >= 100 ? intervalMs : 1000);
+} while (!stopped);
diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts
index 9f151eb9..aa821bcd 100644
--- a/frontend/src/app/api/account/route.ts
+++ b/frontend/src/app/api/account/route.ts
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import {
parseRectificationPriceCredits,
} from "@/lib/birth-time-consultation-consent";
-import { resolveAccountRectificationCase } from "@/lib/account-rectification-case";
+import { resolveAccountRectificationCase, resolveAccountRectificationV4Case } from "@/lib/account-rectification-case";
import {
accountProfilePatchSchema,
applyAccountProfileConcurrencyGuards,
@@ -26,6 +26,14 @@ function isMissingProfileColumn(error: { code?: string; message?: string } | nul
|| message.includes("column");
}
+function isMissingRectificationV4Relation(error: { code?: string; message?: string } | null) {
+ const message = error?.message?.toLowerCase() ?? "";
+ return error?.code === "42P01"
+ || error?.code === "PGRST205"
+ || message.includes('relation "public.birth_time_rectification_v4_cases" does not exist')
+ || (message.includes("schema cache") && message.includes("birth_time_rectification_v4_cases"));
+}
+
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
@@ -40,6 +48,19 @@ export async function GET() {
process.env.RECTIFICATION_PRICE_CREDITS,
);
const admin = createAdminSupabaseClient();
+ const { data: rectificationV4CaseData, error: rectificationV4CaseError } = await admin
+ .from("birth_time_rectification_v4_cases")
+ .select("id,status,version,accepted_range_start,updated_at")
+ .eq("user_id", user.id)
+ .neq("status", "abandoned")
+ .is("accepted_range_start", null)
+ .order("updated_at", { ascending: false })
+ .limit(1);
+ if (rectificationV4CaseError && !isMissingRectificationV4Relation(rectificationV4CaseError)) {
+ return NextResponse.json({ error: "暂时无法读取生时校正状态" }, { status: 500 });
+ }
+ const rectificationV4CaseRows = rectificationV4CaseError ? [] : rectificationV4CaseData;
+
const { data: rectificationCaseRows, error: rectificationCaseError } = await admin
.from("birth_time_rectification_cases")
.select("id,journey_protocol,status,turn_version,revision_of_case_id,baseline_active_time,declared_birth_input,updated_at")
@@ -82,7 +103,9 @@ export async function GET() {
if (profileError || !profile) {
return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 });
}
- const rectificationCase = resolveAccountRectificationCase(
+ const rectificationCase = resolveAccountRectificationV4Case(
+ Array.isArray(rectificationV4CaseRows) ? rectificationV4CaseRows : [],
+ ) ?? resolveAccountRectificationCase(
profile,
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
);
diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts
index e2c798ea..691de977 100644
--- a/frontend/src/app/api/consult/route.ts
+++ b/frontend/src/app/api/consult/route.ts
@@ -5,6 +5,7 @@ import {
getGeneralJyotishAgent,
getJyotishAgent,
runConsultationWorkflow,
+ toAgentConsultationContext,
} from "@/mastra";
import {
languageModelConfigurationMessage,
@@ -33,8 +34,9 @@ import {
} from "@/lib/consultation-route-service";
import {
createRectificationHandoffService,
+ createRectificationV4HandoffService,
type RectificationHandoffExecution,
- type RectificationHandoffService,
+ type RectificationV4HandoffExecution,
} from "@/lib/rectification-handoff-service";
import { z } from "zod";
@@ -56,20 +58,38 @@ const chatRequestMetadataSchema = z.object({
.default([]),
});
-const rectificationHandoffSchema = z.object({
+const rectificationV3HandoffSchema = z.object({
+ protocol: z.literal("conversational-evidence-v3").optional(),
caseId: z.string().uuid(),
turnVersion: z.number().int().nonnegative(),
claimActionId: z.string().uuid(),
requestId: z.string().uuid(),
}).strict();
+const rectificationV4HandoffSchema = z.object({
+ protocol: z.literal("rectification-evidence-v4"),
+ caseId: z.string().uuid(),
+ caseVersion: z.number().int().nonnegative(),
+ claimActionId: z.string().uuid(),
+ requestId: z.string().uuid(),
+}).strict();
+
+const v4ContinuationRequestSchema = z.object({
+ ...chatRequestMetadataSchema.shape,
+ consultationMode: consultationBirthTimeModeSchema,
+ question: z.string().trim().min(1).max(500),
+ theme: z.enum(["career", "marriage", "wealth", "timing", "general"]),
+ entrypoint: z.undefined().optional(),
+ rectificationHandoff: rectificationV4HandoffSchema,
+}).strict();
+
const chartChatRequestSchema = consultationInputSchema.extend({
...chatRequestMetadataSchema.shape,
consultationMode: consultationBirthTimeModeSchema.exclude(["general_no_birth_time"])
.optional()
.default("verified_chart"),
entrypoint: consultationEntrypointSchema.optional(),
- rectificationHandoff: rectificationHandoffSchema.optional(),
+ rectificationHandoff: rectificationV3HandoffSchema.optional(),
}).strict();
const generalChatRequestSchema = z.object({
@@ -80,7 +100,11 @@ const generalChatRequestSchema = z.object({
entrypoint: z.undefined().optional(),
}).strict();
-const chatRequestSchema = z.union([generalChatRequestSchema, chartChatRequestSchema]);
+const chatRequestSchema = z.union([
+ v4ContinuationRequestSchema,
+ generalChatRequestSchema,
+ chartChatRequestSchema,
+]);
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
@@ -94,6 +118,54 @@ function chinaCalendarDate(now: Date) {
return new Date(now.getTime() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
}
+function rangeBoundaryWorkflowContext(
+ start: Awaited>,
+ end: Awaited>,
+ acceptedRange: Readonly<{ start: string; end: string }>,
+) {
+ const startConsumer = start.consumer_context;
+ const endConsumer = end.consumer_context;
+ return {
+ ...start,
+ success: start.success && end.success,
+ consumer_context: {
+ ...startConsumer,
+ core_status: startConsumer.core_status === "blocked" || endConsumer.core_status === "blocked"
+ ? "blocked"
+ : startConsumer.core_status === "degraded" || endConsumer.core_status === "degraded"
+ ? "degraded"
+ : "ready",
+ available_layers: startConsumer.available_layers.filter((layer) =>
+ endConsumer.available_layers.includes(layer)),
+ missing_route_layers: [...new Set([
+ ...startConsumer.missing_route_layers,
+ ...endConsumer.missing_route_layers,
+ ])],
+ hard_blockers: [...new Set([
+ ...startConsumer.hard_blockers,
+ ...endConsumer.hard_blockers,
+ ])],
+ answer_policy: {
+ ...startConsumer.answer_policy,
+ can_answer_direction: startConsumer.answer_policy.can_answer_direction
+ && endConsumer.answer_policy.can_answer_direction,
+ can_answer_precise_timing: false,
+ birth_time_confidence: "accepted_candidate_range",
+ candidate_is_confirmed: false,
+ require_boundary_agreement: true,
+ },
+ },
+ candidate_range: {
+ ...acceptedRange,
+ claim_status: "candidate_range_not_birth_time_truth",
+ },
+ range_boundary_contexts: {
+ start: toAgentConsultationContext(start),
+ end: toAgentConsultationContext(end),
+ },
+ };
+}
+
async function recordModelUsage(
accounting: ReturnType,
userId: string,
@@ -182,25 +254,21 @@ export async function POST(request: Request) {
const handoff = "rectificationHandoff" in parsed.data
? parsed.data.rectificationHandoff
: undefined;
- let handoffService: RectificationHandoffService | null = null;
- let handoffExecution: RectificationHandoffExecution | null = null;
+ const v4Handoff = handoff?.protocol === "rectification-evidence-v4";
+ let handoffExecution: RectificationHandoffExecution | RectificationV4HandoffExecution | null = null;
+ let settleHandoffRequest: ((emitted: boolean) => Promise) | null = null;
let handoffSettlement: Promise | null = null;
async function settleHandoff(emitted: boolean) {
- if (!handoff || !handoffService || !handoffExecution
+ if (!settleHandoffRequest || !handoffExecution
|| handoffExecution.status !== "ready") return;
- handoffSettlement ??= handoffService.settle({
- userId,
- caseId: handoff.caseId,
- claimActionId: handoff.claimActionId,
- requestId: handoff.requestId,
- emitted,
- }).then(() => undefined);
+ handoffSettlement ??= settleHandoffRequest(emitted);
await handoffSettlement;
}
if (handoff) {
- if (!["verified_chart", "unverified_birth_time"].includes(parsed.data.consultationMode)
+ if ((!v4Handoff
+ && !["verified_chart", "unverified_birth_time"].includes(parsed.data.consultationMode))
|| parsed.data.entrypoint !== undefined
|| requestId !== handoff.requestId) {
return NextResponse.json(
@@ -212,15 +280,41 @@ export async function POST(request: Request) {
);
}
try {
- handoffService = createRectificationHandoffService(accounting);
- handoffExecution = await handoffService.beginExecution({
- userId,
- caseId: handoff.caseId,
- turnVersion: handoff.turnVersion,
- claimActionId: handoff.claimActionId,
- requestId: handoff.requestId,
- question: parsed.data.question,
- });
+ if (handoff.protocol === "rectification-evidence-v4") {
+ const service = createRectificationV4HandoffService(accounting);
+ handoffExecution = await service.beginExecution({
+ userId,
+ caseId: handoff.caseId,
+ caseVersion: handoff.caseVersion,
+ claimActionId: handoff.claimActionId,
+ requestId: handoff.requestId,
+ question: parsed.data.question,
+ });
+ settleHandoffRequest = (emitted) => service.settle({
+ userId,
+ caseId: handoff.caseId,
+ claimActionId: handoff.claimActionId,
+ requestId: handoff.requestId,
+ emitted,
+ }).then(() => undefined);
+ } else {
+ const service = createRectificationHandoffService(accounting);
+ handoffExecution = await service.beginExecution({
+ userId,
+ caseId: handoff.caseId,
+ turnVersion: handoff.turnVersion,
+ claimActionId: handoff.claimActionId,
+ requestId: handoff.requestId,
+ question: parsed.data.question,
+ });
+ settleHandoffRequest = (emitted) => service.settle({
+ userId,
+ caseId: handoff.caseId,
+ claimActionId: handoff.claimActionId,
+ requestId: handoff.requestId,
+ emitted,
+ }).then(() => undefined);
+ }
} catch {
return NextResponse.json(
{
@@ -275,6 +369,10 @@ export async function POST(request: Request) {
prepared = await prepareConsultationRoute({
userId,
mode: parsed.data.consultationMode,
+ ...(v4Handoff && handoffExecution?.status === "ready"
+ && "acceptedRange" in handoffExecution
+ ? { candidateRange: handoffExecution.acceptedRange }
+ : {}),
async loadProfile(profileUserId) {
const { data, error } = await supabase
.from("profiles")
@@ -439,7 +537,9 @@ export async function POST(request: Request) {
try {
const { history } = parsed.data;
const name = prepared.serverChart?.name ?? parsed.data.name;
- const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
+ const consultationMode: ConsultationBirthTimeMode = v4Handoff
+ ? "unverified_birth_time"
+ : prepared.consultationMode;
if (!shouldRunBirthChartWorkflow(consultationMode)) {
const result = await getGeneralJyotishAgent(selectedModel).stream([
{
@@ -484,6 +584,78 @@ export async function POST(request: Request) {
}
if (!prepared.serverChart) throw new Error("server_chart_truth_missing");
+ if (v4Handoff) {
+ if (!handoffExecution || handoffExecution.status !== "ready"
+ || !("acceptedRange" in handoffExecution)) {
+ throw new Error("rectification_v4_range_missing");
+ }
+ const acceptedRange = handoffExecution.acceptedRange;
+ const boundaryInput = (time: string) => {
+ const [hour, minute] = time.split(":").map(Number);
+ return consultationInputSchema.parse({
+ ...prepared.serverChart?.toolInput,
+ hour,
+ minute,
+ entryMode: "direct_chart",
+ question: resolvedQuestion.modelQuestion,
+ theme: parsed.data.theme,
+ });
+ };
+ const [startWorkflow, endWorkflow] = await Promise.all([
+ runConsultationWorkflow(boundaryInput(acceptedRange.start)),
+ runConsultationWorkflow(boundaryInput(acceptedRange.end)),
+ ]);
+ const workflowContext = rangeBoundaryWorkflowContext(
+ startWorkflow,
+ endWorkflow,
+ acceptedRange,
+ );
+ const workflowReceipt = consultationWorkflowReceipt(workflowContext);
+ const result = await getJyotishAgent(selectedModel, workflowContext).stream([
+ ...history.map((message) => message.role === "user"
+ ? { role: "user" as const, content: message.text }
+ : { role: "assistant" as const, content: message.text }),
+ {
+ role: "user",
+ content: [
+ currentTimeContext(requestTime),
+ name ? `用户称呼:${name}` : "",
+ resolvedQuestion.modelQuestion,
+ `本次只能使用已保存候选范围 ${acceptedRange.start}–${acceptedRange.end} 的两个边界共同支持的结论。`,
+ "不得选择中点、峰值或单一代表分钟,不得把候选范围说成已确认出生时间。",
+ ].filter(Boolean).join("\n"),
+ },
+ ]);
+ const completeAndRecordUsage = async () => {
+ await complete();
+ void recordModelUsage(
+ accounting,
+ userId,
+ requestId,
+ modelSelection.usageModelId,
+ result.totalUsage,
+ );
+ };
+ const settleInterrupted = (emitted: boolean) =>
+ settle(emitted ? completeAndRecordUsage : cancel);
+ return streamTextResponse(result.textStream, {
+ transformText: createBirthTimeModeOutputGuard("unverified_birth_time", false),
+ mode: "mastra",
+ requestId,
+ headers: {
+ "x-jyotish-workflow-route": workflowReceipt.route,
+ "x-jyotish-workflow-status": workflowReceipt.status,
+ "x-jyotish-technique-truth": workflowReceipt.techniqueTruth,
+ "x-jyotish-precise-timing": "blocked",
+ "x-jyotish-missing-layers": workflowReceipt.missingLayers,
+ "x-jyotish-birth-time-mode": "unverified_birth_time",
+ },
+ onFirstOutput: () => settle(completeAndRecordUsage),
+ onComplete: () => settle(completeAndRecordUsage),
+ onError: (_error, emitted) => settleInterrupted(emitted),
+ onCancel: settleInterrupted,
+ });
+ }
const toolInput = consultationInputSchema.parse({
...prepared.serverChart.toolInput,
// Unverified use is still a normal chart calculation with a hard answer
diff --git a/frontend/src/app/api/rectification/v4/_server.ts b/frontend/src/app/api/rectification/v4/_server.ts
new file mode 100644
index 00000000..32445068
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/_server.ts
@@ -0,0 +1,98 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { parseBirthTimeProfile } from "@/lib/birth-time-journey-adapters";
+import { assessBirthTime } from "@/lib/birth-time-journey";
+import { resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone";
+import type { CalculationSpec } from "@/lib/rectification-v4/contracts";
+import { createRectificationV4CaseService } from "@/lib/rectification-v4/case-service";
+import { RectificationV4StoreError } from "@/lib/rectification-v4/store";
+import { createRectificationV4SupabaseStore } from "@/lib/rectification-v4/supabase-store";
+import { createAdminSupabaseClient } from "@/lib/supabase/admin";
+import { isSupabaseConfigurationError } from "@/lib/supabase/config";
+import { createServerSupabaseClient } from "@/lib/supabase/server";
+
+const idSchema = z.string().uuid();
+
+export async function rectificationV4Context() {
+ const auth = await createServerSupabaseClient();
+ const { data: { user }, error } = await auth.auth.getUser();
+ if (error || !user) throw new RectificationV4HttpError(401, "请先登录后再继续生时校正。");
+ const admin = createAdminSupabaseClient();
+ return {
+ userId: user.id,
+ auth,
+ service: createRectificationV4CaseService(createRectificationV4SupabaseStore(admin)),
+ };
+}
+
+export async function requestBody(request: Request, schema: z.ZodType): Promise {
+ const body = await request.json().catch(() => null);
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) throw new RectificationV4HttpError(400, "提交内容不完整,请检查后重试。");
+ return parsed.data;
+}
+
+export function routeId(value: string): string {
+ const parsed = idSchema.safeParse(value);
+ if (!parsed.success) throw new RectificationV4HttpError(404, "没有找到这次生时校正记录。");
+ return parsed.data;
+}
+
+export async function calculationSpecForUser(
+ auth: Awaited>,
+ userId: string,
+): Promise {
+ const { data, error } = await auth.from("profiles")
+ .select("birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,latitude,longitude,timezone_id,timezone_offset")
+ .eq("id", userId).maybeSingle();
+ if (error) throw error;
+ if (!data) throw new RectificationV4HttpError(409, "请先补全出生日期、时间线索和出生地点。");
+ const profile = await resolveMissingBirthTimezoneOffset(data);
+ const assessment = parseBirthTimeProfile(profile);
+ const range = assessBirthTime(assessment, { kind: "unavailable" }).reportedRange;
+ return {
+ version: "rectification-calculation-spec-v4",
+ birthDate: assessment.date,
+ candidateRange: {
+ start: range.startTime ?? "00:00",
+ end: range.endTime ?? "23:59",
+ },
+ latitude: assessment.location.lat,
+ longitude: assessment.location.lon,
+ timezoneOffsetHours: assessment.location.tz,
+ ayanamsa: "lahiri",
+ nodeMode: "mean",
+ minuteStep: 1,
+ };
+}
+
+export class RectificationV4HttpError extends Error {
+ constructor(readonly status: number, message: string) {
+ super(message);
+ }
+}
+
+export function rectificationV4Error(error: unknown): NextResponse {
+ if (error instanceof RectificationV4HttpError) {
+ return NextResponse.json({ error: error.message }, { status: error.status });
+ }
+ if (error instanceof RectificationV4StoreError) {
+ const responses: Record = {
+ not_found: [404, "没有找到这次生时校正记录。"],
+ stale_version: [409, "记录已在其他位置更新,正在重新载入。"],
+ invalid_state: [409, "当前状态无法执行这个操作,请刷新后重试。"],
+ stale_job: [409, "这次计算已过期,请以最新结果为准。"],
+ lease_lost: [409, "计算任务已由其他进程接管,请稍后刷新。"],
+ };
+ const response = responses[error.code];
+ return NextResponse.json({ error: response[1] }, { status: response[0] });
+ }
+ if (error instanceof z.ZodError) {
+ return NextResponse.json({ error: "出生资料或提交内容格式不正确。" }, { status: 400 });
+ }
+ if (isSupabaseConfigurationError(error)) {
+ return NextResponse.json({ error: "生时校正服务尚未配置。" }, { status: 503 });
+ }
+ console.error("rectification_v4_route_failed", error);
+ return NextResponse.json({ error: "暂时无法处理,请稍后再试。" }, { status: 500 });
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/abandon/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/abandon/route.ts
new file mode 100644
index 00000000..8241cf02
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/abandon/route.ts
@@ -0,0 +1,12 @@
+import { rectificationV4Error } from "../../../_server";
+import { transitionCase } from "../../_action";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
+ try {
+ return await transitionCase(request, params, "abandon");
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/accept-range/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/accept-range/route.ts
new file mode 100644
index 00000000..f266f8ae
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/accept-range/route.ts
@@ -0,0 +1,22 @@
+import { NextResponse } from "next/server";
+import { acceptRangeRequestSchema } from "@/lib/rectification-v4/contracts";
+import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
+ try {
+ const body = await requestBody(request, acceptRangeRequestSchema);
+ const context = await rectificationV4Context();
+ const result = await context.service.acceptRange({
+ ...body,
+ userId: context.userId,
+ caseId: routeId((await params).caseId),
+ });
+ return result
+ ? NextResponse.json(result)
+ : NextResponse.json({ error: "当前结果还不足以保存这个范围。" }, { status: 409 });
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/answers/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/answers/route.ts
new file mode 100644
index 00000000..04f498b8
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/answers/route.ts
@@ -0,0 +1,18 @@
+import { NextResponse } from "next/server";
+import { answerRequestSchema } from "@/lib/rectification-v4/contracts";
+import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../_server";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
+ try {
+ const body = await requestBody(request, answerRequestSchema);
+ const context = await rectificationV4Context();
+ const result = await context.service.answer({ ...body, userId: context.userId, caseId: routeId((await params).caseId) });
+ return result
+ ? NextResponse.json(result, { status: 202 })
+ : NextResponse.json({ error: "当前没有待回答的问题,请刷新后重试。" }, { status: 409 });
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts
new file mode 100644
index 00000000..23690572
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts
@@ -0,0 +1,36 @@
+import { NextResponse } from "next/server";
+import { reviseEventRequestSchema } from "@/lib/rectification-v4/contracts";
+import { appendEventRevision } from "@/lib/rectification-v4/evidence-ledger";
+import { rectificationV4Context, rectificationV4Error, requestBody, routeId } from "../../../../../_server";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request, { params }: { params: Promise<{ caseId: string; eventId: string }> }) {
+ try {
+ const body = await requestBody(request, reviseEventRequestSchema);
+ const context = await rectificationV4Context();
+ const values = await params;
+ const caseId = routeId(values.caseId);
+ const eventId = routeId(values.eventId);
+ const current = await context.service.loadCase(context.userId, caseId);
+ if (!current) return NextResponse.json({ error: "没有找到这次生时校正记录。" }, { status: 404 });
+ const revision = appendEventRevision(current.events, {
+ eventId,
+ domain: body.domain,
+ eventKind: body.eventKind,
+ summary: body.summary,
+ rawText: body.rawText,
+ dateRange: body.dateRange,
+ scoreability: body.scoreability,
+ });
+ return NextResponse.json(await context.service.reviseEvent({
+ userId: context.userId,
+ caseId,
+ actionId: body.actionId,
+ expectedCaseVersion: body.expectedCaseVersion,
+ revision,
+ }), { status: 202 });
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/pause/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/pause/route.ts
new file mode 100644
index 00000000..25ebac8e
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/pause/route.ts
@@ -0,0 +1,12 @@
+import { rectificationV4Error } from "../../../_server";
+import { transitionCase } from "../../_action";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
+ try {
+ return await transitionCase(request, params, "pause");
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/resume/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/resume/route.ts
new file mode 100644
index 00000000..f0c871d3
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/resume/route.ts
@@ -0,0 +1,12 @@
+import { rectificationV4Error } from "../../../_server";
+import { transitionCase } from "../../_action";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request, { params }: { params: Promise<{ caseId: string }> }) {
+ try {
+ return await transitionCase(request, params, "resume");
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/route.ts
new file mode 100644
index 00000000..bbe25c56
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { rectificationV4Context, rectificationV4Error, routeId } from "../../_server";
+
+export const runtime = "nodejs";
+
+export async function GET(_request: Request, { params }: { params: Promise<{ caseId: string }> }) {
+ try {
+ const context = await rectificationV4Context();
+ const result = await context.service.loadCase(context.userId, routeId((await params).caseId));
+ return result ? NextResponse.json(result) : NextResponse.json({ error: "没有找到这次生时校正记录。" }, { status: 404 });
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/_action.ts b/frontend/src/app/api/rectification/v4/cases/_action.ts
new file mode 100644
index 00000000..decb0ba2
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/_action.ts
@@ -0,0 +1,18 @@
+import { NextResponse } from "next/server";
+import { caseActionRequestSchema } from "@/lib/rectification-v4/contracts";
+import { rectificationV4Context, requestBody, routeId } from "../_server";
+
+export async function transitionCase(
+ request: Request,
+ params: Promise<{ caseId: string }>,
+ kind: "pause" | "resume" | "abandon",
+) {
+ const body = await requestBody(request, caseActionRequestSchema);
+ const context = await rectificationV4Context();
+ return NextResponse.json(await context.service.transition({
+ ...body,
+ userId: context.userId,
+ caseId: routeId((await params).caseId),
+ kind,
+ }));
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/active/route.ts b/frontend/src/app/api/rectification/v4/cases/active/route.ts
new file mode 100644
index 00000000..4ac525c7
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/active/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { rectificationV4Context, rectificationV4Error } from "../../_server";
+
+export const runtime = "nodejs";
+
+export async function GET() {
+ try {
+ const context = await rectificationV4Context();
+ const result = await context.service.loadActive(context.userId);
+ return result ? NextResponse.json(result) : new NextResponse(null, { status: 204 });
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/api/rectification/v4/cases/route.ts b/frontend/src/app/api/rectification/v4/cases/route.ts
new file mode 100644
index 00000000..ece53c16
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/cases/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+import { createCaseRequestSchema } from "@/lib/rectification-v4/contracts";
+import { calculationSpecForUser, rectificationV4Context, rectificationV4Error, requestBody } from "../_server";
+
+export const runtime = "nodejs";
+
+export async function POST(request: Request) {
+ try {
+ const body = await requestBody(request, createCaseRequestSchema);
+ const context = await rectificationV4Context();
+ return NextResponse.json(await context.service.createCase({
+ userId: context.userId,
+ actionId: body.actionId,
+ calculationSpec: await calculationSpecForUser(context.auth, context.userId),
+ }));
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/api/rectification/v4/handoff/route.ts b/frontend/src/app/api/rectification/v4/handoff/route.ts
new file mode 100644
index 00000000..51f33631
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/handoff/route.ts
@@ -0,0 +1,30 @@
+import { createAdminSupabaseClient } from "@/lib/supabase/admin";
+import { createServerSupabaseClient } from "@/lib/supabase/server";
+import { createRectificationV4HandoffService } from "@/lib/rectification-handoff-service";
+import {
+ createRectificationV4HandoffHandlers,
+ type RectificationV4HandoffRouteDependencies,
+} from "@/lib/rectification-v4/handoff-route";
+
+export const runtime = "nodejs";
+
+const dependencies: RectificationV4HandoffRouteDependencies = {
+ async authenticate() {
+ const supabase = await createServerSupabaseClient();
+ const { data: { user }, error } = await supabase.auth.getUser();
+ return error || !user ? null : { userId: user.id };
+ },
+ service() {
+ return createRectificationV4HandoffService(createAdminSupabaseClient());
+ },
+};
+
+const handlers = createRectificationV4HandoffHandlers(dependencies);
+
+export async function GET(request: Request) {
+ return handlers.get(request);
+}
+
+export async function POST(request: Request) {
+ return handlers.post(request);
+}
diff --git a/frontend/src/app/api/rectification/v4/jobs/[jobId]/route.ts b/frontend/src/app/api/rectification/v4/jobs/[jobId]/route.ts
new file mode 100644
index 00000000..c37dbc02
--- /dev/null
+++ b/frontend/src/app/api/rectification/v4/jobs/[jobId]/route.ts
@@ -0,0 +1,14 @@
+import { NextResponse } from "next/server";
+import { rectificationV4Context, rectificationV4Error, routeId } from "../../_server";
+
+export const runtime = "nodejs";
+
+export async function GET(_request: Request, { params }: { params: Promise<{ jobId: string }> }) {
+ try {
+ const context = await rectificationV4Context();
+ const job = await context.service.loadJob(context.userId, routeId((await params).jobId));
+ return job ? NextResponse.json({ job }) : NextResponse.json({ error: "没有找到这次处理任务。" }, { status: 404 });
+ } catch (error) {
+ return rectificationV4Error(error);
+ }
+}
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index acc0d6fe..ba3808cb 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -1660,3 +1660,34 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.birth-time-clock-menu.select-content { width: 108px; min-width: 108px; }
.birth-time-clock-menu .select-item { justify-content: flex-start; }
+
+/* Birth-time rectification V4 */
+.rectification-v4-panel { width: min(860px, 100%); margin: 0 auto; padding: clamp(20px, 4vw, 40px); display: grid; gap: 20px; overflow-y: auto; }
+.rectification-v4-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
+.rectification-v4-header h2 { margin: 4px 0 8px; font-size: clamp(24px, 4vw, 36px); letter-spacing: -0.035em; }
+.rectification-v4-header p, .rectification-v4-context p, .rectification-v4-processing p, .rectification-v4-result p { margin: 0; color: var(--color-ink-secondary); line-height: 1.65; }
+.rectification-v4-eyebrow { color: var(--color-action) !important; font-size: var(--type-caption); font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
+.rectification-v4-context, .rectification-v4-processing, .rectification-v4-result, .rectification-v4-notice { padding: 18px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); }
+.rectification-v4-context { display: grid; gap: 6px; }
+.rectification-v4-processing { display: grid; gap: 8px; }
+.rectification-v4-ranges { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 14px 0 18px; }
+.rectification-v4-ranges > div { display: grid; gap: 5px; padding: 16px; border-radius: var(--radius-md); background: var(--color-surface); }
+.rectification-v4-ranges span { color: var(--color-ink-secondary); font-size: var(--type-caption); }
+.rectification-v4-ranges strong { font-size: 24px; letter-spacing: -.02em; }
+.rectification-v4-evidence-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
+.rectification-v4-evidence-grid h3 { margin: 0 0 8px; font-size: 15px; }
+.rectification-v4-evidence-grid ul { margin: 0; padding-left: 18px; color: var(--color-ink-secondary); line-height: 1.6; }
+.rectification-v4-uncertainty { margin-top: 16px !important; padding-top: 14px; border-top: 1px solid var(--color-border); font-size: var(--type-caption); }
+.rectification-v4-actions, .rectification-v4-footer { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-top: 16px; }
+.rectification-v4-saved { display: inline-flex; align-items: center; gap: 7px; color: var(--color-action); font-weight: 700; }
+.rectification-v4-saved svg, .rectification-v4-footer svg { width: 16px; height: 16px; }
+.rectification-v4-composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: end; }
+.rectification-v4-composer label { grid-column: 1 / -1; font-weight: 650; line-height: 1.55; }
+.rectification-v4-composer textarea { min-height: 112px; resize: vertical; }
+.rectification-v4-footer button { display: inline-flex; align-items: center; gap: 7px; min-height: 40px; border: 0; background: transparent; color: var(--color-ink-secondary); cursor: pointer; }
+.rectification-v4-footer button:disabled { cursor: default; opacity: .5; }
+@media (max-width: 680px) {
+ .rectification-v4-panel { padding: 18px 14px 24px; }
+ .rectification-v4-header { display: grid; }
+ .rectification-v4-ranges, .rectification-v4-evidence-grid { grid-template-columns: 1fr; }
+}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 88753e40..0a92db6d 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -15,6 +15,7 @@ import {
import { BirthTimeIntakeFields } from "@/components/birth-time-intake";
import { AppLoadingIndicator } from "@/components/app-loading-indicator";
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
+import type { RectificationV4Continuation } from "@/components/rectification-v4-panel";
import { ChatMessageContent } from "@/components/chat-message-content";
import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row";
import { ModelSelector } from "@/components/model-selector";
@@ -53,16 +54,10 @@ import {
type RectificationCardAction,
} from "@/lib/birth-time-consultation-consent";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
-import {
- ConversationalRectificationRequestError,
- conversationalRectificationHistoryForTurn,
- sendConversationalRectificationCommand,
-} from "@/lib/conversational-rectification/client";
import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts";
+import { claimRectificationV4Handoff } from "@/lib/rectification-v4/client";
import {
- createDurableRectificationQuestionHandoffClient,
createRectificationQuestionHandoffCoordinator,
- DurableRectificationHandoffError,
} from "@/lib/rectification-question-handoff";
import { useBirthTimeGuidedJourney } from "@/hooks/use-birth-time-guided-journey";
import type { ConversationalRectificationMessage } from "@/hooks/use-conversational-rectification";
@@ -210,10 +205,17 @@ type DailyStarlanguageApiResponse = {
};
type SessionReadResult = { readonly sessions: ChatSession[]; readonly fallbackSessionIds: string[] };
type ConsultationRectificationHandoff = Readonly<{
+ protocol?: "conversational-evidence-v3";
caseId: string;
turnVersion: number;
claimActionId: string;
requestId: string;
+}> | Readonly<{
+ protocol: "rectification-evidence-v4";
+ caseId: string;
+ caseVersion: number;
+ claimActionId: string;
+ requestId: string;
}>;
type PendingConsultation = {
readonly requestId: string;
@@ -969,9 +971,6 @@ export default function Home() {
const activeOnboardingRequestIdentity = useRef("");
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
const rectificationQuestionHandoff = useRef(createRectificationQuestionHandoffCoordinator());
- const durableRectificationQuestionHandoff = useRef(
- createDurableRectificationQuestionHandoffClient(),
- );
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
const rectificationOpenInFlight = useRef(false);
const rectificationContinuationInFlight = useRef(false);
@@ -1025,7 +1024,7 @@ export default function Home() {
|| !account
|| !modelCatalog
|| activeSession?.sessionType !== "birth_time_rectification"
- || visibleRectificationTurn
+ || activeSession.id === rectificationSessionId
|| rectificationLoading
|| rectificationMutationPending
|| rectificationContinuationPending
@@ -1042,7 +1041,7 @@ export default function Home() {
rectificationError,
rectificationLoading,
rectificationMutationPending,
- visibleRectificationTurn,
+ rectificationSessionId,
]);
useEffect(() => {
@@ -2061,217 +2060,39 @@ export default function Home() {
sourceSessionOverride: ChatSession | null = null,
) {
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
- || rectificationMutationPending
- || rectificationContinuationInFlight.current) return;
+ || rectificationMutationPending || rectificationContinuationInFlight.current) return;
const sourceSession = sourceSessionOverride ?? activeSession;
if (!sourceSession) return;
- const action = resolveRectificationCardAction({
- rectificationCase: account.rectificationCase,
- hasConfirmedBirthTime: account.hasConfirmedBirthTime,
- });
- const sourceBoundCaseId = sourceSession.sessionType === "birth_time_rectification"
- ? sourceSession.rectificationCaseId
- : null;
- const accountResumeCase = action === "resume" ? account.rectificationCase : null;
- const resumeTarget = sourceBoundCaseId
- ? {
- caseId: sourceBoundCaseId,
- turnVersion: accountResumeCase?.caseId === sourceBoundCaseId
- ? accountResumeCase.turnVersion
- : 0,
- }
- : accountResumeCase;
- const resumableSession = !sourceBoundCaseId && accountResumeCase
- ? sessions.find((session) => session.sessionType === "birth_time_rectification"
- && session.rectificationCaseId === accountResumeCase.caseId)
- ?? sessions.find((session) => session.sessionType === "birth_time_rectification"
- && session.rectificationCaseId === null)
- ?? null
- : null;
- const canReuseSourceRectificationSession = sourceSession.sessionType === "birth_time_rectification"
- && (sourceBoundCaseId !== null || accountResumeCase !== null);
- const rectificationSession = canReuseSourceRectificationSession
+ const existing = sourceSession.sessionType === "birth_time_rectification"
? sourceSession
- : resumableSession ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification");
- const reusingRectificationSession = canReuseSourceRectificationSession
- || resumableSession !== null;
- const localHandoff = rectificationQuestionHandoff.current.peek();
- const requestedQuestion = pendingConsultationQuestion
- ?? (reusingRectificationSession
- ? null
- : localHandoff?.question)
- ?? null;
+ : sessions.find((session) => session.sessionType === "birth_time_rectification") ?? null;
+ const rectificationSession = existing ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification");
+ const requestedQuestion = pendingConsultationQuestion ?? rectificationQuestionHandoff.current.peek()?.question ?? null;
+
rectificationOpenInFlight.current = true;
+ setRectificationLoading(true);
+ setRectificationError("");
+ setRectificationInitialTurn(null);
+ setRectificationOpeningAssistantText("");
+ setRectificationPendingQuestion(requestedQuestion);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
- setRectificationPendingQuestion(requestedQuestion);
- setRectificationInitialTurn(null);
- setRectificationOpeningAssistantText("");
- setRectificationError("");
- setRectificationLoading(true);
- if (!reusingRectificationSession) {
- setSessions((current) => [
- rectificationSession,
- ...current.filter((session) => session.id !== rectificationSession.id),
- ]);
- }
- if (sourceSession && sourceSession.id !== rectificationSession.id) {
- setRectificationReturnSessionId(sourceSession.id);
- }
+ if (sourceSession.id !== rectificationSession.id) setRectificationReturnSessionId(sourceSession.id);
setRectificationSessionId(rectificationSession.id);
activeSessionIdRef.current = rectificationSession.id;
setActiveSessionId(rectificationSession.id);
- try {
- let turn: ConversationalRectificationTurn;
- if (!resumeTarget) {
- const durable = requestedQuestion !== null || localHandoff !== null
- ? await durableRectificationQuestionHandoff.current.load()
- : null;
- if (durable && durable.status !== "consumed") {
- turn = durable.turn;
- } else {
- try {
- turn = await sendConversationalRectificationCommand({
- type: "start",
- actionId: globalThis.crypto.randomUUID(),
- modelId: rectificationSession.modelId,
- pendingConsultationQuestion: requestedQuestion,
- }, {
- onNarrativeDelta(text) {
- setRectificationOpeningAssistantText((current) => current + text);
- },
- });
- } catch (error) {
- // A stale account snapshot can make an existing unfinished case look
- // like a fresh start. The database rejects that second case with a
- // 409 to protect billing; refresh and resume the durable case instead.
- if (!(error instanceof ConversationalRectificationRequestError)
- || error.status !== 409) throw error;
- const latest = await fetchAccount();
- if (!latest.rectificationCase) throw error;
- setAccount(latest);
- turn = await sendConversationalRectificationCommand({
- type: "resume",
- caseId: latest.rectificationCase.caseId,
- actionId: globalThis.crypto.randomUUID(),
- turnVersion: latest.rectificationCase.turnVersion,
- }, {
- onNarrativeDelta(text) {
- setRectificationOpeningAssistantText((current) => current + text);
- },
- });
- }
- }
- } else {
- let current = resumeTarget;
- const canAttachQuestion = pendingConsultationQuestion
- && accountResumeCase?.caseId === current.caseId;
- if (canAttachQuestion) {
- try {
- turn = await durableRectificationQuestionHandoff.current.attach({
- caseId: current.caseId,
- turnVersion: current.turnVersion,
- question: pendingConsultationQuestion,
- });
- } catch (error) {
- if (!(error instanceof DurableRectificationHandoffError)
- || error.status !== 409) throw error;
- const latest = await fetchAccount();
- if (!latest.rectificationCase
- || latest.rectificationCase.caseId !== current.caseId) throw error;
- current = latest.rectificationCase;
- setAccount(latest);
- turn = await durableRectificationQuestionHandoff.current.attach({
- caseId: current.caseId,
- turnVersion: current.turnVersion,
- question: pendingConsultationQuestion,
- });
- }
- } else {
- try {
- turn = await sendConversationalRectificationCommand({
- type: "resume",
- caseId: current.caseId,
- actionId: globalThis.crypto.randomUUID(),
- turnVersion: current.turnVersion,
- }, {
- onNarrativeDelta(text) {
- setRectificationOpeningAssistantText((value) => value + text);
- },
- });
- } catch (error) {
- if (!(error instanceof ConversationalRectificationRequestError)
- || error.status !== 409) throw error;
- const latest = await fetchAccount();
- if (!latest.rectificationCase
- || latest.rectificationCase.caseId !== current.caseId) throw error;
- current = latest.rectificationCase;
- setAccount(latest);
- turn = await sendConversationalRectificationCommand({
- type: "resume",
- caseId: current.caseId,
- actionId: globalThis.crypto.randomUUID(),
- turnVersion: current.turnVersion,
- }, {
- onNarrativeDelta(text) {
- setRectificationOpeningAssistantText((value) => value + text);
- },
- });
- }
- }
- }
- const recoveredConversation = conversationalRectificationHistoryForTurn(turn);
- const firstSessionMessages = recoveredConversation.length > 0
- ? recoveredConversation.map(({ role, text }) => ({ role, text }))
- : !reusingRectificationSession && rectificationSession.messages.length === 0
- ? [{ role: "assistant" as const, text: turn.narrative.trim() }]
- : rectificationSession.messages;
- const boundSession = {
- ...rectificationSession,
- messages: firstSessionMessages,
- rectificationCaseId: turn.caseId,
- updatedAt: rectificationSession.rectificationCaseId === turn.caseId
- && firstSessionMessages === rectificationSession.messages
- ? rectificationSession.updatedAt
- : timestamp(),
- };
- setRectificationInitialTurn(turn);
- setRectificationOpeningAssistantText("");
- synchronizeRectificationQuestion(turn, sourceSession);
- setComposerNotice("");
- if (!reusingRectificationSession) {
- setSessions((current) => [boundSession, ...current.filter((session) => session.id !== boundSession.id)]);
- void rectificationPersistence.current.enqueue(
- boundSession.id,
- () => persistSession(boundSession, "create"),
- ).catch(() => {
- setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。");
- });
- } else if (boundSession !== rectificationSession) {
- updateSession(rectificationSession.id, () => boundSession);
- void rectificationPersistence.current.enqueue(
- boundSession.id,
- () => persistSession(boundSession),
- ).catch(() => {
- setComposerNotice("校正已经开始,但会话关联暂时未同步到云端。");
- });
- }
- } catch (caught) {
- const message = caught instanceof Error
- ? caught.message
- : "生时校正暂时无法继续,请稍后重试。";
- setRectificationError(message);
- setComposerNotice(message);
- if (!reusingRectificationSession) {
- setSessions((current) => current.filter((session) => session.id !== rectificationSession.id));
- setRectificationSessionId(null);
- if (sourceSession) {
- activeSessionIdRef.current = sourceSession.id;
- setActiveSessionId(sourceSession.id);
- }
+ try {
+ if (!existing) {
+ setSessions((current) => [rectificationSession, ...current.filter((session) => session.id !== rectificationSession.id)]);
+ await rectificationPersistence.current.enqueue(
+ rectificationSession.id,
+ () => persistSession(rectificationSession, "create"),
+ );
}
+ } catch {
+ setComposerNotice("生时校正已打开,但会话列表暂时未同步到云端。");
} finally {
rectificationOpenInFlight.current = false;
setRectificationLoading(false);
@@ -2711,7 +2532,8 @@ export default function Home() {
modelId: currentSession.modelId,
name: profile.name,
consultationMode: consultationRoute.mode,
- ...(consultationRoute.mode === "general_no_birth_time" ? {} : {
+ ...(consultationRoute.mode === "general_no_birth_time"
+ || rectificationHandoff?.protocol === "rectification-evidence-v4" ? {} : {
entrypoint: entrypoint ?? undefined,
year,
month,
@@ -2865,13 +2687,10 @@ export default function Home() {
}
- async function continueRectificationOriginalQuestion(question: string) {
+ async function continueRectificationOriginalQuestion(continuation: RectificationV4Continuation) {
+ const question = continuation.question;
if (rectificationContinuationInFlight.current || rectificationMutationPending
|| rectificationLoading || !activeSession || !account) return;
- const confirmedTurn = rectificationInitialTurn;
- if (!confirmedTurn || confirmedTurn.status !== "completed"
- || confirmedTurn.pendingConsultationQuestion !== question
- || !confirmedTurn.actions.includes("continue_original_question")) return;
if (account.credits <= 0) {
openAccountDialog("redeem", creditTrigger.current);
return;
@@ -2898,9 +2717,9 @@ export default function Home() {
setRectificationContinuationPending(true);
setRectificationError("");
try {
- const durableClaim = await durableRectificationQuestionHandoff.current.claim({
- caseId: confirmedTurn.caseId,
- turnVersion: confirmedTurn.turnVersion,
+ const durableClaim = await claimRectificationV4Handoff({
+ caseId: continuation.caseId,
+ caseVersion: continuation.caseVersion,
question,
});
if (durableClaim.status === "in_progress") {
@@ -2935,8 +2754,9 @@ export default function Home() {
null,
context.sessionId,
{
+ protocol: "rectification-evidence-v4",
caseId: durableClaim.caseId,
- turnVersion: durableClaim.turnVersion,
+ caseVersion: durableClaim.caseVersion,
claimActionId: durableClaim.claimActionId,
requestId: durableClaim.requestId,
},
@@ -2945,9 +2765,7 @@ export default function Home() {
);
if (completed) {
setRectificationPendingQuestion(null);
- setComposerNotice(confirmedTurn.candidate.status === "confirmed"
- ? "已使用新确认时间继续回答原问题。"
- : "已保留候选范围,并按未确认出生分钟的边界继续回答原问题。");
+ setComposerNotice("已按候选范围边界继续回答原问题。");
} else {
setComposerNotice("原问题仍保留,可再次点击继续回答。");
}
@@ -3249,7 +3067,7 @@ export default function Home() {
-
{rectificationCardAction === "resume"
- ? "继续同一案例,不重复收费。"
- : `固定费用 ${account.rectificationPriceCredits} 点;首轮有效分析后收取。`}
+
进度会自动保存;结果只作为候选范围,不会改写已填报出生时间。
{rectificationCardLabel}
@@ -3330,7 +3146,7 @@ export default function Home() {
continuationPending={rectificationContinuationPending}
onPendingChange={setRectificationMutationPending}
onTurn={handleConversationalRectificationTurn}
- onContinueOriginalQuestion={(question) => void continueRectificationOriginalQuestion(question)}
+ onContinueOriginalQuestion={(continuation) => void continueRectificationOriginalQuestion(continuation)}
/>
))}
diff --git a/frontend/src/components/conversational-birth-time-rectification.tsx b/frontend/src/components/conversational-birth-time-rectification.tsx
index 2c836dd0..f7e37416 100644
--- a/frontend/src/components/conversational-birth-time-rectification.tsx
+++ b/frontend/src/components/conversational-birth-time-rectification.tsx
@@ -8,7 +8,10 @@ import { ModelSelector } from "./model-selector.tsx";
import { Button } from "./ui/button.tsx";
import { Textarea } from "./ui/textarea.tsx";
import {
- useConversationalRectification,
+ RectificationV4Panel,
+ type RectificationV4Continuation,
+} from "./rectification-v4-panel.tsx";
+import {
type ConversationalRectificationMessage,
type ConversationalRectificationStoredMessage,
type ConversationalRectificationController,
@@ -319,31 +322,15 @@ type ConversationalBirthTimeRectificationProps = Readonly<{
messages: readonly ConversationalRectificationMessage[],
) => void;
onPendingChange?: (pending: boolean) => void;
- onContinueOriginalQuestion?: (question: string) => void;
+ onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>;
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
- const pendingChange = useRef(props.onPendingChange);
- useEffect(() => {
- pendingChange.current = props.onPendingChange;
- }, [props.onPendingChange]);
- useEffect(() => () => pendingChange.current?.(false), []);
- const controller = useConversationalRectification({
- initialTurn: props.initialTurn,
- initialMessages: props.initialMessages,
- modelId: props.selectedModelId,
- onTurn: props.onTurn,
- onPendingChange: props.onPendingChange,
- });
return (
-
);
diff --git a/frontend/src/components/rectification-v4-panel.tsx b/frontend/src/components/rectification-v4-panel.tsx
new file mode 100644
index 00000000..44ec4011
--- /dev/null
+++ b/frontend/src/components/rectification-v4-panel.tsx
@@ -0,0 +1,204 @@
+"use client";
+
+import { ArrowUp, Check, Pause, Play, Square } from "lucide-react";
+import { useMemo, useRef, useState } from "react";
+import { useRectificationV4 } from "@/hooks/use-rectification-v4";
+import type { CandidateCluster, LifeEventRevision } from "@/lib/rectification-v4/contracts";
+import { AppLoadingIndicator } from "./app-loading-indicator";
+import { Button } from "./ui/button";
+import { Textarea } from "./ui/textarea";
+
+const phaseCopy = {
+ extracting_evidence: "正在整理经历",
+ scoring_candidates: "正在比较候选时间",
+ checking_robustness: "正在做稳定性复核",
+ planning_question: "正在准备下一步问题",
+ collecting_evidence: "正在准备下一步问题",
+ complete: "正在整理结果",
+} as const;
+
+function minutes(time: string) {
+ const [hour = 0, minute = 0] = time.split(":").map(Number);
+ return hour * 60 + minute;
+}
+
+function inCluster(time: string, cluster: CandidateCluster) {
+ const value = minutes(time);
+ const start = minutes(cluster.startTime);
+ const end = minutes(cluster.endTime);
+ return end >= start ? value >= start && value <= end : value >= start || value <= end;
+}
+
+function latestEvents(events: readonly LifeEventRevision[]) {
+ const latest = new Map();
+ for (const event of events) {
+ const current = latest.get(event.eventId);
+ if (!current || current.revision < event.revision) latest.set(event.eventId, event);
+ }
+ return [...latest.values()].sort((left, right) => left.dateRange.start.localeCompare(right.dateRange.start));
+}
+
+function eventText(event: LifeEventRevision) {
+ return `${event.dateRange.label} · ${event.summary}`;
+}
+
+export type RectificationV4Continuation = Readonly<{
+ protocol: "rectification-evidence-v4";
+ question: string;
+ caseId: string;
+ caseVersion: number;
+ acceptedRange: Readonly<{ start: string; end: string }>;
+}>;
+
+export function RectificationV4Panel(props: Readonly<{
+ pendingConsultationQuestion?: string | null;
+ continuationPending?: boolean;
+ onPendingChange?: (pending: boolean) => void;
+ onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
+}>) {
+ const controller = useRectificationV4({
+ pendingConsultationQuestion: props.pendingConsultationQuestion,
+ onPendingChange: props.onPendingChange,
+ });
+ const [draft, setDraft] = useState("");
+ const composer = useRef(null);
+ const data = controller.data;
+ const caseValue = data?.case;
+ const snapshot = caseValue?.latestSnapshot;
+ const primary = snapshot?.clusters[0];
+ const allEvents = useMemo(() => latestEvents(data?.events ?? []), [data?.events]);
+ const eventById = useMemo(() => new Map(allEvents.map((event) => [event.eventId, event])), [allEvents]);
+ const evidence = useMemo(() => {
+ if (!snapshot || !primary) return { supporting: [] as LifeEventRevision[], conflicting: [] as LifeEventRevision[] };
+ const candidates = snapshot.candidates.filter((candidate) => inCluster(candidate.time, primary));
+ const supporting = new Set(candidates.flatMap((candidate) => candidate.supportingEventIds));
+ const conflicting = new Set(candidates.flatMap((candidate) => candidate.conflictingEventIds));
+ return {
+ supporting: [...supporting].map((id) => eventById.get(id)).filter((event): event is LifeEventRevision => Boolean(event)),
+ conflicting: [...conflicting].map((id) => eventById.get(id)).filter((event): event is LifeEventRevision => Boolean(event)),
+ };
+ }, [eventById, primary, snapshot]);
+
+ if (controller.loading) {
+ return ;
+ }
+ if (!caseValue) {
+ return {controller.error || "暂时无法打开生时校正。"}
;
+ }
+
+ const processing = caseValue.status === "processing" || ["pending", "processing"].includes(controller.job?.status ?? "");
+ const phase = controller.job?.phase ?? caseValue.phase;
+ const canAnswer = Boolean(caseValue.currentQuestion) && !processing && ["awaiting_answer", "range_ready"].includes(caseValue.status);
+ const accepted = caseValue.acceptedRange;
+ const handoff = controller.handoff;
+ const canContinue = Boolean(accepted && handoff?.status === "pending" && props.onContinueOriginalQuestion);
+
+ async function submit(event: React.FormEvent) {
+ event.preventDefault();
+ const answer = draft.trim();
+ if (!answer || !canAnswer) return;
+ const result = await controller.answer(answer);
+ if (result) setDraft("");
+ }
+
+ return (
+
+
+
+ {props.pendingConsultationQuestion && (
+
+ )}
+
+ {processing && (
+
+
+
回答已经保存。你可以离开此页,稍后回来继续。
+
+ )}
+
+ {snapshot && primary && !processing && (
+
+ 当前结果
+
+
主要候选范围{primary.startTime}–{primary.endTime}
+ {snapshot.clusters[1] &&
次级候选范围{snapshot.clusters[1].startTime}–{snapshot.clusters[1].endTime}
}
+
+
+
+
支持这个范围的经历
+ {evidence.supporting.length > 0 ?
{evidence.supporting.map((event) => - {eventText(event)}
)}
:
现有经历提供了初步支持,但还需要更多不同领域的事件。
}
+
+
+
仍有冲突或区分力不足
+ {evidence.conflicting.length > 0 ?
{evidence.conflicting.map((event) => - {eventText(event)}
)}
:
暂未发现明确冲突;范围仍会随新增事件变化。
}
+
+
+ 系统只保存通过邻近分钟、逐项排除和日期敏感性复核的范围;不会把峰值分钟当作真实出生时间。
+
+ {caseValue.currentQuestion && !accepted && }
+ {snapshot.canAcceptRange && !accepted && }
+ {accepted && 已保存 {accepted.start}–{accepted.end}}
+
+
+ )}
+
+ {caseValue.status === "paused" && 进度已保存。继续后会从下一道问题开始。
}
+ {caseValue.status === "abandoned" && 本次校正已结束,现有出生时间没有被改写。
}
+ {controller.error && {controller.error}
}
+
+ {canAnswer && (
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/hooks/use-rectification-v4.ts b/frontend/src/hooks/use-rectification-v4.ts
new file mode 100644
index 00000000..c93ed054
--- /dev/null
+++ b/frontend/src/hooks/use-rectification-v4.ts
@@ -0,0 +1,156 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type {
+ RectificationV4ApiResponse,
+ RectificationV4Handoff,
+ RectificationV4Job,
+} from "@/lib/rectification-v4/contracts";
+import {
+ RectificationV4RequestError,
+ acceptRectificationV4Range,
+ answerRectificationV4,
+ attachRectificationV4Question,
+ createRectificationV4,
+ loadActiveRectificationV4,
+ loadRectificationV4,
+ loadRectificationV4Handoff,
+ loadRectificationV4Job,
+ transitionRectificationV4,
+} from "@/lib/rectification-v4/client";
+
+function friendly(error: unknown): string {
+ return error instanceof Error ? error.message : "暂时无法处理,请稍后再试。";
+}
+
+export function useRectificationV4(input: {
+ readonly pendingConsultationQuestion?: string | null;
+ readonly onPendingChange?: (pending: boolean) => void;
+} = {}) {
+ const onPendingChange = input.onPendingChange;
+ const pendingConsultationQuestion = input.pendingConsultationQuestion?.trim() || null;
+ const [data, setData] = useState(null);
+ const [job, setJob] = useState(null);
+ const [handoff, setHandoff] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [pending, setPending] = useState(false);
+ const [error, setError] = useState("");
+ const mounted = useRef(true);
+
+ const setBusy = useCallback((value: boolean) => {
+ setPending(value);
+ onPendingChange?.(value);
+ }, [onPendingChange]);
+
+ const refresh = useCallback(async (caseId?: string) => {
+ const result = caseId ? await loadRectificationV4(caseId) : await loadActiveRectificationV4();
+ if (mounted.current) {
+ setData(result);
+ setJob(result?.job ?? null);
+ }
+ return result;
+ }, []);
+
+ useEffect(() => {
+ mounted.current = true;
+ void (async () => {
+ try {
+ const existingHandoff = await loadRectificationV4Handoff();
+ const result = existingHandoff
+ ? await loadRectificationV4(existingHandoff.caseId)
+ : await createRectificationV4();
+ let nextHandoff = existingHandoff;
+ if (pendingConsultationQuestion) {
+ if (existingHandoff && existingHandoff.question !== pendingConsultationQuestion) {
+ throw new RectificationV4RequestError(409, "已有另一个原问题等待继续,请先处理后再开始新的生时校正。");
+ }
+ nextHandoff ??= await attachRectificationV4Question({
+ caseId: result.case.id,
+ caseVersion: result.case.version,
+ question: pendingConsultationQuestion,
+ actionId: globalThis.crypto.randomUUID(),
+ });
+ }
+ if (mounted.current) {
+ setData(result);
+ setJob(result.job);
+ setHandoff(nextHandoff);
+ }
+ } catch (caught) {
+ if (mounted.current) setError(friendly(caught));
+ } finally {
+ if (mounted.current) setLoading(false);
+ }
+ })();
+ return () => { mounted.current = false; };
+ }, [pendingConsultationQuestion]);
+
+ useEffect(() => {
+ const jobId = job?.id;
+ if (!jobId || !["pending", "processing"].includes(job.status)) return;
+ const timer = window.setInterval(() => {
+ void loadRectificationV4Job(jobId).then(async (next) => {
+ if (!mounted.current) return;
+ setJob(next);
+ if (["completed", "failed", "stale"].includes(next.status) && data) {
+ window.clearInterval(timer);
+ const latest = await refresh(data.case.id);
+ if (next.status === "failed" && latest) setError("这次比较没有完成,回答已经保留,请再试一次。");
+ }
+ }).catch((caught) => {
+ if (mounted.current) setError(friendly(caught));
+ });
+ }, 1_000);
+ return () => window.clearInterval(timer);
+ }, [data, job, refresh]);
+
+ const mutate = useCallback(async (operation: () => Promise) => {
+ setBusy(true);
+ setError("");
+ try {
+ const result = await operation();
+ if (mounted.current) {
+ setData(result);
+ setJob(result.job);
+ }
+ return result;
+ } catch (caught) {
+ if (caught instanceof RectificationV4RequestError && caught.status === 409 && data) {
+ await refresh(data.case.id).catch(() => undefined);
+ }
+ if (mounted.current) setError(friendly(caught));
+ return null;
+ } finally {
+ if (mounted.current) setBusy(false);
+ }
+ }, [data, refresh, setBusy]);
+
+ return {
+ data,
+ job,
+ handoff,
+ loading,
+ pending,
+ error,
+ clearError: () => setError(""),
+ answer: (answer: string) => data
+ ? mutate(() => answerRectificationV4(data.case.id, data.case.version, answer))
+ : Promise.resolve(null),
+ pause: () => data
+ ? mutate(() => transitionRectificationV4(data.case.id, data.case.version, "pause"))
+ : Promise.resolve(null),
+ resume: () => data
+ ? mutate(() => transitionRectificationV4(data.case.id, data.case.version, "resume"))
+ : Promise.resolve(null),
+ abandon: () => data
+ ? mutate(() => transitionRectificationV4(data.case.id, data.case.version, "abandon"))
+ : Promise.resolve(null),
+ acceptRange: () => {
+ const primary = data?.case.latestSnapshot?.clusters[0];
+ return data && primary
+ ? mutate(() => acceptRectificationV4Range(data.case.id, data.case.version, primary.startTime, primary.endTime))
+ : Promise.resolve(null);
+ },
+ refresh: () => refresh(data?.case.id),
+ };
+}
diff --git a/frontend/src/lib/account-rectification-case.ts b/frontend/src/lib/account-rectification-case.ts
index d2f1d2bd..18521059 100644
--- a/frontend/src/lib/account-rectification-case.ts
+++ b/frontend/src/lib/account-rectification-case.ts
@@ -206,3 +206,29 @@ export function resolveAccountRectificationCase(
}
return null;
}
+
+
+const unfinishedV4Statuses = new Set([
+ "awaiting_answer", "processing", "range_ready", "paused",
+]);
+
+/** V4 cases already carry the calculation spec; creation atomically replaces a stale spec. */
+export function resolveAccountRectificationV4Case(rows: readonly unknown[]): AccountRectificationCaseState | null {
+ for (const value of rows) {
+ const row = record(value);
+ if (!row || typeof row.id !== "string" || typeof row.version !== "number"
+ || !Number.isSafeInteger(row.version) || row.version < 0
+ || typeof row.status !== "string"
+ || !unfinishedV4Statuses.has(row.status as AccountRectificationCaseState["status"])
+ || row.accepted_range_start !== null) continue;
+ return Object.freeze({
+ caseId: row.id,
+ journeyProtocol: "rectification-evidence-v4" as const,
+ status: row.status as AccountRectificationCaseState["status"],
+ turnVersion: row.version,
+ isRevision: false,
+ preservesActiveTime: true,
+ });
+ }
+ return null;
+}
diff --git a/frontend/src/lib/birth-time-consultation-consent.ts b/frontend/src/lib/birth-time-consultation-consent.ts
index 34c3748a..6d75226e 100644
--- a/frontend/src/lib/birth-time-consultation-consent.ts
+++ b/frontend/src/lib/birth-time-consultation-consent.ts
@@ -12,8 +12,8 @@ export type BirthTimeConsultationConsentState = Readonly<
export type AccountRectificationCaseState = Readonly<{
caseId: string;
- journeyProtocol: "conversational-evidence-v3";
- status: "starting" | "active" | "paused" | "confirming" | "completed" | "abandoned";
+ journeyProtocol: "conversational-evidence-v3" | "rectification-evidence-v4";
+ status: "starting" | "active" | "awaiting_answer" | "processing" | "range_ready" | "paused" | "confirming" | "completed" | "abandoned";
turnVersion: number;
isRevision: boolean;
preservesActiveTime: boolean;
@@ -32,6 +32,9 @@ const unfinishedRectificationStatuses = new Set = Readonly<{
userId: string;
mode: ConsultationBirthTimeMode;
+ candidateRange?: Readonly<{ start: string; end: string }>;
loadProfile: (userId: string) => Promise;
resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise;
reserve: () => Promise;
@@ -157,6 +158,7 @@ function legacyChinaPlaceLabel(profile: RecordValue): string | null {
function serverChartFromProfile(
value: unknown,
mode: Exclude,
+ candidateBoundary?: string,
): ServerChartConsultation {
const profile = record(value);
if (!profile) throw new ConsultationProfileTruthError("profile_incomplete");
@@ -195,8 +197,14 @@ function serverChartFromProfile(
if (!placeLabel) throw new ConsultationProfileTruthError("profile_incomplete");
let selectedTime: string;
- let selectedTimeKind: "reported" | "active";
- if (mode === "verified_chart") {
+ let selectedTimeKind: "reported" | "active" | "candidate_range_boundary";
+ if (candidateBoundary !== undefined) {
+ if (!isBirthClockTime(candidateBoundary)) {
+ throw new ConsultationProfileTruthError("profile_inconsistent");
+ }
+ selectedTime = candidateBoundary;
+ selectedTimeKind = "candidate_range_boundary";
+ } else if (mode === "verified_chart") {
if (birthTimeStatus !== "confirmed") {
throw new ConsultationProfileTruthError("mode_changed");
}
@@ -263,20 +271,26 @@ export async function prepareConsultationRoute(
try {
profile = await input.loadProfile(input.userId);
} catch (error) {
- if (input.mode === "general_no_birth_time") profile = null;
+ if (input.mode === "general_no_birth_time" && !input.candidateRange) profile = null;
else if (error instanceof ConsultationProfileTruthError) throw error;
else throw new ConsultationProfileTruthError("profile_unavailable");
}
- const consultationMode = input.mode === "general_no_birth_time"
- ? persistedChartMode(profile) ?? input.mode
- : input.mode;
+ const consultationMode = input.candidateRange
+ ? "unverified_birth_time"
+ : input.mode === "general_no_birth_time"
+ ? persistedChartMode(profile) ?? input.mode
+ : input.mode;
let serverChart: ServerChartConsultation | null = null;
if (consultationMode !== "general_no_birth_time") {
const profileValue = record(profile);
- const selectedTime = consultationMode === "verified_chart"
+ if (input.candidateRange && (!isBirthClockTime(input.candidateRange.start)
+ || !isBirthClockTime(input.candidateRange.end))) {
+ throw new ConsultationProfileTruthError("profile_inconsistent");
+ }
+ const selectedTime = input.candidateRange?.start ?? (consultationMode === "verified_chart"
? nullableClock(profileValue ?? {}, "active_birth_time")
- : nullableClock(profileValue ?? {}, "reported_birth_time");
+ : nullableClock(profileValue ?? {}, "reported_birth_time"));
try {
profile = await (input.resolveTimezoneOffset ?? ((value, time) => (
resolveMissingBirthTimezoneOffset(value, { preferredTime: time })
@@ -284,7 +298,11 @@ export async function prepareConsultationRoute(
} catch {
throw new ConsultationProfileTruthError("profile_unavailable");
}
- serverChart = serverChartFromProfile(profile, consultationMode);
+ serverChart = serverChartFromProfile(
+ profile,
+ consultationMode,
+ input.candidateRange?.start,
+ );
}
const reservation = await input.reserve();
return Object.freeze({ consultationMode, serverChart, reservation });
diff --git a/frontend/src/lib/rectification-handoff-service.ts b/frontend/src/lib/rectification-handoff-service.ts
index c8c900d3..c475326a 100644
--- a/frontend/src/lib/rectification-handoff-service.ts
+++ b/frontend/src/lib/rectification-handoff-service.ts
@@ -5,6 +5,10 @@ import { storedCaseRowSchema } from "./conversational-rectification/persistence-
const uuidSchema = z.string().uuid();
const fingerprintSchema = z.string().regex(/^[0-9a-f]{64}$/);
+const acceptedRangeSchema = z.object({
+ start: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
+ end: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
+}).strict();
const handoffProjectionSchema = z.object({
caseId: uuidSchema,
@@ -29,9 +33,14 @@ const settlementProjectionSchema = z.object({
credits: z.number().int().nonnegative().nullable(),
}).strict();
+const rectificationV4ExecutionProjectionSchema = executionProjectionSchema.extend({
+ acceptedRange: acceptedRangeSchema,
+}).strict();
+
export type RectificationHandoffProjection = z.infer;
export type RectificationHandoffExecution = z.infer;
export type RectificationHandoffSettlement = z.infer;
+export type RectificationV4HandoffExecution = z.infer;
export type RectificationHandoffRpcClient = Readonly<{
rpc(
@@ -60,13 +69,13 @@ function rpcMessage(error: unknown): string {
function mappedError(error: unknown): RectificationHandoffServiceError {
const message = rpcMessage(error);
- if (message === "conversational_case_not_found") {
+ if (["conversational_case_not_found", "rectification_v4_case_not_found"].includes(message)) {
return new RectificationHandoffServiceError("not_found");
}
- if (message === "conversational_stale_turn") {
+ if (["conversational_stale_turn", "stale_rectification_v4_case"].includes(message)) {
return new RectificationHandoffServiceError("stale");
}
- if (message === "conversational_action_conflict") {
+ if (["conversational_action_conflict", "rectification_v4_handoff_conflict"].includes(message)) {
return new RectificationHandoffServiceError("conflict");
}
return new RectificationHandoffServiceError("unavailable");
@@ -207,3 +216,127 @@ export function createRectificationHandoffService(client: RectificationHandoffRp
}
export type RectificationHandoffService = ReturnType;
+
+const rectificationV4HandoffProjectionSchema = z.object({
+ protocol: z.literal("rectification-evidence-v4"),
+ caseId: uuidSchema,
+ caseVersion: z.number().int().nonnegative(),
+ question: z.string().trim().min(1).max(500),
+ questionFingerprint: fingerprintSchema,
+ requestId: uuidSchema,
+ status: z.enum(["pending", "claimed", "in_progress", "consumed"]),
+ acceptedRange: acceptedRangeSchema.nullable(),
+}).strict();
+
+export type RectificationV4HandoffProjection = z.infer;
+
+export function createRectificationV4HandoffService(client: RectificationHandoffRpcClient) {
+ return Object.freeze({
+ async attach(input: Readonly<{
+ userId: string;
+ caseId: string;
+ caseVersion: number;
+ actionId: string;
+ question: string;
+ }>): Promise {
+ const question = input.question.trim();
+ const parsed = rectificationV4HandoffProjectionSchema.safeParse(await rpc(
+ client,
+ "attach_birth_time_rectification_v4_question",
+ {
+ p_user_id: input.userId,
+ p_case_id: input.caseId,
+ p_expected_version: input.caseVersion,
+ p_action_id: input.actionId,
+ p_question: question,
+ p_question_fingerprint: rectificationQuestionFingerprint(question),
+ },
+ ));
+ if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
+ return parsed.data;
+ },
+
+ async load(input: Readonly<{ userId: string; caseId?: string }>) {
+ const value = await rpc(client, "load_birth_time_rectification_v4_handoff", {
+ p_user_id: input.userId,
+ p_case_id: input.caseId ?? null,
+ });
+ if (value === null) return null;
+ const parsed = rectificationV4HandoffProjectionSchema.safeParse(value);
+ if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
+ return parsed.data;
+ },
+
+ async claim(input: Readonly<{
+ userId: string;
+ caseId: string;
+ caseVersion: number;
+ actionId: string;
+ question: string;
+ }>): Promise {
+ const question = input.question.trim();
+ const parsed = rectificationV4HandoffProjectionSchema.safeParse(await rpc(
+ client,
+ "claim_birth_time_rectification_v4_handoff",
+ {
+ p_user_id: input.userId,
+ p_case_id: input.caseId,
+ p_expected_version: input.caseVersion,
+ p_action_id: input.actionId,
+ p_question_fingerprint: rectificationQuestionFingerprint(question),
+ },
+ ));
+ if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
+ return parsed.data;
+ },
+
+ async beginExecution(input: Readonly<{
+ userId: string;
+ caseId: string;
+ caseVersion: number;
+ claimActionId: string;
+ requestId: string;
+ question: string;
+ }>): Promise {
+ const question = input.question.trim();
+ const parsed = rectificationV4ExecutionProjectionSchema.safeParse(await rpc(
+ client,
+ "begin_birth_time_rectification_v4_handoff_execution",
+ {
+ p_user_id: input.userId,
+ p_case_id: input.caseId,
+ p_expected_version: input.caseVersion,
+ p_claim_action_id: input.claimActionId,
+ p_request_id: input.requestId,
+ p_question_fingerprint: rectificationQuestionFingerprint(question),
+ },
+ ));
+ if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
+ return parsed.data;
+ },
+
+ async settle(input: Readonly<{
+ userId: string;
+ caseId: string;
+ claimActionId: string;
+ requestId: string;
+ emitted: boolean;
+ }>): Promise {
+ const parsed = settlementProjectionSchema.safeParse(await rpc(
+ client,
+ "settle_birth_time_rectification_v4_handoff",
+ {
+ p_user_id: input.userId,
+ p_case_id: input.caseId,
+ p_claim_action_id: input.claimActionId,
+ p_request_id: input.requestId,
+ p_emitted: input.emitted,
+ },
+ ));
+ if (!parsed.success) throw new RectificationHandoffServiceError("unavailable");
+ return parsed.data;
+ },
+ });
+}
+
+export type RectificationV4HandoffService = ReturnType;
diff --git a/frontend/src/lib/rectification-v4/candidate-clusters.ts b/frontend/src/lib/rectification-v4/candidate-clusters.ts
new file mode 100644
index 00000000..1b32637d
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/candidate-clusters.ts
@@ -0,0 +1,41 @@
+import type { CandidateCluster, CandidateMinute } from "./contracts.ts";
+
+function minuteValue(value: string): number {
+ const [hour, minute] = value.split(":").map(Number);
+ return hour! * 60 + minute!;
+}
+
+function nextMinute(previous: string, current: string): boolean {
+ return (minuteValue(current) - minuteValue(previous) + 1_440) % 1_440 === 1;
+}
+
+export function buildCandidateClusters(
+ candidates: readonly CandidateMinute[],
+ relativeFloor = 0.97,
+): readonly CandidateCluster[] {
+ if (candidates.length === 0) return [];
+ const sorted = [...candidates].sort((left, right) => minuteValue(left.time) - minuteValue(right.time));
+ const peak = Math.max(...sorted.map((candidate) => candidate.score));
+ const floor = peak >= 0 ? peak * relativeFloor : peak / relativeFloor;
+ const viable = sorted.filter((candidate) => candidate.score >= floor);
+ const groups: CandidateMinute[][] = [];
+ for (const candidate of viable) {
+ const group = groups.at(-1);
+ if (group && nextMinute(group.at(-1)!.time, candidate.time)) group.push(candidate);
+ else groups.push([candidate]);
+ }
+ return groups.map((group) => {
+ const peakScore = Math.max(...group.map((candidate) => candidate.score));
+ const peakCandidate = group.find((candidate) => candidate.score === peakScore)!;
+ return {
+ rank: 0,
+ startTime: group[0]!.time,
+ endTime: group.at(-1)!.time,
+ representativeTime: peakCandidate.time,
+ widthMinutes: group.length,
+ peakScore,
+ scoreMass: group.reduce((total, candidate) => total + Math.max(candidate.score, 0), 0),
+ };
+ }).sort((left, right) => right.peakScore - left.peakScore || right.scoreMass - left.scoreMass)
+ .map((cluster, index) => ({ ...cluster, rank: index + 1 }));
+}
diff --git a/frontend/src/lib/rectification-v4/candidate-engine.ts b/frontend/src/lib/rectification-v4/candidate-engine.ts
new file mode 100644
index 00000000..62101c3c
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/candidate-engine.ts
@@ -0,0 +1,90 @@
+import { z } from "zod";
+import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "./contracts.ts";
+import { rectificationV4AlgorithmVersion } from "./contracts.ts";
+
+const responseSchema = z.object({
+ result_id: z.string().uuid(),
+ algorithm_version: z.literal(rectificationV4AlgorithmVersion),
+ calculation_spec_hash: z.string().regex(/^[a-f0-9]{64}$/),
+ candidate_scores: z.array(z.object({
+ time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/),
+ score: z.number().finite(),
+ supporting_event_ids: z.array(z.string().uuid()),
+ conflicting_event_ids: z.array(z.string().uuid()),
+ }).strict()).min(1).max(1_440),
+ robustness: z.object({
+ neighbor_support_minutes: z.number().int().nonnegative(),
+ leave_one_out_retention_rate: z.number().finite().min(0).max(1),
+ date_sensitivity_retention_rate: z.number().finite().min(0).max(1),
+ }).passthrough(),
+ missing_layers: z.array(z.string()),
+ can_confirm_exact_minute: z.literal(false),
+}).passthrough();
+
+export type CandidateEngineResult = Readonly<{
+ resultId: string;
+ calculationSpecHash: string;
+ candidates: readonly CandidateMinute[];
+ robustness: {
+ readonly neighborSupportMinutes: number;
+ readonly leaveOneOutRetentionRate: number;
+ readonly dateSensitivityRetentionRate: number;
+ };
+ missingLayers: readonly string[];
+}>;
+
+export interface RectificationV4CandidateEngine {
+ score(input: { readonly calculationSpec: CalculationSpec; readonly events: readonly LifeEventRevision[] }): Promise;
+}
+
+export function createRectificationV4CandidateEngine(options: {
+ readonly apiBase: string;
+ readonly fetchImpl?: typeof fetch;
+}): RectificationV4CandidateEngine {
+ const fetchImpl = options.fetchImpl ?? fetch;
+ return {
+ async score({ calculationSpec, events }) {
+ const response = await fetchImpl(`${options.apiBase}/api/active_rectification_events_v4`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ birth_date: calculationSpec.birthDate,
+ start_time: calculationSpec.candidateRange.start,
+ end_time: calculationSpec.candidateRange.end,
+ lat: calculationSpec.latitude,
+ lon: calculationSpec.longitude,
+ tz: calculationSpec.timezoneOffsetHours,
+ events: events.map((event) => ({
+ id: event.eventId,
+ domain: event.domain,
+ event_kind: event.eventKind,
+ date_start: event.dateRange.start,
+ date_end: event.dateRange.end,
+ precision: event.dateRange.precision,
+ summary: event.summary,
+ })),
+ }),
+ signal: AbortSignal.timeout(5 * 60_000),
+ });
+ const payload: unknown = await response.json();
+ if (!response.ok) throw new Error(`rectification_v4_engine_${response.status}`);
+ const parsed = responseSchema.parse(payload);
+ return {
+ resultId: parsed.result_id,
+ calculationSpecHash: parsed.calculation_spec_hash,
+ candidates: parsed.candidate_scores.map((candidate) => ({
+ time: candidate.time,
+ score: candidate.score,
+ supportingEventIds: candidate.supporting_event_ids,
+ conflictingEventIds: candidate.conflicting_event_ids,
+ })),
+ robustness: {
+ neighborSupportMinutes: parsed.robustness.neighbor_support_minutes,
+ leaveOneOutRetentionRate: parsed.robustness.leave_one_out_retention_rate,
+ dateSensitivityRetentionRate: parsed.robustness.date_sensitivity_retention_rate,
+ },
+ missingLayers: parsed.missing_layers,
+ };
+ },
+ };
+}
diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts
new file mode 100644
index 00000000..729ee463
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/case-service.ts
@@ -0,0 +1,117 @@
+import { randomUUID } from "node:crypto";
+import type {
+ CalculationSpec,
+ LifeEventRevision,
+ RectificationV4ApiResponse,
+ RectificationV4Case,
+} from "./contracts.ts";
+import { rectificationV4Protocol } from "./contracts.ts";
+import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
+import { openingQuestion } from "./question-planner.ts";
+import type { RectificationV4Store } from "./store.ts";
+
+export function createRectificationV4CaseService(store: RectificationV4Store, options: { readonly now?: () => Date } = {}) {
+ const now = options.now ?? (() => new Date());
+
+ async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise {
+ return {
+ case: caseValue,
+ job: jobId ? await store.loadJob(userId, jobId) : null,
+ events: [...await store.loadEvents(userId, caseValue.id)],
+ };
+ }
+
+ return {
+ async createCase(input: { readonly userId: string; readonly actionId: string; readonly calculationSpec: CalculationSpec }) {
+ const timestamp = now().toISOString();
+ const caseValue: RectificationV4Case = {
+ id: randomUUID(),
+ userId: input.userId,
+ protocol: rectificationV4Protocol,
+ version: 0,
+ status: "awaiting_answer",
+ phase: "collecting_evidence",
+ calculationSpec: input.calculationSpec,
+ calculationSpecHash: calculationSpecHash(input.calculationSpec),
+ evidenceSetHash: evidenceSetHash([]),
+ currentQuestion: openingQuestion(),
+ latestSnapshot: null,
+ acceptedRange: null,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ };
+ return response(input.userId, await store.createCase({ case: caseValue, actionId: input.actionId }));
+ },
+
+ async loadCase(userId: string, caseId: string) {
+ const found = await store.loadCase(userId, caseId);
+ return found ? response(userId, found) : null;
+ },
+
+ async loadActive(userId: string) {
+ const found = await store.findActiveCase(userId);
+ return found ? response(userId, found) : null;
+ },
+
+ async loadJob(userId: string, jobId: string) {
+ return store.loadJob(userId, jobId);
+ },
+
+ async answer(input: { readonly userId: string; readonly caseId: string; readonly actionId: string; readonly expectedCaseVersion: number; readonly answer: string }) {
+ const current = await store.loadCase(input.userId, input.caseId);
+ if (!current?.currentQuestion) return null;
+ const saved = await store.submitAnswer({
+ ...input,
+ question: current.currentQuestion,
+ jobId: randomUUID(),
+ turnId: randomUUID(),
+ now: now().toISOString(),
+ });
+ return response(input.userId, saved.case, saved.job.id);
+ },
+
+ async reviseEvent(input: {
+ readonly userId: string;
+ readonly caseId: string;
+ readonly actionId: string;
+ readonly expectedCaseVersion: number;
+ readonly revision: LifeEventRevision;
+ }) {
+ const saved = await store.reviseEvent({ ...input, jobId: randomUUID(), now: now().toISOString() });
+ return response(input.userId, saved.case, saved.job.id);
+ },
+
+ async transition(input: {
+ readonly userId: string;
+ readonly caseId: string;
+ readonly actionId: string;
+ readonly expectedCaseVersion: number;
+ readonly kind: "pause" | "resume" | "abandon";
+ }) {
+ const status = input.kind === "pause" ? "paused" : input.kind === "abandon" ? "abandoned" : "awaiting_answer";
+ const phase = input.kind === "abandon" ? "complete" : "collecting_evidence";
+ return response(input.userId, await store.transitionCase({ ...input, status, phase, now: now().toISOString() }));
+ },
+
+ async acceptRange(input: {
+ readonly userId: string;
+ readonly caseId: string;
+ readonly actionId: string;
+ readonly expectedCaseVersion: number;
+ readonly startTime: string;
+ readonly endTime: string;
+ }) {
+ const current = await store.loadCase(input.userId, input.caseId);
+ const primary = current?.latestSnapshot?.clusters[0];
+ if (!current || !current.latestSnapshot?.canAcceptRange || !primary
+ || primary.startTime !== input.startTime || primary.endTime !== input.endTime) return null;
+ return response(input.userId, await store.transitionCase({
+ ...input,
+ status: "range_ready",
+ phase: "complete",
+ acceptedRange: { start: input.startTime, end: input.endTime },
+ now: now().toISOString(),
+ }));
+ },
+ };
+}
diff --git a/frontend/src/lib/rectification-v4/client.ts b/frontend/src/lib/rectification-v4/client.ts
new file mode 100644
index 00000000..a2c5be5b
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/client.ts
@@ -0,0 +1,160 @@
+import { z } from "zod";
+import {
+ rectificationV4ApiResponseSchema,
+ rectificationV4HandoffSchema,
+ rectificationV4JobSchema,
+ type RectificationV4ApiResponse,
+ type RectificationV4Handoff,
+ type RectificationV4Job,
+} from "./contracts";
+
+const errorSchema = z.object({
+ error: z.string().optional(),
+ message: z.string().optional(),
+}).passthrough();
+
+function errorMessage(payload: unknown, fallback: string): string {
+ const parsed = errorSchema.safeParse(payload);
+ return parsed.success ? parsed.data.message || parsed.data.error || fallback : fallback;
+}
+
+export class RectificationV4RequestError extends Error {
+ constructor(readonly status: number, message: string) {
+ super(message);
+ }
+}
+
+async function json(response: Response, schema: z.ZodType): Promise {
+ const payload = await response.json().catch(() => null);
+ if (!response.ok) {
+ throw new RectificationV4RequestError(
+ response.status,
+ errorMessage(payload, "暂时无法处理,请稍后再试。"),
+ );
+ }
+ return schema.parse(payload);
+}
+
+function post(path: string, body: unknown): Promise {
+ return fetch(path, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ }).then((response) => json(response, rectificationV4ApiResponseSchema));
+}
+
+export async function loadActiveRectificationV4(): Promise {
+ const response = await fetch("/api/rectification/v4/cases/active", { cache: "no-store" });
+ if (response.status === 204) return null;
+ return json(response, rectificationV4ApiResponseSchema);
+}
+
+export function createRectificationV4(): Promise {
+ return post("/api/rectification/v4/cases", { actionId: globalThis.crypto.randomUUID() });
+}
+
+export async function loadRectificationV4(caseId: string): Promise {
+ return json(await fetch(`/api/rectification/v4/cases/${caseId}`, { cache: "no-store" }), rectificationV4ApiResponseSchema);
+}
+
+export function answerRectificationV4(caseId: string, expectedCaseVersion: number, answer: string) {
+ return post(`/api/rectification/v4/cases/${caseId}/answers`, {
+ actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, answer,
+ });
+}
+
+export function transitionRectificationV4(
+ caseId: string,
+ expectedCaseVersion: number,
+ action: "pause" | "resume" | "abandon",
+) {
+ return post(`/api/rectification/v4/cases/${caseId}/${action}`, {
+ actionId: globalThis.crypto.randomUUID(), expectedCaseVersion,
+ });
+}
+
+export function acceptRectificationV4Range(
+ caseId: string,
+ expectedCaseVersion: number,
+ startTime: string,
+ endTime: string,
+) {
+ return post(`/api/rectification/v4/cases/${caseId}/accept-range`, {
+ actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, startTime, endTime,
+ });
+}
+
+export async function loadRectificationV4Job(jobId: string): Promise {
+ const schema = z.object({ job: rectificationV4JobSchema }).strict();
+ return (await json(await fetch(`/api/rectification/v4/jobs/${jobId}`, { cache: "no-store" }), schema)).job;
+}
+
+
+const handoffClaimActions = new Map();
+
+async function handoffPayload(response: Response): Promise {
+ if (response.status === 204) return null;
+ return response.json().catch(() => null);
+}
+
+async function handoffPost(body: Readonly>): Promise {
+ const serialized = JSON.stringify(body);
+ let lastError: unknown;
+ for (let attempt = 0; attempt < 2; attempt += 1) {
+ try {
+ const response = await fetch("/api/rectification/v4/handoff", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: serialized,
+ });
+ const payload = await handoffPayload(response);
+ if (!response.ok) {
+ throw new RectificationV4RequestError(
+ response.status,
+ errorMessage(payload, "暂时无法保存或继续原问题,请稍后重试。"),
+ );
+ }
+ return rectificationV4HandoffSchema.parse(payload);
+ } catch (error) {
+ lastError = error;
+ if (error instanceof RectificationV4RequestError || attempt > 0) throw error;
+ }
+ }
+ throw lastError;
+}
+
+export async function loadRectificationV4Handoff(caseId?: string): Promise {
+ const query = caseId ? `?caseId=${encodeURIComponent(caseId)}` : "";
+ const response = await fetch(`/api/rectification/v4/handoff${query}`, { cache: "no-store" });
+ const payload = await handoffPayload(response);
+ if (response.status === 204) return null;
+ if (!response.ok) {
+ throw new RectificationV4RequestError(
+ response.status,
+ errorMessage(payload, "暂时无法读取原问题,请稍后再试。"),
+ );
+ }
+ return rectificationV4HandoffSchema.parse(payload);
+}
+
+export function attachRectificationV4Question(input: Readonly<{
+ caseId: string;
+ caseVersion: number;
+ question: string;
+ actionId: string;
+}>): Promise {
+ return handoffPost({ type: "attach", ...input, question: input.question.trim() });
+}
+
+export async function claimRectificationV4Handoff(input: Readonly<{
+ caseId: string;
+ caseVersion: number;
+ question: string;
+}>): Promise> {
+ const identity = JSON.stringify([input.caseId, input.caseVersion, input.question.trim()]);
+ const actionId = handoffClaimActions.get(identity) ?? globalThis.crypto.randomUUID();
+ handoffClaimActions.set(identity, actionId);
+ const result = await handoffPost({ type: "claim", ...input, actionId, question: input.question.trim() });
+ if (result.status !== "claimed") handoffClaimActions.delete(identity);
+ return Object.freeze({ ...result, claimActionId: actionId });
+}
diff --git a/frontend/src/lib/rectification-v4/contracts.ts b/frontend/src/lib/rectification-v4/contracts.ts
new file mode 100644
index 00000000..8ea7c7db
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/contracts.ts
@@ -0,0 +1,241 @@
+import { z } from "zod";
+
+export const rectificationV4Protocol = "rectification-evidence-v4" as const;
+export const rectificationV4AlgorithmVersion = "rectification-v4-range-scoring-1" as const;
+
+export const rectificationV4CaseStatusSchema = z.enum([
+ "awaiting_answer",
+ "processing",
+ "range_ready",
+ "paused",
+ "abandoned",
+]);
+export type RectificationV4CaseStatus = z.infer;
+
+export const rectificationV4PhaseSchema = z.enum([
+ "collecting_evidence",
+ "extracting_evidence",
+ "scoring_candidates",
+ "checking_robustness",
+ "planning_question",
+ "complete",
+]);
+export type RectificationV4Phase = z.infer;
+
+export const evidenceDomainSchema = z.enum([
+ "education",
+ "relocation",
+ "relationship",
+ "career",
+ "finance",
+ "health_pressure",
+ "family",
+ "other",
+]);
+export type EvidenceDomain = z.infer;
+
+export const eventKindSchema = z.enum([
+ "education_milestone",
+ "relocation",
+ "relationship_start",
+ "relationship_end",
+ "career_change",
+ "finance_change",
+ "health_event",
+ "family_event",
+ "other",
+]);
+export type EventKind = z.infer;
+
+export const datePrecisionSchema = z.enum(["day", "month", "quarter", "year", "range"]);
+export type DatePrecision = z.infer;
+
+export const calendarDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
+export const clockTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
+
+export const eventDateRangeSchema = z.object({
+ start: calendarDateSchema,
+ end: calendarDateSchema,
+ precision: datePrecisionSchema,
+ label: z.string().trim().min(1).max(80),
+}).strict().superRefine((value, context) => {
+ if (value.start > value.end) {
+ context.addIssue({ code: "custom", message: "event date range start must not exceed end" });
+ }
+});
+export type EventDateRange = z.infer;
+
+export const scoreabilitySchema = z.enum(["scoreable", "context_only"]);
+export type Scoreability = z.infer;
+
+export const lifeEventRevisionSchema = z.object({
+ id: z.string().uuid(),
+ eventId: z.string().uuid(),
+ revision: z.number().int().positive(),
+ domain: evidenceDomainSchema,
+ eventKind: eventKindSchema,
+ summary: z.string().trim().min(1).max(1_000),
+ rawText: z.string().trim().min(1).max(4_000),
+ dateRange: eventDateRangeSchema,
+ scoreability: scoreabilitySchema,
+ supersedesRevisionId: z.string().uuid().nullable(),
+ createdAt: z.string().datetime({ offset: true }),
+}).strict();
+export type LifeEventRevision = z.infer;
+
+export const calculationSpecSchema = z.object({
+ version: z.literal("rectification-calculation-spec-v4"),
+ birthDate: calendarDateSchema,
+ candidateRange: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(),
+ latitude: z.number().finite().min(-90).max(90),
+ longitude: z.number().finite().min(-180).max(180),
+ timezoneOffsetHours: z.number().finite().min(-14).max(14),
+ ayanamsa: z.literal("lahiri"),
+ nodeMode: z.literal("mean"),
+ minuteStep: z.literal(1),
+}).strict();
+export type CalculationSpec = z.infer;
+
+export const candidateMinuteSchema = z.object({
+ time: clockTimeSchema,
+ score: z.number().finite(),
+ supportingEventIds: z.array(z.string().uuid()),
+ conflictingEventIds: z.array(z.string().uuid()),
+}).strict();
+export type CandidateMinute = z.infer;
+
+export const candidateClusterSchema = z.object({
+ rank: z.number().int().positive(),
+ startTime: clockTimeSchema,
+ endTime: clockTimeSchema,
+ representativeTime: clockTimeSchema,
+ widthMinutes: z.number().int().positive(),
+ peakScore: z.number().finite(),
+ scoreMass: z.number().finite().nonnegative(),
+}).strict();
+export type CandidateCluster = z.infer;
+
+export const robustnessSchema = z.object({
+ neighborSupportMinutes: z.number().int().nonnegative(),
+ leaveOneOutRetentionRate: z.number().finite().min(0).max(1),
+ dateSensitivityRetentionRate: z.number().finite().min(0).max(1),
+ calculationSpecHashMatched: z.boolean(),
+}).strict();
+export type Robustness = z.infer;
+
+export const candidateSnapshotSchema = z.object({
+ id: z.string().uuid(),
+ caseId: z.string().uuid(),
+ caseVersion: z.number().int().nonnegative(),
+ evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/),
+ calculationSpecHash: z.string().regex(/^[a-f0-9]{64}$/),
+ algorithmVersion: z.literal(rectificationV4AlgorithmVersion),
+ candidates: z.array(candidateMinuteSchema).min(1).max(1_440),
+ clusters: z.array(candidateClusterSchema).max(20),
+ robustness: robustnessSchema,
+ canConfirmExactMinute: z.literal(false),
+ canAcceptRange: z.boolean(),
+ gateReasons: z.array(z.string().trim().min(1).max(120)).max(20),
+ createdAt: z.string().datetime({ offset: true }),
+}).strict();
+export type CandidateSnapshot = z.infer;
+
+export const rectificationV4QuestionSchema = z.object({
+ id: z.string().uuid(),
+ domain: evidenceDomainSchema,
+ targetEventId: z.string().uuid().nullable(),
+ prompt: z.string().trim().min(1).max(1_000),
+ recallCost: z.enum(["low", "medium", "high"]),
+ reason: z.string().trim().min(1).max(240),
+}).strict();
+export type RectificationV4Question = z.infer;
+
+export const rectificationV4CaseSchema = z.object({
+ id: z.string().uuid(),
+ userId: z.string().uuid(),
+ protocol: z.literal(rectificationV4Protocol),
+ version: z.number().int().nonnegative(),
+ status: rectificationV4CaseStatusSchema,
+ phase: rectificationV4PhaseSchema,
+ calculationSpec: calculationSpecSchema,
+ calculationSpecHash: z.string().regex(/^[a-f0-9]{64}$/),
+ evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/),
+ currentQuestion: rectificationV4QuestionSchema.nullable(),
+ latestSnapshot: candidateSnapshotSchema.nullable(),
+ acceptedRange: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict().nullable(),
+ createdAt: z.string().datetime({ offset: true }),
+ updatedAt: z.string().datetime({ offset: true }),
+}).strict();
+export type RectificationV4Case = z.infer;
+
+export const createCaseRequestSchema = z.object({
+ actionId: z.string().uuid(),
+ uncertaintyMinutes: z.number().int().min(5).max(720).optional(),
+}).strict();
+
+export const answerRequestSchema = z.object({
+ actionId: z.string().uuid(),
+ expectedCaseVersion: z.number().int().nonnegative(),
+ answer: z.string().trim().min(1).max(4_000),
+}).strict();
+
+export const reviseEventRequestSchema = z.object({
+ actionId: z.string().uuid(),
+ expectedCaseVersion: z.number().int().nonnegative(),
+ domain: evidenceDomainSchema,
+ eventKind: eventKindSchema,
+ summary: z.string().trim().min(1).max(1_000),
+ rawText: z.string().trim().min(1).max(4_000),
+ dateRange: eventDateRangeSchema,
+ scoreability: scoreabilitySchema.optional(),
+}).strict();
+
+export const caseActionRequestSchema = z.object({
+ actionId: z.string().uuid(),
+ expectedCaseVersion: z.number().int().nonnegative(),
+}).strict();
+
+export const acceptRangeRequestSchema = caseActionRequestSchema.extend({
+ startTime: clockTimeSchema,
+ endTime: clockTimeSchema,
+}).strict();
+
+export const rectificationV4JobSchema = z.object({
+ id: z.string().uuid(),
+ caseId: z.string().uuid(),
+ status: z.enum(["pending", "processing", "completed", "failed", "stale"]),
+ phase: rectificationV4PhaseSchema,
+ expectedCaseVersion: z.number().int().nonnegative(),
+ evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/),
+ calculationSpecHash: z.string().regex(/^[a-f0-9]{64}$/),
+ errorCode: z.string().trim().min(1).max(120).nullable(),
+ createdAt: z.string().datetime({ offset: true }),
+ updatedAt: z.string().datetime({ offset: true }),
+}).strict();
+export type RectificationV4Job = z.infer;
+
+export const rectificationV4ApiResponseSchema = z.object({
+ case: rectificationV4CaseSchema,
+ job: rectificationV4JobSchema.nullable(),
+ events: z.array(lifeEventRevisionSchema),
+}).strict();
+export type RectificationV4ApiResponse = z.infer;
+
+export const rectificationV4HandoffStatusSchema = z.enum([
+ "pending",
+ "claimed",
+ "in_progress",
+ "consumed",
+]);
+
+export const rectificationV4HandoffSchema = z.object({
+ protocol: z.literal(rectificationV4Protocol),
+ caseId: z.string().uuid(),
+ caseVersion: z.number().int().nonnegative(),
+ question: z.string().trim().min(1).max(500),
+ questionFingerprint: z.string().regex(/^[0-9a-f]{64}$/),
+ requestId: z.string().uuid(),
+ status: rectificationV4HandoffStatusSchema,
+ acceptedRange: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict().nullable(),
+}).strict();
+export type RectificationV4Handoff = z.infer;
diff --git a/frontend/src/lib/rectification-v4/date-range.ts b/frontend/src/lib/rectification-v4/date-range.ts
new file mode 100644
index 00000000..cabca364
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/date-range.ts
@@ -0,0 +1,66 @@
+import type { DatePrecision, EventDateRange } from "./contracts.ts";
+
+const dayMs = 86_400_000;
+
+function isoDate(year: number, month: number, day: number): string {
+ const value = new Date(Date.UTC(year, month - 1, day));
+ if (value.getUTCFullYear() !== year || value.getUTCMonth() !== month - 1 || value.getUTCDate() !== day) {
+ throw new Error("invalid_calendar_date");
+ }
+ return value.toISOString().slice(0, 10);
+}
+
+function monthEnd(year: number, month: number): string {
+ return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10);
+}
+
+export function dateRangeFromDeclared(value: string, precision: Exclude): EventDateRange {
+ if (precision === "day") {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("invalid_day");
+ const [year, month, day] = value.split("-").map(Number);
+ const date = isoDate(year!, month!, day!);
+ return { start: date, end: date, precision, label: value };
+ }
+ if (precision === "month") {
+ if (!/^\d{4}-\d{2}$/.test(value)) throw new Error("invalid_month");
+ const [year, month] = value.split("-").map(Number);
+ const start = isoDate(year!, month!, 1);
+ return { start, end: monthEnd(year!, month!), precision, label: value };
+ }
+ if (precision === "quarter") {
+ const matched = /^(\d{4})-Q([1-4])$/.exec(value);
+ if (!matched) throw new Error("invalid_quarter");
+ const year = Number(matched[1]);
+ const firstMonth = (Number(matched[2]) - 1) * 3 + 1;
+ return {
+ start: isoDate(year, firstMonth, 1),
+ end: monthEnd(year, firstMonth + 2),
+ precision,
+ label: value,
+ };
+ }
+ if (!/^\d{4}$/.test(value)) throw new Error("invalid_year");
+ const year = Number(value);
+ return { start: isoDate(year, 1, 1), end: isoDate(year, 12, 31), precision, label: value };
+}
+
+export function explicitDateRange(start: string, end: string, label = `${start}–${end}`): EventDateRange {
+ const normalizedStart = dateRangeFromDeclared(start, "day").start;
+ const normalizedEnd = dateRangeFromDeclared(end, "day").end;
+ if (normalizedStart > normalizedEnd) throw new Error("invalid_date_range");
+ return { start: normalizedStart, end: normalizedEnd, precision: "range", label };
+}
+
+export function sampledDates(range: EventDateRange): readonly string[] {
+ if (range.start === range.end) return [range.start];
+ const start = Date.parse(`${range.start}T00:00:00Z`);
+ const end = Date.parse(`${range.end}T00:00:00Z`);
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) throw new Error("invalid_date_range");
+
+ const values = new Set([range.start, range.end]);
+ for (let cursor = start; cursor <= end; cursor += 31 * dayMs) {
+ const current = new Date(cursor);
+ values.add(new Date(Date.UTC(current.getUTCFullYear(), current.getUTCMonth(), 1)).toISOString().slice(0, 10));
+ }
+ return [...values].filter((value) => value >= range.start && value <= range.end).sort();
+}
diff --git a/frontend/src/lib/rectification-v4/decision-gate.ts b/frontend/src/lib/rectification-v4/decision-gate.ts
new file mode 100644
index 00000000..5c0a6519
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/decision-gate.ts
@@ -0,0 +1,27 @@
+import type { CandidateCluster, Robustness } from "./contracts.ts";
+
+export type DecisionGateResult = Readonly<{
+ canConfirmExactMinute: false;
+ canAcceptRange: boolean;
+ reasons: readonly string[];
+}>;
+
+export function evaluateDecisionGate(input: {
+ readonly clusters: readonly CandidateCluster[];
+ readonly robustness: Robustness;
+ readonly scoreableEventCount: number;
+ readonly scoreableDomainCount: number;
+}): DecisionGateResult {
+ const reasons: string[] = [];
+ const primary = input.clusters[0];
+ if (!primary) reasons.push("no_primary_candidate_cluster");
+ if (input.scoreableEventCount < 5) reasons.push("insufficient_scoreable_events");
+ if (input.scoreableDomainCount < 3) reasons.push("insufficient_scoreable_domains");
+ if ((primary?.widthMinutes ?? 0) < 2) reasons.push("single_minute_cluster_not_acceptable");
+ if ((primary?.widthMinutes ?? Number.POSITIVE_INFINITY) > 15) reasons.push("primary_cluster_too_wide");
+ if (input.robustness.neighborSupportMinutes < 2) reasons.push("neighbor_support_not_passed");
+ if (input.robustness.leaveOneOutRetentionRate < 0.8) reasons.push("leave_one_out_not_stable");
+ if (input.robustness.dateSensitivityRetentionRate < 0.8) reasons.push("date_range_sensitivity_not_stable");
+ if (!input.robustness.calculationSpecHashMatched) reasons.push("calculation_spec_changed");
+ return { canConfirmExactMinute: false, canAcceptRange: reasons.length === 0, reasons };
+}
diff --git a/frontend/src/lib/rectification-v4/domain-scorers.ts b/frontend/src/lib/rectification-v4/domain-scorers.ts
new file mode 100644
index 00000000..c77fc702
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/domain-scorers.ts
@@ -0,0 +1,33 @@
+import type { EvidenceDomain, EventKind, LifeEventRevision, Scoreability } from "./contracts.ts";
+
+export type DomainScorerPolicy = Readonly<{
+ domain: EvidenceDomain;
+ defaultScoreability: Scoreability;
+ supportedKinds: readonly EventKind[];
+ techniqueLayers: readonly string[];
+}>;
+
+export const domainScorerRegistry: Readonly> = {
+ education: { domain: "education", defaultScoreability: "scoreable", supportedKinds: ["education_milestone"], techniqueLayers: ["D24", "vimshottari", "narayana"] },
+ relocation: { domain: "relocation", defaultScoreability: "scoreable", supportedKinds: ["relocation"], techniqueLayers: ["D4", "vimshottari", "narayana"] },
+ relationship: { domain: "relationship", defaultScoreability: "scoreable", supportedKinds: ["relationship_start", "relationship_end"], techniqueLayers: ["D9", "UL", "vimshottari", "narayana"] },
+ career: { domain: "career", defaultScoreability: "scoreable", supportedKinds: ["career_change"], techniqueLayers: ["D10", "A10", "vimshottari", "narayana"] },
+ finance: { domain: "finance", defaultScoreability: "scoreable", supportedKinds: ["finance_change"], techniqueLayers: ["D2", "D11", "vimshottari", "narayana"] },
+ health_pressure: { domain: "health_pressure", defaultScoreability: "scoreable", supportedKinds: ["health_event"], techniqueLayers: ["D30", "vimshottari", "narayana"] },
+ family: { domain: "family", defaultScoreability: "context_only", supportedKinds: ["family_event"], techniqueLayers: [] },
+ other: { domain: "other", defaultScoreability: "context_only", supportedKinds: ["other"], techniqueLayers: [] },
+};
+
+export function scoreabilityFor(domain: EvidenceDomain): Scoreability {
+ return domainScorerRegistry[domain].defaultScoreability;
+}
+
+export function assertScorerSupports(event: Pick): void {
+ const policy = domainScorerRegistry[event.domain];
+ if (event.scoreability === "scoreable" && !policy.supportedKinds.includes(event.eventKind)) {
+ throw new Error("unsupported_event_kind_for_domain");
+ }
+ if (event.scoreability === "scoreable" && policy.techniqueLayers.length === 0) {
+ throw new Error("domain_not_validated_for_scoring");
+ }
+}
diff --git a/frontend/src/lib/rectification-v4/evidence-ledger.ts b/frontend/src/lib/rectification-v4/evidence-ledger.ts
new file mode 100644
index 00000000..dba76a92
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/evidence-ledger.ts
@@ -0,0 +1,39 @@
+import { randomUUID } from "node:crypto";
+import type { LifeEventRevision } from "./contracts.ts";
+import { assertScorerSupports, scoreabilityFor } from "./domain-scorers.ts";
+
+export type NewEventRevision = Omit & {
+ readonly scoreability?: LifeEventRevision["scoreability"];
+};
+
+export function latestEventRevisions(revisions: readonly LifeEventRevision[]): readonly LifeEventRevision[] {
+ const latest = new Map();
+ for (const revision of revisions) {
+ const current = latest.get(revision.eventId);
+ if (!current || revision.revision > current.revision) latest.set(revision.eventId, revision);
+ }
+ return [...latest.values()].sort((left, right) => left.eventId.localeCompare(right.eventId));
+}
+
+export function appendEventRevision(
+ revisions: readonly LifeEventRevision[],
+ input: NewEventRevision,
+ options: { readonly now?: Date; readonly id?: string } = {},
+): LifeEventRevision {
+ const prior = revisions.filter((value) => value.eventId === input.eventId)
+ .sort((left, right) => right.revision - left.revision)[0] ?? null;
+ const revision: LifeEventRevision = {
+ ...input,
+ id: options.id ?? randomUUID(),
+ revision: (prior?.revision ?? 0) + 1,
+ scoreability: input.scoreability ?? scoreabilityFor(input.domain),
+ supersedesRevisionId: prior?.id ?? null,
+ createdAt: (options.now ?? new Date()).toISOString(),
+ };
+ assertScorerSupports(revision);
+ return revision;
+}
+
+export function scoreableEvents(revisions: readonly LifeEventRevision[]): readonly LifeEventRevision[] {
+ return latestEventRevisions(revisions).filter((event) => event.scoreability === "scoreable");
+}
diff --git a/frontend/src/lib/rectification-v4/extraction.ts b/frontend/src/lib/rectification-v4/extraction.ts
new file mode 100644
index 00000000..217dfe81
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/extraction.ts
@@ -0,0 +1,65 @@
+import { extractLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts";
+import type { EventKind, EvidenceDomain, LifeEventRevision } from "./contracts.ts";
+import { dateRangeFromDeclared } from "./date-range.ts";
+import { appendEventRevision, latestEventRevisions } from "./evidence-ledger.ts";
+
+function eventKind(domain: EvidenceDomain, summary: string): EventKind {
+ if (domain === "relationship") {
+ return /分手|离婚|结束|断联|分开|破裂/.test(summary) ? "relationship_end" : "relationship_start";
+ }
+ switch (domain) {
+ case "education": return "education_milestone";
+ case "relocation": return "relocation";
+ case "career": return "career_change";
+ case "finance": return "finance_change";
+ case "health_pressure": return "health_event";
+ case "family": return "family_event";
+ case "other": return "other";
+ }
+}
+
+export function extractV4EventRevisions(input: {
+ readonly answer: string;
+ readonly sourceTurnId: string;
+ readonly asOfDate: string;
+ readonly existing: readonly LifeEventRevision[];
+ readonly targetEventId?: string | null;
+ readonly now?: Date;
+}): readonly LifeEventRevision[] {
+ const extracted = extractLifeEventEvidence({
+ rawText: input.answer,
+ sourceTurnId: input.sourceTurnId,
+ asOfDate: input.asOfDate,
+ });
+ const target = input.targetEventId
+ ? latestEventRevisions(input.existing).find((event) => event.eventId === input.targetEventId) ?? null
+ : null;
+ if (input.targetEventId) {
+ if (!target) throw new Error("rectification_v4_target_event_not_found");
+ const event = extracted.find((value) => value.dateValue && value.datePrecision !== "unknown");
+ if (!event?.dateValue || event.datePrecision === "unknown") return [];
+ const dateRange = dateRangeFromDeclared(event.dateValue, event.datePrecision);
+ if (dateRange.start > input.asOfDate) return [];
+ return [appendEventRevision(input.existing, {
+ eventId: target.eventId,
+ domain: target.domain,
+ eventKind: target.eventKind,
+ summary: target.summary,
+ rawText: input.answer,
+ dateRange,
+ scoreability: target.scoreability,
+ }, { id: event.id, now: input.now })];
+ }
+ return extracted.flatMap((event) => {
+ if (!event.dateValue || event.datePrecision === "unknown") return [];
+ const domain = event.domain as EvidenceDomain;
+ return [appendEventRevision(input.existing, {
+ eventId: event.id,
+ domain,
+ eventKind: eventKind(domain, event.eventSummary),
+ summary: event.eventSummary,
+ rawText: event.rawText,
+ dateRange: dateRangeFromDeclared(event.dateValue, event.datePrecision),
+ }, { id: event.id, now: input.now })];
+ });
+}
diff --git a/frontend/src/lib/rectification-v4/fingerprints.ts b/frontend/src/lib/rectification-v4/fingerprints.ts
new file mode 100644
index 00000000..cb5f00d7
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/fingerprints.ts
@@ -0,0 +1,31 @@
+import { createHash } from "node:crypto";
+import type { CalculationSpec, LifeEventRevision } from "./contracts.ts";
+import { latestEventRevisions } from "./evidence-ledger.ts";
+
+function canonical(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(canonical);
+ if (value && typeof value === "object") {
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, item]) => [key, canonical(item)]));
+ }
+ return value;
+}
+
+function hash(value: unknown): string {
+ return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex");
+}
+
+export function calculationSpecHash(spec: CalculationSpec): string {
+ return hash(spec);
+}
+
+export function evidenceSetHash(revisions: readonly LifeEventRevision[]): string {
+ return hash(latestEventRevisions(revisions).map((event) => ({
+ eventId: event.eventId,
+ revision: event.revision,
+ domain: event.domain,
+ eventKind: event.eventKind,
+ dateRange: event.dateRange,
+ scoreability: event.scoreability,
+ })));
+}
diff --git a/frontend/src/lib/rectification-v4/handoff-route.ts b/frontend/src/lib/rectification-v4/handoff-route.ts
new file mode 100644
index 00000000..873d5611
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/handoff-route.ts
@@ -0,0 +1,80 @@
+import { z } from "zod";
+import {
+ RectificationHandoffServiceError,
+ type RectificationV4HandoffService,
+} from "../rectification-handoff-service.ts";
+
+const identity = {
+ caseId: z.string().uuid(),
+ caseVersion: z.number().int().nonnegative(),
+ actionId: z.string().uuid(),
+ question: z.string().trim().min(1).max(500),
+} as const;
+
+const commandSchema = z.discriminatedUnion("type", [
+ z.object({ type: z.literal("attach"), ...identity }).strict(),
+ z.object({ type: z.literal("claim"), ...identity }).strict(),
+]);
+
+export type RectificationV4HandoffRouteDependencies = Readonly<{
+ authenticate(): Promise | null>;
+ service(): RectificationV4HandoffService;
+}>;
+
+function failure(error: unknown) {
+ if (error instanceof RectificationHandoffServiceError) {
+ if (error.code === "not_found") {
+ return Response.json({ code: "handoff_not_found", message: "没有找到可继续的原问题。" }, { status: 404 });
+ }
+ if (error.code === "stale") {
+ return Response.json({ code: "stale_case", message: "校正结果已经更新,请刷新后重试。" }, { status: 409 });
+ }
+ if (error.code === "conflict") {
+ return Response.json({ code: "handoff_conflict", message: "原问题状态已经变化,请刷新后查看。" }, { status: 409 });
+ }
+ }
+ return Response.json({ code: "handoff_unavailable", message: "暂时无法保存或继续原问题,请稍后重试。" }, { status: 503 });
+}
+
+export function createRectificationV4HandoffHandlers(
+ dependencies: RectificationV4HandoffRouteDependencies,
+) {
+ return Object.freeze({
+ async get(request: Request) {
+ const authenticated = await dependencies.authenticate();
+ if (!authenticated) {
+ return Response.json({ code: "authentication_required", message: "登录后才能继续原问题。" }, { status: 401 });
+ }
+ const caseId = new URL(request.url).searchParams.get("caseId") ?? undefined;
+ if (caseId && !z.string().uuid().safeParse(caseId).success) {
+ return Response.json({ code: "invalid_case", message: "生时校正记录格式不正确。" }, { status: 400 });
+ }
+ try {
+ const handoff = await dependencies.service().load({ userId: authenticated.userId, caseId });
+ return handoff ? Response.json(handoff) : new Response(null, { status: 204 });
+ } catch (error) {
+ return failure(error);
+ }
+ },
+
+ async post(request: Request) {
+ const authenticated = await dependencies.authenticate();
+ if (!authenticated) {
+ return Response.json({ code: "authentication_required", message: "登录后才能保存或继续原问题。" }, { status: 401 });
+ }
+ const parsed = commandSchema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) {
+ return Response.json({ code: "invalid_command", message: "原问题交接请求格式不正确。" }, { status: 400 });
+ }
+ try {
+ const service = dependencies.service();
+ const input = { userId: authenticated.userId, ...parsed.data };
+ return Response.json(parsed.data.type === "attach"
+ ? await service.attach(input)
+ : await service.claim(input));
+ } catch (error) {
+ return failure(error);
+ }
+ },
+ });
+}
diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts
new file mode 100644
index 00000000..0293cec8
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/memory-store.ts
@@ -0,0 +1,225 @@
+import type {
+ LifeEventRevision,
+ RectificationV4Case,
+ RectificationV4Job,
+} from "./contracts.ts";
+import type {
+ ClaimedRectificationV4Job,
+ CompleteRectificationV4JobInput,
+ RectificationV4Store,
+ RectificationV4Turn,
+} from "./store.ts";
+import { RectificationV4StoreError } from "./store.ts";
+import { evidenceSetHash } from "./fingerprints.ts";
+
+export function createRectificationV4MemoryStore(): RectificationV4Store & {
+ readonly cases: Map;
+ readonly jobs: Map;
+} {
+ const cases = new Map();
+ const events = new Map();
+ const turns = new Map();
+ const jobs = new Map();
+ const actionResults = new Map();
+
+ function owned(userId: string, caseId: string): RectificationV4Case {
+ const value = cases.get(caseId);
+ if (!value || value.userId !== userId) throw new RectificationV4StoreError("not_found");
+ return value;
+ }
+
+ return {
+ cases,
+ jobs,
+ async findActiveCase(userId) {
+ return [...cases.values()].find((value) => value.userId === userId
+ && value.status !== "abandoned" && value.acceptedRange === null) ?? null;
+ },
+ async loadCase(userId, caseId) {
+ const value = cases.get(caseId);
+ return value?.userId === userId ? value : null;
+ },
+ async loadEvents(userId, caseId) {
+ owned(userId, caseId);
+ return events.get(caseId) ?? [];
+ },
+ async createCase(input) {
+ const replay = actionResults.get(`${input.case.userId}:${input.actionId}`);
+ if (replay) return owned(input.case.userId, replay.caseId);
+ const active = [...cases.values()].find((value) => value.userId === input.case.userId
+ && value.status !== "abandoned" && value.acceptedRange === null);
+ if (active?.calculationSpecHash === input.case.calculationSpecHash) {
+ actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: active.id, jobId: null });
+ return active;
+ }
+ if (active) {
+ cases.set(active.id, { ...active, status: "abandoned", phase: "complete", currentQuestion: null, updatedAt: input.case.createdAt });
+ for (const [jobId, job] of jobs) {
+ if (job.caseId === active.id && ["pending", "processing"].includes(job.status)) {
+ jobs.set(jobId, { ...job, status: "stale", updatedAt: input.case.createdAt });
+ }
+ }
+ }
+ cases.set(input.case.id, input.case);
+ events.set(input.case.id, []);
+ actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: input.case.id, jobId: null });
+ return input.case;
+ },
+ async submitAnswer(input) {
+ const key = `${input.userId}:${input.actionId}`;
+ const replay = actionResults.get(key);
+ if (replay?.jobId) return { case: owned(input.userId, replay.caseId), job: jobs.get(replay.jobId)! };
+ const current = owned(input.userId, input.caseId);
+ if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version");
+ if (!["awaiting_answer", "range_ready"].includes(current.status)) throw new RectificationV4StoreError("invalid_state");
+ const version = current.version + 1;
+ const updated: RectificationV4Case = {
+ ...current, version, status: "processing", phase: "extracting_evidence", currentQuestion: null, updatedAt: input.now,
+ };
+ const turn: RectificationV4Turn = {
+ id: input.turnId,
+ caseId: input.caseId,
+ caseVersion: version,
+ questionId: input.question.id,
+ questionDomain: input.question.domain,
+ questionTargetEventId: input.question.targetEventId,
+ question: input.question.prompt,
+ answer: input.answer,
+ actionId: input.actionId,
+ createdAt: input.now,
+ };
+ const job: RectificationV4Job & { workerId: string | null; turnId: string } = {
+ id: input.jobId,
+ caseId: input.caseId,
+ status: "pending",
+ phase: "extracting_evidence",
+ expectedCaseVersion: version,
+ evidenceSetHash: current.evidenceSetHash,
+ calculationSpecHash: current.calculationSpecHash,
+ errorCode: null,
+ createdAt: input.now,
+ updatedAt: input.now,
+ workerId: null,
+ turnId: turn.id,
+ };
+ cases.set(current.id, updated);
+ turns.set(turn.id, turn);
+ jobs.set(job.id, job);
+ actionResults.set(key, { caseId: current.id, jobId: job.id });
+ return { case: updated, job };
+ },
+ async reviseEvent(input) {
+ const key = `${input.userId}:${input.actionId}`;
+ const replay = actionResults.get(key);
+ if (replay?.jobId) return { case: owned(input.userId, replay.caseId), job: jobs.get(replay.jobId)! };
+ const current = owned(input.userId, input.caseId);
+ if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version");
+ const nextEvents = [...(events.get(current.id) ?? []), input.revision];
+ events.set(current.id, nextEvents);
+ const version = current.version + 1;
+ const updated = {
+ ...current, version, status: "processing" as const, phase: "scoring_candidates" as const,
+ evidenceSetHash: evidenceSetHash(nextEvents), currentQuestion: null, updatedAt: input.now,
+ };
+ const turn: RectificationV4Turn = {
+ id: input.revision.id, caseId: current.id, caseVersion: version, questionId: null, questionDomain: null,
+ questionTargetEventId: null, question: "修订事件", answer: "", actionId: input.actionId, createdAt: input.now,
+ };
+ const job = {
+ id: input.jobId, caseId: current.id, status: "pending" as const, phase: "scoring_candidates" as const,
+ expectedCaseVersion: version, evidenceSetHash: updated.evidenceSetHash,
+ calculationSpecHash: current.calculationSpecHash, errorCode: null, createdAt: input.now, updatedAt: input.now,
+ workerId: null, turnId: turn.id,
+ };
+ cases.set(current.id, updated);
+ turns.set(turn.id, turn);
+ jobs.set(job.id, job);
+ actionResults.set(key, { caseId: current.id, jobId: job.id });
+ return { case: updated, job };
+ },
+ async transitionCase(input) {
+ const key = `${input.userId}:${input.actionId}`;
+ const replay = actionResults.get(key);
+ if (replay) return owned(input.userId, replay.caseId);
+ const current = owned(input.userId, input.caseId);
+ if (current.version !== input.expectedCaseVersion) throw new RectificationV4StoreError("stale_version");
+ const updated = {
+ ...current,
+ version: current.version + 1,
+ status: input.status,
+ phase: input.phase,
+ acceptedRange: input.acceptedRange === undefined ? current.acceptedRange : input.acceptedRange,
+ updatedAt: input.now,
+ };
+ cases.set(current.id, updated);
+ actionResults.set(key, { caseId: current.id, jobId: null });
+ return updated;
+ },
+ async loadJob(userId, jobId) {
+ const job = jobs.get(jobId);
+ if (!job) return null;
+ owned(userId, job.caseId);
+ return job;
+ },
+ async updateJobPhase(input) {
+ const job = jobs.get(input.jobId);
+ if (!job || job.workerId !== input.workerId || job.status !== "processing") throw new RectificationV4StoreError("lease_lost");
+ jobs.set(job.id, { ...job, phase: input.phase, updatedAt: input.now });
+ const current = cases.get(job.caseId)!;
+ cases.set(current.id, { ...current, phase: input.phase, updatedAt: input.now });
+ },
+ async claimNextJob(workerId, now): Promise {
+ const job = [...jobs.values()].find((value) => value.status === "pending");
+ if (!job) return null;
+ const claimed = { ...job, status: "processing" as const, workerId, updatedAt: now };
+ jobs.set(job.id, claimed);
+ const caseValue = cases.get(job.caseId)!;
+ return {
+ job: claimed,
+ case: caseValue,
+ turn: turns.get(job.turnId)!,
+ events: events.get(job.caseId) ?? [],
+ attemptedRefinementEventIds: [...new Set(
+ [...turns.values()]
+ .filter((turn) => turn.caseId === job.caseId && turn.questionTargetEventId)
+ .map((turn) => turn.questionTargetEventId!),
+ )],
+ };
+ },
+ async completeJob(input: CompleteRectificationV4JobInput, now) {
+ const job = jobs.get(input.jobId);
+ if (!job || job.workerId !== input.workerId || job.status !== "processing") throw new RectificationV4StoreError("lease_lost");
+ const current = cases.get(job.caseId)!;
+ if (current.version !== input.expectedCaseVersion
+ || current.evidenceSetHash !== input.inputEvidenceSetHash
+ || current.calculationSpecHash !== input.calculationSpecHash) throw new RectificationV4StoreError("stale_job");
+ const nextEvents = [...(events.get(current.id) ?? []), ...input.newEventRevisions];
+ events.set(current.id, nextEvents);
+ const updated: RectificationV4Case = {
+ ...current,
+ version: current.version + 1,
+ evidenceSetHash: input.outputEvidenceSetHash,
+ latestSnapshot: input.snapshot,
+ currentQuestion: input.nextQuestion,
+ status: input.status,
+ phase: input.phase,
+ updatedAt: now,
+ };
+ cases.set(current.id, updated);
+ jobs.set(job.id, { ...job, status: "completed", phase: input.phase, updatedAt: now });
+ return updated;
+ },
+ async failJob(input) {
+ const job = jobs.get(input.jobId);
+ if (!job || job.workerId !== input.workerId) throw new RectificationV4StoreError("lease_lost");
+ jobs.set(job.id, { ...job, status: "failed", errorCode: input.errorCode, updatedAt: input.now });
+ const current = cases.get(job.caseId);
+ if (current?.version === input.expectedCaseVersion) {
+ cases.set(current.id, {
+ ...current, status: "awaiting_answer", phase: "collecting_evidence",
+ currentQuestion: input.restoreQuestion, updatedAt: input.now,
+ });
+ }
+ },
+ };
+}
diff --git a/frontend/src/lib/rectification-v4/question-planner.ts b/frontend/src/lib/rectification-v4/question-planner.ts
new file mode 100644
index 00000000..d009c130
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/question-planner.ts
@@ -0,0 +1,82 @@
+import { randomUUID } from "node:crypto";
+import type { EvidenceDomain, LifeEventRevision, RectificationV4Question } from "./contracts.ts";
+import { scoreableEvents } from "./evidence-ledger.ts";
+
+const domainOrder: readonly EvidenceDomain[] = [
+ "education", "relocation", "relationship", "career", "finance", "health_pressure", "family",
+];
+const recallCost: Readonly> = {
+ education: 1, relocation: 1, relationship: 1, career: 1, finance: 2, health_pressure: 2, family: 2, other: 3,
+};
+const prompts: Readonly> = {
+ education: "请说一件你记得最清楚的升学、复读、转学或毕业事件,并给出尽可能准确的年月。",
+ relocation: "请说一次影响较大的搬家或长期迁居,并给出尽可能准确的年月。",
+ relationship: "请说一段重要关系明确开始或结束的时间;开始和结束请分开说。",
+ career: "请说一次明确的入职、离职、转行或职责突变,并给出尽可能准确的年月。",
+ finance: "请说一次明显的收入、负债或资产变化,并给出尽可能准确的年月。",
+ health_pressure: "请说一次明确的疾病、手术、事故或长期压力起点,并给出尽可能准确的年月。",
+ family: "请说一件对你影响很大的家庭事件和时间;这一类先作为背景,不直接参与评分。",
+ other: "请再补充一件日期明确、对人生方向影响较大的事件;如果暂时想不到,也可以回复“暂停”。",
+};
+
+function refinementQuestion(event: LifeEventRevision, id?: string): RectificationV4Question {
+ return {
+ id: id ?? randomUUID(),
+ domain: event.domain,
+ targetEventId: event.eventId,
+ prompt: `你之前提到“${event.summary.slice(0, 120)}”,目前时间是${event.dateRange.label}。如果记得,请补充更具体的日期;不记得可以回复“跳过”。`,
+ recallCost: "medium",
+ reason: "缩小已有事件的日期范围,用于检验候选时间对日期误差是否稳定。",
+ };
+}
+
+export function planNextQuestion(input: {
+ readonly askedDomains: readonly EvidenceDomain[];
+ readonly coveredDomains: readonly EvidenceDomain[];
+ readonly candidateSplitByDomain?: Readonly>>;
+ readonly events?: readonly LifeEventRevision[];
+ readonly attemptedRefinementEventIds?: readonly string[];
+ readonly id?: string;
+}): RectificationV4Question {
+ const asked = new Set(input.askedDomains);
+ const covered = new Set(input.coveredDomains);
+ const candidates = domainOrder.filter((domain) => !asked.has(domain));
+ if (candidates.length > 0) {
+ candidates.sort((left, right) => {
+ const leftValue = (input.candidateSplitByDomain?.[left] ?? 0) + (covered.has(left) ? 0 : 1) - recallCost[left] * 0.1;
+ const rightValue = (input.candidateSplitByDomain?.[right] ?? 0) + (covered.has(right) ? 0 : 1) - recallCost[right] * 0.1;
+ return rightValue - leftValue || domainOrder.indexOf(left) - domainOrder.indexOf(right);
+ });
+ const domain = candidates[0]!;
+ const cost = recallCost[domain] === 1 ? "low" : recallCost[domain] === 2 ? "medium" : "high";
+ return {
+ id: input.id ?? randomUUID(),
+ domain,
+ targetEventId: null,
+ prompt: prompts[domain],
+ recallCost: cost,
+ reason: input.candidateSplitByDomain?.[domain]
+ ? "该领域最能区分当前候选时间,同时回忆成本较低。"
+ : "先收集高回忆率、可核对日期的人生事件。",
+ };
+ }
+
+ const attempted = new Set(input.attemptedRefinementEventIds ?? []);
+ const target = scoreableEvents(input.events ?? [])
+ .filter((event) => event.dateRange.precision !== "day" && !attempted.has(event.eventId))
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.eventId.localeCompare(right.eventId))[0];
+ if (target) return refinementQuestion(target, input.id);
+
+ return {
+ id: input.id ?? randomUUID(),
+ domain: "other",
+ targetEventId: null,
+ prompt: prompts.other,
+ recallCost: "high",
+ reason: "现有事件仍不足以通过稳定性门槛,需要新的明确日期证据,或由用户主动暂停。",
+ };
+}
+
+export function openingQuestion(id?: string): RectificationV4Question {
+ return planNextQuestion({ askedDomains: [], coveredDomains: [], id });
+}
diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts
new file mode 100644
index 00000000..1678877a
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/store.ts
@@ -0,0 +1,99 @@
+import type {
+ CandidateSnapshot,
+ LifeEventRevision,
+ RectificationV4Case,
+ RectificationV4Job,
+ RectificationV4Phase,
+ RectificationV4Question,
+} from "./contracts.ts";
+
+export type RectificationV4Turn = Readonly<{
+ id: string;
+ caseId: string;
+ caseVersion: number;
+ questionId: string | null;
+ questionDomain: LifeEventRevision["domain"] | null;
+ questionTargetEventId: string | null;
+ question: string;
+ answer: string;
+ actionId: string;
+ createdAt: string;
+}>;
+
+export type ClaimedRectificationV4Job = Readonly<{
+ job: RectificationV4Job;
+ case: RectificationV4Case;
+ turn: RectificationV4Turn;
+ events: readonly LifeEventRevision[];
+ attemptedRefinementEventIds: readonly string[];
+}>;
+
+export type CompleteRectificationV4JobInput = Readonly<{
+ workerId: string;
+ jobId: string;
+ expectedCaseVersion: number;
+ inputEvidenceSetHash: string;
+ outputEvidenceSetHash: string;
+ calculationSpecHash: string;
+ newEventRevisions: readonly LifeEventRevision[];
+ snapshot: CandidateSnapshot | null;
+ nextQuestion: RectificationV4Question | null;
+ status: RectificationV4Case["status"];
+ phase: RectificationV4Phase;
+}>;
+
+export interface RectificationV4Store {
+ findActiveCase(userId: string): Promise;
+ loadCase(userId: string, caseId: string): Promise;
+ loadEvents(userId: string, caseId: string): Promise;
+ createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise;
+ submitAnswer(input: {
+ readonly userId: string;
+ readonly caseId: string;
+ readonly actionId: string;
+ readonly expectedCaseVersion: number;
+ readonly answer: string;
+ readonly question: RectificationV4Question;
+ readonly jobId: string;
+ readonly turnId: string;
+ readonly now: string;
+ }): Promise<{ readonly case: RectificationV4Case; readonly job: RectificationV4Job }>;
+ reviseEvent(input: {
+ readonly userId: string;
+ readonly caseId: string;
+ readonly actionId: string;
+ readonly expectedCaseVersion: number;
+ readonly revision: LifeEventRevision;
+ readonly jobId: string;
+ readonly now: string;
+ }): Promise<{ readonly case: RectificationV4Case; readonly job: RectificationV4Job }>;
+ transitionCase(input: {
+ readonly userId: string;
+ readonly caseId: string;
+ readonly actionId: string;
+ readonly expectedCaseVersion: number;
+ readonly status: RectificationV4Case["status"];
+ readonly phase: RectificationV4Phase;
+ readonly acceptedRange?: { readonly start: string; readonly end: string } | null;
+ readonly now: string;
+ }): Promise;
+ loadJob(userId: string, jobId: string): Promise;
+ updateJobPhase(input: { readonly workerId: string; readonly jobId: string; readonly phase: RectificationV4Phase; readonly now: string }): Promise;
+ claimNextJob(workerId: string, now: string): Promise;
+ completeJob(input: CompleteRectificationV4JobInput, now: string): Promise;
+ failJob(input: {
+ readonly workerId: string;
+ readonly jobId: string;
+ readonly expectedCaseVersion: number;
+ readonly errorCode: string;
+ readonly restoreQuestion: RectificationV4Question | null;
+ readonly now: string;
+ }): Promise;
+}
+
+export class RectificationV4StoreError extends Error {
+ readonly name = "RectificationV4StoreError";
+ constructor(readonly code: "not_found" | "stale_version" | "invalid_state" | "stale_job" | "lease_lost") {
+ super(code);
+ }
+}
diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts
new file mode 100644
index 00000000..24ac3dfe
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/supabase-store.ts
@@ -0,0 +1,321 @@
+import type { SupabaseClient } from "@supabase/supabase-js";
+import {
+ candidateSnapshotSchema,
+ lifeEventRevisionSchema,
+ rectificationV4CaseSchema,
+ rectificationV4JobSchema,
+ type CandidateSnapshot,
+ type LifeEventRevision,
+ type RectificationV4Case,
+ type RectificationV4Job,
+} from "./contracts.ts";
+import type {
+ ClaimedRectificationV4Job,
+ CompleteRectificationV4JobInput,
+ RectificationV4Store,
+ RectificationV4Turn,
+} from "./store.ts";
+import { RectificationV4StoreError } from "./store.ts";
+import { evidenceSetHash } from "./fingerprints.ts";
+
+type Row = Record;
+
+function timestamp(value: unknown): string {
+ return value instanceof Date ? value.toISOString() : String(value);
+}
+
+function date(value: unknown): string {
+ return value instanceof Date ? value.toISOString().slice(0, 10) : String(value);
+}
+
+function storeError(error: unknown): RectificationV4StoreError {
+ const message = error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
+ if (message.includes("not_found")) return new RectificationV4StoreError("not_found");
+ if (message.includes("stale_rectification_v4_case")) return new RectificationV4StoreError("stale_version");
+ if (message.includes("stale_rectification_v4_job")) return new RectificationV4StoreError("stale_job");
+ if (message.includes("lease_lost")) return new RectificationV4StoreError("lease_lost");
+ return new RectificationV4StoreError("invalid_state");
+}
+
+function snapshot(row: Row | null): CandidateSnapshot | null {
+ if (!row) return null;
+ return candidateSnapshotSchema.parse({
+ id: row.id,
+ caseId: row.case_id,
+ caseVersion: Number(row.case_version),
+ evidenceSetHash: row.evidence_set_hash,
+ calculationSpecHash: row.calculation_spec_hash,
+ algorithmVersion: row.algorithm_version,
+ candidates: row.candidates,
+ clusters: row.clusters,
+ robustness: row.robustness,
+ canConfirmExactMinute: false,
+ canAcceptRange: row.can_accept_range,
+ gateReasons: row.gate_reasons,
+ createdAt: timestamp(row.created_at),
+ });
+}
+
+function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): RectificationV4Case {
+ return rectificationV4CaseSchema.parse({
+ id: row.id,
+ userId: row.user_id,
+ protocol: row.protocol,
+ version: Number(row.version),
+ status: row.status,
+ phase: row.phase,
+ calculationSpec: row.calculation_spec,
+ calculationSpecHash: row.calculation_spec_hash,
+ evidenceSetHash: row.evidence_set_hash,
+ currentQuestion: row.current_question,
+ latestSnapshot,
+ acceptedRange: row.accepted_range_start && row.accepted_range_end
+ ? { start: row.accepted_range_start, end: row.accepted_range_end }
+ : null,
+ createdAt: timestamp(row.created_at),
+ updatedAt: timestamp(row.updated_at),
+ });
+}
+
+function eventRevision(row: Row): LifeEventRevision {
+ return lifeEventRevisionSchema.parse({
+ id: row.id,
+ eventId: row.event_id,
+ revision: Number(row.revision),
+ domain: row.domain,
+ eventKind: row.event_kind,
+ summary: row.summary,
+ rawText: row.raw_text,
+ dateRange: {
+ start: date(row.date_start),
+ end: date(row.date_end),
+ precision: row.date_precision,
+ label: row.date_label,
+ },
+ scoreability: row.scoreability,
+ supersedesRevisionId: row.supersedes_revision_id,
+ createdAt: timestamp(row.created_at),
+ });
+}
+
+function jobValue(row: Row): RectificationV4Job {
+ return rectificationV4JobSchema.parse({
+ id: row.id,
+ caseId: row.case_id,
+ status: row.status,
+ phase: row.phase,
+ expectedCaseVersion: Number(row.expected_case_version),
+ evidenceSetHash: row.evidence_set_hash,
+ calculationSpecHash: row.calculation_spec_hash,
+ errorCode: row.error_code,
+ createdAt: timestamp(row.created_at),
+ updatedAt: timestamp(row.updated_at),
+ });
+}
+
+function turnValue(row: Row): RectificationV4Turn {
+ return {
+ id: String(row.id),
+ caseId: String(row.case_id),
+ caseVersion: Number(row.case_version),
+ questionId: row.question_id ? String(row.question_id) : null,
+ questionDomain: row.question_domain as RectificationV4Turn["questionDomain"],
+ questionTargetEventId: row.question_target_event_id ? String(row.question_target_event_id) : null,
+ question: String(row.question),
+ answer: String(row.answer),
+ actionId: String(row.action_id),
+ createdAt: timestamp(row.created_at),
+ };
+}
+
+export function createRectificationV4SupabaseStore(supabase: SupabaseClient): RectificationV4Store {
+ async function rowById(table: string, id: string): Promise {
+ const { data, error } = await supabase.from(table).select("*").eq("id", id).maybeSingle();
+ if (error) throw storeError(error);
+ return data as Row | null;
+ }
+
+ async function loadCaseById(userId: string, caseId: string): Promise {
+ const { data, error } = await supabase.from("birth_time_rectification_v4_cases")
+ .select("*").eq("id", caseId).eq("user_id", userId).maybeSingle();
+ if (error) throw storeError(error);
+ if (!data) return null;
+ const row = data as Row;
+ const latest = row.latest_snapshot_id
+ ? snapshot(await rowById("birth_time_rectification_v4_candidate_snapshots", String(row.latest_snapshot_id)))
+ : null;
+ return caseValue(row, latest);
+ }
+
+ async function loadJobRow(jobId: string): Promise {
+ return rowById("birth_time_rectification_v4_jobs", jobId);
+ }
+
+ async function loadEventsByCase(userId: string, caseId: string): Promise {
+ if (!await loadCaseById(userId, caseId)) throw new RectificationV4StoreError("not_found");
+ const { data, error } = await supabase.from("birth_time_rectification_v4_event_revisions")
+ .select("*").eq("case_id", caseId).eq("user_id", userId)
+ .order("created_at", { ascending: true });
+ if (error) throw storeError(error);
+ return ((data ?? []) as Row[]).map(eventRevision);
+ }
+
+ async function rpc(name: string, args: Row): Promise {
+ const { data, error } = await supabase.rpc(name, args);
+ if (error) throw storeError(error);
+ return data;
+ }
+
+ return {
+ async findActiveCase(userId) {
+ const { data, error } = await supabase.from("birth_time_rectification_v4_cases")
+ .select("id").eq("user_id", userId).neq("status", "abandoned").is("accepted_range_start", null)
+ .order("created_at", { ascending: false }).limit(1).maybeSingle();
+ if (error) throw storeError(error);
+ return data ? loadCaseById(userId, String((data as Row).id)) : null;
+ },
+ loadCase: loadCaseById,
+ loadEvents: loadEventsByCase,
+ async createCase(input) {
+ const id = String(await rpc("create_birth_time_rectification_v4_case", {
+ p_user_id: input.case.userId,
+ p_case_id: input.case.id,
+ p_action_id: input.actionId,
+ p_status: input.case.status,
+ p_phase: input.case.phase,
+ p_calculation_spec: input.case.calculationSpec,
+ p_calculation_spec_hash: input.case.calculationSpecHash,
+ p_evidence_set_hash: input.case.evidenceSetHash,
+ p_current_question: input.case.currentQuestion,
+ p_now: input.case.createdAt,
+ }));
+ const value = await loadCaseById(input.case.userId, id);
+ if (!value) throw new RectificationV4StoreError("not_found");
+ return value;
+ },
+ async submitAnswer(input) {
+ const jobId = String(await rpc("submit_birth_time_rectification_v4_answer", {
+ p_user_id: input.userId,
+ p_case_id: input.caseId,
+ p_action_id: input.actionId,
+ p_expected_version: input.expectedCaseVersion,
+ p_turn_id: input.turnId,
+ p_question_id: input.question.id,
+ p_question_domain: input.question.domain,
+ p_question_target_event_id: input.question.targetEventId,
+ p_question: input.question.prompt,
+ p_answer: input.answer,
+ p_job_id: input.jobId,
+ p_now: input.now,
+ }));
+ const [caseResult, jobRow] = await Promise.all([loadCaseById(input.userId, input.caseId), loadJobRow(jobId)]);
+ if (!caseResult || !jobRow) throw new RectificationV4StoreError("not_found");
+ return { case: caseResult, job: jobValue(jobRow) };
+ },
+ async reviseEvent(input) {
+ const current = await loadEventsByCase(input.userId, input.caseId);
+ const outputHash = evidenceSetHash([...current, input.revision]);
+ const jobId = String(await rpc("revise_birth_time_rectification_v4_event", {
+ p_user_id: input.userId,
+ p_case_id: input.caseId,
+ p_action_id: input.actionId,
+ p_expected_version: input.expectedCaseVersion,
+ p_revision: input.revision,
+ p_output_evidence_set_hash: outputHash,
+ p_turn_id: input.revision.id,
+ p_job_id: input.jobId,
+ p_now: input.now,
+ }));
+ const [caseResult, jobRow] = await Promise.all([loadCaseById(input.userId, input.caseId), loadJobRow(jobId)]);
+ if (!caseResult || !jobRow) throw new RectificationV4StoreError("not_found");
+ return { case: caseResult, job: jobValue(jobRow) };
+ },
+ async transitionCase(input) {
+ const id = String(await rpc("transition_birth_time_rectification_v4_case", {
+ p_user_id: input.userId,
+ p_case_id: input.caseId,
+ p_action_id: input.actionId,
+ p_expected_version: input.expectedCaseVersion,
+ p_status: input.status,
+ p_phase: input.phase,
+ p_accepted_range_start: input.acceptedRange?.start ?? null,
+ p_accepted_range_end: input.acceptedRange?.end ?? null,
+ p_now: input.now,
+ }));
+ const value = await loadCaseById(input.userId, id);
+ if (!value) throw new RectificationV4StoreError("not_found");
+ return value;
+ },
+ async loadJob(userId, jobId) {
+ const row = await loadJobRow(jobId);
+ if (!row || row.user_id !== userId) return null;
+ return jobValue(row);
+ },
+ async updateJobPhase(input) {
+ await rpc("update_birth_time_rectification_v4_job_phase", {
+ p_worker_id: input.workerId,
+ p_job_id: input.jobId,
+ p_phase: input.phase,
+ p_now: input.now,
+ });
+ },
+ async claimNextJob(workerId, now): Promise {
+ const claimed = await rpc("claim_next_birth_time_rectification_v4_job", { p_worker_id: workerId, p_now: now });
+ if (!claimed) return null;
+ const jobRow = await loadJobRow(String(claimed));
+ if (!jobRow) throw new RectificationV4StoreError("not_found");
+ const userId = String(jobRow.user_id);
+ const caseId = String(jobRow.case_id);
+ const [caseResult, turnRow, events, turnRows] = await Promise.all([
+ loadCaseById(userId, caseId),
+ rowById("birth_time_rectification_v4_turns", String(jobRow.turn_id)),
+ loadEventsByCase(userId, caseId),
+ supabase.from("birth_time_rectification_v4_turns")
+ .select("question_target_event_id").eq("case_id", caseId),
+ ]);
+ if (!caseResult || !turnRow) throw new RectificationV4StoreError("not_found");
+ if (turnRows.error) throw storeError(turnRows.error);
+ return {
+ job: jobValue(jobRow),
+ case: caseResult,
+ turn: turnValue(turnRow),
+ events,
+ attemptedRefinementEventIds: [...new Set(
+ ((turnRows.data ?? []) as Row[])
+ .flatMap((row) => row.question_target_event_id ? [String(row.question_target_event_id)] : []),
+ )],
+ };
+ },
+ async completeJob(input: CompleteRectificationV4JobInput, now) {
+ const jobRow = await loadJobRow(input.jobId);
+ if (!jobRow) throw new RectificationV4StoreError("not_found");
+ await rpc("complete_birth_time_rectification_v4_job", {
+ p_worker_id: input.workerId,
+ p_job_id: input.jobId,
+ p_expected_case_version: input.expectedCaseVersion,
+ p_input_evidence_set_hash: input.inputEvidenceSetHash,
+ p_output_evidence_set_hash: input.outputEvidenceSetHash,
+ p_calculation_spec_hash: input.calculationSpecHash,
+ p_event_revisions: input.newEventRevisions,
+ p_snapshot: input.snapshot,
+ p_next_question: input.nextQuestion,
+ p_status: input.status,
+ p_phase: input.phase,
+ p_now: now,
+ });
+ const value = await loadCaseById(String(jobRow.user_id), String(jobRow.case_id));
+ if (!value) throw new RectificationV4StoreError("not_found");
+ return value;
+ },
+ async failJob(input) {
+ await rpc("fail_birth_time_rectification_v4_job", {
+ p_worker_id: input.workerId,
+ p_job_id: input.jobId,
+ p_expected_case_version: input.expectedCaseVersion,
+ p_error_code: input.errorCode,
+ p_restore_question: input.restoreQuestion,
+ p_now: input.now,
+ });
+ },
+ };
+}
diff --git a/frontend/src/lib/rectification-v4/worker.ts b/frontend/src/lib/rectification-v4/worker.ts
new file mode 100644
index 00000000..024dcbe6
--- /dev/null
+++ b/frontend/src/lib/rectification-v4/worker.ts
@@ -0,0 +1,115 @@
+import { randomUUID } from "node:crypto";
+import type { CandidateSnapshot, EvidenceDomain } from "./contracts.ts";
+import { rectificationV4AlgorithmVersion } from "./contracts.ts";
+import type { RectificationV4CandidateEngine } from "./candidate-engine.ts";
+import { buildCandidateClusters } from "./candidate-clusters.ts";
+import { evaluateDecisionGate } from "./decision-gate.ts";
+import { evidenceSetHash } from "./fingerprints.ts";
+import { extractV4EventRevisions } from "./extraction.ts";
+import { latestEventRevisions, scoreableEvents } from "./evidence-ledger.ts";
+import { planNextQuestion } from "./question-planner.ts";
+import type { RectificationV4Store } from "./store.ts";
+
+export function createRectificationV4Worker(input: {
+ readonly store: RectificationV4Store;
+ readonly engine: RectificationV4CandidateEngine;
+ readonly workerId?: string;
+ readonly now?: () => Date;
+}) {
+ const workerId = input.workerId ?? randomUUID();
+ const now = input.now ?? (() => new Date());
+
+ return {
+ async runOnce(): Promise {
+ const claimed = await input.store.claimNextJob(workerId, now().toISOString());
+ if (!claimed) return false;
+ try {
+ const extracted = claimed.turn.answer
+ ? extractV4EventRevisions({
+ answer: claimed.turn.answer,
+ sourceTurnId: claimed.turn.id,
+ asOfDate: now().toISOString().slice(0, 10),
+ existing: claimed.events,
+ targetEventId: claimed.turn.questionTargetEventId,
+ now: now(),
+ })
+ : [];
+ const events = latestEventRevisions([...claimed.events, ...extracted]);
+ await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "scoring_candidates", now: now().toISOString() });
+ const scoreable = scoreableEvents(events);
+ const domains = new Set(scoreable.map((event) => event.domain));
+ let snapshot: CandidateSnapshot | null = null;
+ if (scoreable.length >= 3 && domains.size >= 2) {
+ const scored = await input.engine.score({ calculationSpec: claimed.case.calculationSpec, events: scoreable });
+ await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "checking_robustness", now: now().toISOString() });
+ const clusters = buildCandidateClusters(scored.candidates);
+ const robustness = {
+ ...scored.robustness,
+ calculationSpecHashMatched: scored.calculationSpecHash === claimed.case.calculationSpecHash,
+ };
+ const gate = evaluateDecisionGate({
+ clusters,
+ robustness,
+ scoreableEventCount: scoreable.length,
+ scoreableDomainCount: domains.size,
+ });
+ snapshot = {
+ id: scored.resultId,
+ caseId: claimed.case.id,
+ caseVersion: claimed.case.version,
+ evidenceSetHash: evidenceSetHash(events),
+ calculationSpecHash: claimed.case.calculationSpecHash,
+ algorithmVersion: rectificationV4AlgorithmVersion,
+ candidates: [...scored.candidates],
+ clusters: [...clusters],
+ robustness,
+ canConfirmExactMinute: false,
+ canAcceptRange: gate.canAcceptRange,
+ gateReasons: [...gate.reasons, ...scored.missingLayers.map((layer) => `missing_layer:${layer}`)],
+ createdAt: now().toISOString(),
+ };
+ }
+ await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "planning_question", now: now().toISOString() });
+ const covered = events.map((event) => event.domain);
+ const asked = [...covered, ...(claimed.turn.questionDomain ? [claimed.turn.questionDomain] : [])];
+ const nextQuestion = snapshot?.canAcceptRange ? null : planNextQuestion({
+ askedDomains: [...new Set(asked)] as EvidenceDomain[],
+ coveredDomains: [...new Set(covered)] as EvidenceDomain[],
+ events,
+ attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
+ });
+ await input.store.completeJob({
+ workerId,
+ jobId: claimed.job.id,
+ expectedCaseVersion: claimed.case.version,
+ inputEvidenceSetHash: claimed.case.evidenceSetHash,
+ outputEvidenceSetHash: evidenceSetHash(events),
+ calculationSpecHash: claimed.case.calculationSpecHash,
+ newEventRevisions: extracted,
+ snapshot,
+ nextQuestion,
+ status: snapshot?.canAcceptRange ? "range_ready" : "awaiting_answer",
+ phase: snapshot?.canAcceptRange ? "complete" : "collecting_evidence",
+ }, now().toISOString());
+ return true;
+ } catch (error) {
+ await input.store.failJob({
+ workerId,
+ jobId: claimed.job.id,
+ expectedCaseVersion: claimed.case.version,
+ errorCode: error instanceof Error ? error.message.slice(0, 120) : "unknown_worker_error",
+ restoreQuestion: claimed.turn.questionId && claimed.turn.questionDomain ? {
+ id: claimed.turn.questionId,
+ domain: claimed.turn.questionDomain,
+ targetEventId: claimed.turn.questionTargetEventId,
+ prompt: claimed.turn.question,
+ recallCost: "low",
+ reason: "上一轮处理没有完成,请重新提交这段经历。",
+ } : null,
+ now: now().toISOString(),
+ });
+ return true;
+ }
+ },
+ };
+}
diff --git a/frontend/src/lib/supabase/admin-client-core.ts b/frontend/src/lib/supabase/admin-client-core.ts
new file mode 100644
index 00000000..8286bd75
--- /dev/null
+++ b/frontend/src/lib/supabase/admin-client-core.ts
@@ -0,0 +1,36 @@
+import { createClient, type SupabaseClient } from "@supabase/supabase-js";
+import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client-core";
+import { readDatabaseUrl } from "@/lib/db/config";
+import {
+ getSupabaseUrl,
+ SupabaseConfigurationError,
+} from "./config";
+
+export function createAdminSupabaseClient() {
+ if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
+ return createLocalPostgresDataClient(
+ readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
+ null,
+ "service_role",
+ ) as unknown as SupabaseClient;
+ }
+ const url = getSupabaseUrl();
+ const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
+ if (!serviceRoleKey) {
+ throw new SupabaseConfigurationError(["SUPABASE_SERVICE_ROLE_KEY"]);
+ }
+
+ return createClient(url, serviceRoleKey, {
+ auth: { autoRefreshToken: false, persistSession: false },
+ });
+}
+
+export function isAdminEmail(email: string | null | undefined) {
+ const configured = process.env.ADMIN_EMAILS;
+ if (!configured?.trim() || !email) return false;
+
+ const normalized = email.trim().toLowerCase();
+ return configured
+ .split(",")
+ .some((candidate) => candidate.trim().toLowerCase() === normalized);
+}
diff --git a/frontend/src/lib/supabase/admin.ts b/frontend/src/lib/supabase/admin.ts
index 97c6d148..01deb76a 100644
--- a/frontend/src/lib/supabase/admin.ts
+++ b/frontend/src/lib/supabase/admin.ts
@@ -1,38 +1,6 @@
import "server-only";
-import { createClient, type SupabaseClient } from "@supabase/supabase-js";
-import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client";
-import { readDatabaseUrl } from "@/lib/db/config";
-import {
- getSupabaseUrl,
- SupabaseConfigurationError,
-} from "./config";
-
-export function createAdminSupabaseClient() {
- if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
- return createLocalPostgresDataClient(
- readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
- null,
- "service_role",
- ) as unknown as SupabaseClient;
- }
- const url = getSupabaseUrl();
- const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
- if (!serviceRoleKey) {
- throw new SupabaseConfigurationError(["SUPABASE_SERVICE_ROLE_KEY"]);
- }
-
- return createClient(url, serviceRoleKey, {
- auth: { autoRefreshToken: false, persistSession: false },
- });
-}
-
-export function isAdminEmail(email: string | null | undefined) {
- const configured = process.env.ADMIN_EMAILS;
- if (!configured?.trim() || !email) return false;
-
- const normalized = email.trim().toLowerCase();
- return configured
- .split(",")
- .some((candidate) => candidate.trim().toLowerCase() === normalized);
-}
+export {
+ createAdminSupabaseClient,
+ isAdminEmail,
+} from "./admin-client-core";
diff --git a/frontend/src/mastra/index.ts b/frontend/src/mastra/index.ts
index 525923be..0709ad42 100644
--- a/frontend/src/mastra/index.ts
+++ b/frontend/src/mastra/index.ts
@@ -146,6 +146,8 @@ export function toAgentConsultationContext(data: JsonRecord) {
enabled_vargas: rectification.enabled_vargas,
lagna_boundary: rectification.lagna_boundary,
},
+ candidate_range: record(data.candidate_range),
+ range_boundary_contexts: record(data.range_boundary_contexts),
thematic_evidence: selectedTheme,
vedastro_gateway: record(data.vedastro_gateway),
external_engine_evidence: {
@@ -206,6 +208,7 @@ function groundedJyotishInstructions(workflowContext: JsonRecord) {
return `${jyotishInstructions}
The server-computed Jyotish workflow below is the only source for this chart claim. Use it directly, preserve its truth boundaries, and do not run a second consultation workflow.
+When candidate_range and range_boundary_contexts are present, both boundary contexts are authoritative server calculations. Answer only claims supported by both boundaries. Never select a midpoint, peak, or representative minute; never present the range as a confirmed birth time; never give month-level, day-level, or exact event timing from this range.
${JSON.stringify(toAgentConsultationContext(workflowContext))}
`;
diff --git a/frontend/supabase/migrations/20260726020000_birth_time_rectification_v4.sql b/frontend/supabase/migrations/20260726020000_birth_time_rectification_v4.sql
new file mode 100644
index 00000000..8af87795
--- /dev/null
+++ b/frontend/supabase/migrations/20260726020000_birth_time_rectification_v4.sql
@@ -0,0 +1,987 @@
+begin;
+
+create table public.birth_time_rectification_v4_cases (
+ id uuid primary key,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ protocol text not null default 'rectification-evidence-v4' check (protocol = 'rectification-evidence-v4'),
+ version bigint not null default 0 check (version >= 0),
+ status text not null check (status in ('awaiting_answer', 'processing', 'range_ready', 'paused', 'abandoned')),
+ phase text not null check (phase in ('collecting_evidence', 'extracting_evidence', 'scoring_candidates', 'checking_robustness', 'planning_question', 'complete')),
+ calculation_spec jsonb not null check (jsonb_typeof(calculation_spec) = 'object' and octet_length(calculation_spec::text) <= 16384),
+ calculation_spec_hash text not null check (calculation_spec_hash ~ '^[a-f0-9]{64}$'),
+ evidence_set_hash text not null check (evidence_set_hash ~ '^[a-f0-9]{64}$'),
+ current_question jsonb check (current_question is null or (jsonb_typeof(current_question) = 'object' and octet_length(current_question::text) <= 4096)),
+ latest_snapshot_id uuid,
+ accepted_range_start text check (accepted_range_start is null or accepted_range_start ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),
+ accepted_range_end text check (accepted_range_end is null or accepted_range_end ~ '^([01][0-9]|2[0-3]):[0-5][0-9]$'),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ check ((accepted_range_start is null) = (accepted_range_end is null)),
+ check (accepted_range_start is null or accepted_range_start <> accepted_range_end)
+);
+
+create unique index birth_time_rectification_v4_one_active_case
+ on public.birth_time_rectification_v4_cases(user_id)
+ where status <> 'abandoned' and accepted_range_start is null;
+
+create table public.birth_time_rectification_v4_actions (
+ user_id uuid not null references auth.users(id) on delete cascade,
+ action_id uuid not null,
+ case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade,
+ job_id uuid,
+ created_at timestamptz not null default now(),
+ primary key (user_id, action_id)
+);
+
+create table public.birth_time_rectification_v4_turns (
+ id uuid primary key,
+ case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ case_version bigint not null check (case_version > 0),
+ question_id uuid,
+ question_domain text check (question_domain is null or question_domain in ('education', 'relocation', 'relationship', 'career', 'finance', 'health_pressure', 'family', 'other')),
+ question_target_event_id uuid,
+ question text not null check (length(btrim(question)) between 1 and 1000),
+ answer text not null check (length(answer) <= 4000),
+ action_id uuid not null,
+ created_at timestamptz not null default now(),
+ unique (user_id, action_id)
+);
+
+create table public.birth_time_rectification_v4_events (
+ id uuid primary key,
+ case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ created_at timestamptz not null default now(),
+ unique (case_id, id)
+);
+
+alter table public.birth_time_rectification_v4_turns
+ add constraint birth_time_rectification_v4_turn_target_event_fk
+ foreign key (case_id, question_target_event_id)
+ references public.birth_time_rectification_v4_events(case_id, id);
+
+create table public.birth_time_rectification_v4_event_revisions (
+ id uuid primary key,
+ event_id uuid not null references public.birth_time_rectification_v4_events(id) on delete cascade,
+ case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ revision integer not null check (revision > 0),
+ domain text not null check (domain in ('education', 'relocation', 'relationship', 'career', 'finance', 'health_pressure', 'family', 'other')),
+ event_kind text not null check (event_kind in ('education_milestone', 'relocation', 'relationship_start', 'relationship_end', 'career_change', 'finance_change', 'health_event', 'family_event', 'other')),
+ summary text not null check (length(btrim(summary)) between 1 and 1000),
+ raw_text text not null check (length(btrim(raw_text)) between 1 and 4000),
+ date_start date not null,
+ date_end date not null,
+ date_precision text not null check (date_precision in ('day', 'month', 'quarter', 'year', 'range')),
+ date_label text not null check (length(btrim(date_label)) between 1 and 80),
+ scoreability text not null check (scoreability in ('scoreable', 'context_only')),
+ supersedes_revision_id uuid references public.birth_time_rectification_v4_event_revisions(id),
+ created_at timestamptz not null default now(),
+ unique (event_id, revision),
+ check (date_start <= date_end),
+ check (domain not in ('family', 'other') or scoreability = 'context_only'),
+ check (domain <> 'relationship' or event_kind in ('relationship_start', 'relationship_end'))
+);
+
+create table public.birth_time_rectification_v4_candidate_snapshots (
+ id uuid primary key,
+ case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ case_version bigint not null check (case_version >= 0),
+ evidence_set_hash text not null check (evidence_set_hash ~ '^[a-f0-9]{64}$'),
+ calculation_spec_hash text not null check (calculation_spec_hash ~ '^[a-f0-9]{64}$'),
+ algorithm_version text not null check (algorithm_version = 'rectification-v4-range-scoring-1'),
+ candidates jsonb not null check (jsonb_typeof(candidates) = 'array' and jsonb_array_length(candidates) between 1 and 1440 and octet_length(candidates::text) <= 524288),
+ clusters jsonb not null check (jsonb_typeof(clusters) = 'array' and jsonb_array_length(clusters) <= 20 and octet_length(clusters::text) <= 32768),
+ robustness jsonb not null check (jsonb_typeof(robustness) = 'object' and octet_length(robustness::text) <= 16384),
+ can_confirm_exact_minute boolean not null default false check (can_confirm_exact_minute = false),
+ can_accept_range boolean not null,
+ gate_reasons jsonb not null check (jsonb_typeof(gate_reasons) = 'array' and jsonb_array_length(gate_reasons) <= 20),
+ created_at timestamptz not null default now()
+);
+
+alter table public.birth_time_rectification_v4_cases
+ add constraint birth_time_rectification_v4_latest_snapshot_fk
+ foreign key (latest_snapshot_id) references public.birth_time_rectification_v4_candidate_snapshots(id);
+
+create table public.birth_time_rectification_v4_jobs (
+ id uuid primary key,
+ case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ turn_id uuid not null references public.birth_time_rectification_v4_turns(id) on delete cascade,
+ status text not null check (status in ('pending', 'processing', 'completed', 'failed', 'stale')),
+ phase text not null check (phase in ('collecting_evidence', 'extracting_evidence', 'scoring_candidates', 'checking_robustness', 'planning_question', 'complete')),
+ expected_case_version bigint not null check (expected_case_version >= 0),
+ evidence_set_hash text not null check (evidence_set_hash ~ '^[a-f0-9]{64}$'),
+ calculation_spec_hash text not null check (calculation_spec_hash ~ '^[a-f0-9]{64}$'),
+ worker_id uuid,
+ lease_expires_at timestamptz,
+ result_snapshot_id uuid references public.birth_time_rectification_v4_candidate_snapshots(id),
+ error_code text check (error_code is null or length(error_code) between 1 and 120),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+alter table public.birth_time_rectification_v4_actions
+ add constraint birth_time_rectification_v4_actions_job_fk
+ foreign key (job_id) references public.birth_time_rectification_v4_jobs(id);
+
+create index birth_time_rectification_v4_jobs_claim_idx
+ on public.birth_time_rectification_v4_jobs(status, created_at)
+ where status in ('pending', 'processing');
+
+create table public.birth_time_rectification_v4_handoffs (
+ case_id uuid primary key references public.birth_time_rectification_v4_cases(id) on delete cascade,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ question text not null check (length(btrim(question)) between 1 and 500),
+ question_fingerprint text not null check (question_fingerprint ~ '^[a-f0-9]{64}$'),
+ attached_case_version bigint not null check (attached_case_version >= 0),
+ attach_action_id uuid not null,
+ state text not null check (state in ('pending', 'claimed', 'executing', 'consumed')),
+ attempt integer not null default 0 check (attempt between 0 and 1000),
+ request_id uuid not null,
+ claim_action_id uuid,
+ lease_expires_at timestamptz,
+ claimed_at timestamptz,
+ consumed_at timestamptz,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ unique (user_id, request_id),
+ check ((state in ('claimed', 'executing')) = (claim_action_id is not null)),
+ check ((state in ('claimed', 'executing')) = (lease_expires_at is not null)),
+ check ((state = 'consumed') = (consumed_at is not null))
+);
+
+create table public.birth_time_rectification_v4_handoff_attach_receipts (
+ case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade,
+ action_id uuid not null,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ expected_case_version bigint not null check (expected_case_version >= 0),
+ question_fingerprint text not null check (question_fingerprint ~ '^[a-f0-9]{64}$'),
+ response jsonb not null,
+ created_at timestamptz not null default now(),
+ primary key (case_id, action_id)
+);
+
+create table public.birth_time_rectification_v4_handoff_settlements (
+ case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade,
+ request_id uuid not null,
+ user_id uuid not null references auth.users(id) on delete cascade,
+ claim_action_id uuid not null,
+ emitted boolean not null,
+ response jsonb not null,
+ created_at timestamptz not null default now(),
+ primary key (case_id, request_id)
+);
+
+create index birth_time_rectification_v4_handoff_owner_state_idx
+ on public.birth_time_rectification_v4_handoffs(user_id, state, updated_at desc);
+
+alter table public.birth_time_rectification_v4_cases enable row level security;
+alter table public.birth_time_rectification_v4_actions enable row level security;
+alter table public.birth_time_rectification_v4_turns enable row level security;
+alter table public.birth_time_rectification_v4_events enable row level security;
+alter table public.birth_time_rectification_v4_event_revisions enable row level security;
+alter table public.birth_time_rectification_v4_candidate_snapshots enable row level security;
+alter table public.birth_time_rectification_v4_jobs enable row level security;
+alter table public.birth_time_rectification_v4_handoffs enable row level security;
+alter table public.birth_time_rectification_v4_handoff_attach_receipts enable row level security;
+alter table public.birth_time_rectification_v4_handoff_settlements enable row level security;
+
+revoke all on table public.birth_time_rectification_v4_cases, public.birth_time_rectification_v4_actions,
+ public.birth_time_rectification_v4_turns, public.birth_time_rectification_v4_events,
+ public.birth_time_rectification_v4_event_revisions, public.birth_time_rectification_v4_candidate_snapshots,
+ public.birth_time_rectification_v4_jobs, public.birth_time_rectification_v4_handoffs,
+ public.birth_time_rectification_v4_handoff_attach_receipts,
+ public.birth_time_rectification_v4_handoff_settlements from public, anon, authenticated;
+grant all on table public.birth_time_rectification_v4_cases, public.birth_time_rectification_v4_actions,
+ public.birth_time_rectification_v4_turns, public.birth_time_rectification_v4_events,
+ public.birth_time_rectification_v4_event_revisions, public.birth_time_rectification_v4_candidate_snapshots,
+ public.birth_time_rectification_v4_jobs, public.birth_time_rectification_v4_handoffs,
+ public.birth_time_rectification_v4_handoff_attach_receipts,
+ public.birth_time_rectification_v4_handoff_settlements to service_role;
+
+create function public.create_birth_time_rectification_v4_case(
+ p_user_id uuid, p_case_id uuid, p_action_id uuid, p_status text, p_phase text,
+ p_calculation_spec jsonb, p_calculation_spec_hash text, p_evidence_set_hash text,
+ p_current_question jsonb, p_now timestamptz
+) returns uuid
+language plpgsql security definer set search_path = '' as $$
+declare v_case public.birth_time_rectification_v4_cases%rowtype; v_case_id uuid;
+begin
+ select action.case_id into v_case_id from public.birth_time_rectification_v4_actions action
+ where action.user_id = p_user_id and action.action_id = p_action_id;
+ if v_case_id is not null then return v_case_id; end if;
+ perform pg_catalog.pg_advisory_xact_lock(pg_catalog.hashtextextended(p_user_id::text || ':rectification-v4-case', 0));
+ select value.* into v_case from public.birth_time_rectification_v4_cases value
+ where value.user_id = p_user_id and value.status <> 'abandoned'
+ and value.accepted_range_start is null
+ order by value.created_at desc limit 1 for update;
+ if found and v_case.calculation_spec_hash = p_calculation_spec_hash then
+ insert into public.birth_time_rectification_v4_actions(user_id, action_id, case_id, created_at)
+ values (p_user_id, p_action_id, v_case.id, p_now);
+ return v_case.id;
+ end if;
+ if found then
+ update public.birth_time_rectification_v4_cases set
+ status = 'abandoned', phase = 'complete', current_question = null, updated_at = p_now
+ where id = v_case.id;
+ update public.birth_time_rectification_v4_jobs set
+ status = 'stale', lease_expires_at = null, updated_at = p_now
+ where case_id = v_case.id and status in ('pending', 'processing');
+ end if;
+ insert into public.birth_time_rectification_v4_cases (
+ id, user_id, status, phase, calculation_spec, calculation_spec_hash,
+ evidence_set_hash, current_question, created_at, updated_at
+ ) values (
+ p_case_id, p_user_id, p_status, p_phase, p_calculation_spec, p_calculation_spec_hash,
+ p_evidence_set_hash, p_current_question, p_now, p_now
+ );
+ insert into public.birth_time_rectification_v4_actions(user_id, action_id, case_id, created_at)
+ values (p_user_id, p_action_id, p_case_id, p_now);
+ return p_case_id;
+end;
+$$;
+
+create function public.submit_birth_time_rectification_v4_answer(
+ p_user_id uuid, p_case_id uuid, p_action_id uuid, p_expected_version bigint,
+ p_turn_id uuid, p_question_id uuid, p_question_domain text, p_question_target_event_id uuid, p_question text,
+ p_answer text, p_job_id uuid, p_now timestamptz
+) returns uuid
+language plpgsql security definer set search_path = '' as $$
+declare v_case public.birth_time_rectification_v4_cases%rowtype; v_job_id uuid;
+begin
+ select action.job_id into v_job_id from public.birth_time_rectification_v4_actions action
+ where action.user_id = p_user_id and action.action_id = p_action_id;
+ if v_job_id is not null then return v_job_id; end if;
+ select value.* into v_case from public.birth_time_rectification_v4_cases value
+ where value.id = p_case_id and value.user_id = p_user_id for update;
+ if not found then raise exception 'rectification_v4_case_not_found'; end if;
+ if v_case.version <> p_expected_version then raise exception 'stale_rectification_v4_case'; end if;
+ if v_case.status not in ('awaiting_answer', 'range_ready') then raise exception 'rectification_v4_case_not_awaiting_answer'; end if;
+ insert into public.birth_time_rectification_v4_turns(
+ id, case_id, user_id, case_version, question_id, question_domain, question_target_event_id, question, answer, action_id, created_at
+ ) values (
+ p_turn_id, p_case_id, p_user_id, p_expected_version + 1, p_question_id, p_question_domain, p_question_target_event_id,
+ p_question, p_answer, p_action_id, p_now
+ );
+ update public.birth_time_rectification_v4_cases set
+ version = p_expected_version + 1, status = 'processing', phase = 'extracting_evidence',
+ current_question = null, updated_at = p_now
+ where id = p_case_id;
+ insert into public.birth_time_rectification_v4_jobs(
+ id, case_id, user_id, turn_id, status, phase, expected_case_version,
+ evidence_set_hash, calculation_spec_hash, created_at, updated_at
+ ) values (
+ p_job_id, p_case_id, p_user_id, p_turn_id, 'pending', 'extracting_evidence', p_expected_version + 1,
+ v_case.evidence_set_hash, v_case.calculation_spec_hash, p_now, p_now
+ );
+ insert into public.birth_time_rectification_v4_actions(user_id, action_id, case_id, job_id, created_at)
+ values (p_user_id, p_action_id, p_case_id, p_job_id, p_now);
+ return p_job_id;
+end;
+$$;
+
+create function public.revise_birth_time_rectification_v4_event(
+ p_user_id uuid, p_case_id uuid, p_action_id uuid, p_expected_version bigint,
+ p_revision jsonb, p_output_evidence_set_hash text, p_turn_id uuid, p_job_id uuid, p_now timestamptz
+) returns uuid
+language plpgsql security definer set search_path = '' as $$
+declare v_case public.birth_time_rectification_v4_cases%rowtype; v_job_id uuid; v_event_id uuid;
+begin
+ select action.job_id into v_job_id from public.birth_time_rectification_v4_actions action
+ where action.user_id = p_user_id and action.action_id = p_action_id;
+ if v_job_id is not null then return v_job_id; end if;
+ select value.* into v_case from public.birth_time_rectification_v4_cases value
+ where value.id = p_case_id and value.user_id = p_user_id for update;
+ if not found then raise exception 'rectification_v4_case_not_found'; end if;
+ if v_case.version <> p_expected_version then raise exception 'stale_rectification_v4_case'; end if;
+ if v_case.status in ('processing', 'abandoned', 'paused') then raise exception 'rectification_v4_case_invalid_state'; end if;
+ if jsonb_typeof(p_revision) <> 'object' then raise exception 'invalid_rectification_v4_event_revision'; end if;
+ v_event_id = (p_revision->>'eventId')::uuid;
+ insert into public.birth_time_rectification_v4_events(id, case_id, user_id, created_at)
+ values (v_event_id, p_case_id, p_user_id, p_now) on conflict (id) do nothing;
+ insert into public.birth_time_rectification_v4_event_revisions(
+ id, event_id, case_id, user_id, revision, domain, event_kind, summary, raw_text,
+ date_start, date_end, date_precision, date_label, scoreability, supersedes_revision_id, created_at
+ ) values (
+ (p_revision->>'id')::uuid, v_event_id, p_case_id, p_user_id,
+ (p_revision->>'revision')::integer, p_revision->>'domain', p_revision->>'eventKind',
+ p_revision->>'summary', p_revision->>'rawText',
+ (p_revision#>>'{dateRange,start}')::date, (p_revision#>>'{dateRange,end}')::date,
+ p_revision#>>'{dateRange,precision}', p_revision#>>'{dateRange,label}', p_revision->>'scoreability',
+ nullif(p_revision->>'supersedesRevisionId', '')::uuid, (p_revision->>'createdAt')::timestamptz
+ );
+ insert into public.birth_time_rectification_v4_turns(
+ id, case_id, user_id, case_version, question, answer, action_id, created_at
+ ) values (p_turn_id, p_case_id, p_user_id, p_expected_version + 1, '修订事件', '', p_action_id, p_now);
+ update public.birth_time_rectification_v4_cases set
+ version = p_expected_version + 1, status = 'processing', phase = 'scoring_candidates',
+ evidence_set_hash = p_output_evidence_set_hash, current_question = null, updated_at = p_now
+ where id = p_case_id;
+ insert into public.birth_time_rectification_v4_jobs(
+ id, case_id, user_id, turn_id, status, phase, expected_case_version,
+ evidence_set_hash, calculation_spec_hash, created_at, updated_at
+ ) values (
+ p_job_id, p_case_id, p_user_id, p_turn_id, 'pending', 'scoring_candidates', p_expected_version + 1,
+ p_output_evidence_set_hash, v_case.calculation_spec_hash, p_now, p_now
+ );
+ insert into public.birth_time_rectification_v4_actions(user_id, action_id, case_id, job_id, created_at)
+ values (p_user_id, p_action_id, p_case_id, p_job_id, p_now);
+ return p_job_id;
+end;
+$$;
+
+create function public.transition_birth_time_rectification_v4_case(
+ p_user_id uuid, p_case_id uuid, p_action_id uuid, p_expected_version bigint,
+ p_status text, p_phase text, p_accepted_range_start text, p_accepted_range_end text, p_now timestamptz
+) returns uuid
+language plpgsql security definer set search_path = '' as $$
+declare v_case public.birth_time_rectification_v4_cases%rowtype; v_case_id uuid; v_snapshot public.birth_time_rectification_v4_candidate_snapshots%rowtype; v_primary jsonb;
+begin
+ select action.case_id into v_case_id from public.birth_time_rectification_v4_actions action
+ where action.user_id = p_user_id and action.action_id = p_action_id;
+ if v_case_id is not null then return v_case_id; end if;
+ select value.* into v_case from public.birth_time_rectification_v4_cases value
+ where value.id = p_case_id and value.user_id = p_user_id for update;
+ if not found then raise exception 'rectification_v4_case_not_found'; end if;
+ if v_case.version <> p_expected_version then raise exception 'stale_rectification_v4_case'; end if;
+ if v_case.status = 'abandoned' then raise exception 'rectification_v4_case_invalid_state'; end if;
+ if p_status = 'paused' and v_case.status not in ('awaiting_answer', 'range_ready') then raise exception 'rectification_v4_case_invalid_state'; end if;
+ if p_status = 'awaiting_answer' and v_case.status <> 'paused' then raise exception 'rectification_v4_case_invalid_state'; end if;
+ if p_status = 'range_ready' then
+ if v_case.status not in ('awaiting_answer', 'range_ready') or v_case.latest_snapshot_id is null then raise exception 'rectification_v4_case_invalid_state'; end if;
+ select value.* into v_snapshot from public.birth_time_rectification_v4_candidate_snapshots value where value.id = v_case.latest_snapshot_id;
+ v_primary = v_snapshot.clusters->0;
+ if not v_snapshot.can_accept_range
+ or p_accepted_range_start is null or p_accepted_range_end is null
+ or p_accepted_range_start = p_accepted_range_end
+ or v_primary->>'startTime' <> p_accepted_range_start
+ or v_primary->>'endTime' <> p_accepted_range_end then
+ raise exception 'rectification_v4_range_not_acceptable';
+ end if;
+ elsif p_accepted_range_start is not null or p_accepted_range_end is not null then
+ raise exception 'rectification_v4_range_not_acceptable';
+ end if;
+ update public.birth_time_rectification_v4_cases set
+ version = p_expected_version + 1, status = p_status, phase = p_phase,
+ accepted_range_start = case when p_status = 'range_ready' then p_accepted_range_start else accepted_range_start end,
+ accepted_range_end = case when p_status = 'range_ready' then p_accepted_range_end else accepted_range_end end,
+ current_question = case when p_status in ('abandoned', 'range_ready') then null else current_question end,
+ updated_at = p_now
+ where id = p_case_id;
+ insert into public.birth_time_rectification_v4_actions(user_id, action_id, case_id, created_at)
+ values (p_user_id, p_action_id, p_case_id, p_now);
+ return p_case_id;
+end;
+$$;
+
+create function public.claim_next_birth_time_rectification_v4_job(
+ p_worker_id uuid, p_now timestamptz
+) returns uuid
+language plpgsql security definer set search_path = '' as $$
+declare v_job_id uuid;
+begin
+ select value.id into v_job_id from public.birth_time_rectification_v4_jobs value
+ where value.status = 'pending'
+ or (value.status = 'processing' and value.lease_expires_at <= p_now)
+ order by value.created_at for update skip locked limit 1;
+ if v_job_id is null then return null; end if;
+ update public.birth_time_rectification_v4_jobs set
+ status = 'processing', worker_id = p_worker_id, lease_expires_at = p_now + interval '10 minutes',
+ error_code = null, updated_at = p_now
+ where id = v_job_id;
+ return v_job_id;
+end;
+$$;
+
+create function public.update_birth_time_rectification_v4_job_phase(
+ p_worker_id uuid, p_job_id uuid, p_phase text, p_now timestamptz
+) returns void
+language plpgsql security definer set search_path = '' as $$
+declare v_case_id uuid;
+begin
+ update public.birth_time_rectification_v4_jobs set phase = p_phase, updated_at = p_now,
+ lease_expires_at = p_now + interval '10 minutes'
+ where id = p_job_id and worker_id = p_worker_id and status = 'processing'
+ and lease_expires_at > p_now returning case_id into v_case_id;
+ if v_case_id is null then raise exception 'rectification_v4_job_lease_lost'; end if;
+ update public.birth_time_rectification_v4_cases set phase = p_phase, updated_at = p_now where id = v_case_id;
+end;
+$$;
+
+create function public.complete_birth_time_rectification_v4_job(
+ p_worker_id uuid, p_job_id uuid, p_expected_case_version bigint,
+ p_input_evidence_set_hash text, p_output_evidence_set_hash text, p_calculation_spec_hash text,
+ p_event_revisions jsonb, p_snapshot jsonb, p_next_question jsonb,
+ p_status text, p_phase text, p_now timestamptz
+) returns uuid
+language plpgsql security definer set search_path = '' as $$
+declare
+ v_job public.birth_time_rectification_v4_jobs%rowtype;
+ v_case public.birth_time_rectification_v4_cases%rowtype;
+ item jsonb;
+ v_snapshot_id uuid;
+begin
+ select value.* into v_job from public.birth_time_rectification_v4_jobs value
+ where value.id = p_job_id for update;
+ if not found or v_job.worker_id is distinct from p_worker_id or v_job.status <> 'processing'
+ or v_job.lease_expires_at <= p_now then raise exception 'rectification_v4_job_lease_lost'; end if;
+ select value.* into v_case from public.birth_time_rectification_v4_cases value
+ where value.id = v_job.case_id for update;
+ if v_case.version <> p_expected_case_version
+ or v_case.evidence_set_hash <> p_input_evidence_set_hash
+ or v_case.calculation_spec_hash <> p_calculation_spec_hash
+ or v_job.expected_case_version <> p_expected_case_version
+ or v_job.evidence_set_hash <> p_input_evidence_set_hash
+ or v_job.calculation_spec_hash <> p_calculation_spec_hash then
+ update public.birth_time_rectification_v4_jobs set status = 'stale', updated_at = p_now where id = p_job_id;
+ raise exception 'stale_rectification_v4_job';
+ end if;
+ if jsonb_typeof(p_event_revisions) <> 'array' then raise exception 'invalid_rectification_v4_event_revisions'; end if;
+ for item in select value from jsonb_array_elements(p_event_revisions) loop
+ insert into public.birth_time_rectification_v4_events(id, case_id, user_id, created_at)
+ values ((item->>'eventId')::uuid, v_case.id, v_case.user_id, (item->>'createdAt')::timestamptz)
+ on conflict (id) do nothing;
+ insert into public.birth_time_rectification_v4_event_revisions(
+ id, event_id, case_id, user_id, revision, domain, event_kind, summary, raw_text,
+ date_start, date_end, date_precision, date_label, scoreability, supersedes_revision_id, created_at
+ ) values (
+ (item->>'id')::uuid, (item->>'eventId')::uuid, v_case.id, v_case.user_id,
+ (item->>'revision')::integer, item->>'domain', item->>'eventKind', item->>'summary', item->>'rawText',
+ (item#>>'{dateRange,start}')::date, (item#>>'{dateRange,end}')::date,
+ item#>>'{dateRange,precision}', item#>>'{dateRange,label}', item->>'scoreability',
+ nullif(item->>'supersedesRevisionId', '')::uuid, (item->>'createdAt')::timestamptz
+ );
+ end loop;
+ if p_snapshot is not null then
+ v_snapshot_id = (p_snapshot->>'id')::uuid;
+ if (p_snapshot->>'canConfirmExactMinute')::boolean then raise exception 'exact_minute_confirmation_forbidden'; end if;
+ insert into public.birth_time_rectification_v4_candidate_snapshots(
+ id, case_id, user_id, case_version, evidence_set_hash, calculation_spec_hash,
+ algorithm_version, candidates, clusters, robustness, can_confirm_exact_minute,
+ can_accept_range, gate_reasons, created_at
+ ) values (
+ v_snapshot_id, v_case.id, v_case.user_id, (p_snapshot->>'caseVersion')::bigint,
+ p_snapshot->>'evidenceSetHash', p_snapshot->>'calculationSpecHash', p_snapshot->>'algorithmVersion',
+ p_snapshot->'candidates', p_snapshot->'clusters', p_snapshot->'robustness', false,
+ (p_snapshot->>'canAcceptRange')::boolean, p_snapshot->'gateReasons', (p_snapshot->>'createdAt')::timestamptz
+ );
+ end if;
+ update public.birth_time_rectification_v4_cases set
+ version = p_expected_case_version + 1, evidence_set_hash = p_output_evidence_set_hash,
+ latest_snapshot_id = coalesce(v_snapshot_id, latest_snapshot_id), current_question = p_next_question,
+ status = p_status, phase = p_phase, updated_at = p_now
+ where id = v_case.id;
+ update public.birth_time_rectification_v4_jobs set
+ status = 'completed', phase = p_phase, result_snapshot_id = v_snapshot_id,
+ lease_expires_at = null, updated_at = p_now
+ where id = p_job_id;
+ return v_case.id;
+end;
+$$;
+
+create function public.fail_birth_time_rectification_v4_job(
+ p_worker_id uuid, p_job_id uuid, p_expected_case_version bigint, p_error_code text,
+ p_restore_question jsonb, p_now timestamptz
+) returns void
+language plpgsql security definer set search_path = '' as $$
+declare v_case_id uuid;
+begin
+ update public.birth_time_rectification_v4_jobs set
+ status = 'failed', error_code = p_error_code, lease_expires_at = null, updated_at = p_now
+ where id = p_job_id and worker_id = p_worker_id and status = 'processing'
+ returning case_id into v_case_id;
+ if v_case_id is null then raise exception 'rectification_v4_job_lease_lost'; end if;
+ update public.birth_time_rectification_v4_cases set
+ status = 'awaiting_answer', phase = 'collecting_evidence', current_question = p_restore_question, updated_at = p_now
+ where id = v_case_id and version = p_expected_case_version;
+end;
+$$;
+
+
+create function public.birth_time_rectification_v4_handoff_projection(
+ p_user_id uuid,
+ p_case_id uuid
+) returns jsonb
+language sql stable security definer set search_path = '' as $$
+ select pg_catalog.jsonb_build_object(
+ 'protocol', 'rectification-evidence-v4',
+ 'caseId', handoff.case_id,
+ 'caseVersion', case_value.version,
+ 'question', handoff.question,
+ 'questionFingerprint', handoff.question_fingerprint,
+ 'requestId', handoff.request_id,
+ 'status', case
+ when handoff.state = 'pending' then 'pending'
+ when handoff.state in ('claimed', 'executing')
+ and handoff.lease_expires_at <= pg_catalog.now() then 'pending'
+ when handoff.state in ('claimed', 'executing') then 'in_progress'
+ else 'consumed'
+ end,
+ 'acceptedRange', case
+ when case_value.accepted_range_start is null then null
+ else pg_catalog.jsonb_build_object(
+ 'start', case_value.accepted_range_start,
+ 'end', case_value.accepted_range_end
+ )
+ end
+ )
+ from public.birth_time_rectification_v4_handoffs handoff
+ join public.birth_time_rectification_v4_cases case_value
+ on case_value.id = handoff.case_id and case_value.user_id = handoff.user_id
+ where handoff.case_id = p_case_id and handoff.user_id = p_user_id;
+$$;
+
+create function public.attach_birth_time_rectification_v4_question(
+ p_user_id uuid,
+ p_case_id uuid,
+ p_expected_version bigint,
+ p_action_id uuid,
+ p_question text,
+ p_question_fingerprint text
+) returns jsonb
+language plpgsql security definer set search_path = '' as $$
+declare
+ v_case public.birth_time_rectification_v4_cases%rowtype;
+ v_handoff public.birth_time_rectification_v4_handoffs%rowtype;
+ v_receipt public.birth_time_rectification_v4_handoff_attach_receipts%rowtype;
+ v_response jsonb;
+begin
+ if p_user_id is null or p_case_id is null or p_action_id is null
+ or p_expected_version is null or p_expected_version < 0
+ or p_question is null or length(btrim(p_question)) not between 1 and 500
+ or p_question_fingerprint is null or p_question_fingerprint !~ '^[a-f0-9]{64}$'
+ or public.conversational_rectification_question_fingerprint(p_question)
+ is distinct from p_question_fingerprint then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+
+ perform pg_catalog.pg_advisory_xact_lock(
+ pg_catalog.hashtextextended(
+ p_user_id::text || ':' || p_case_id::text || ':rectification-v4-attach', 0
+ )
+ );
+
+ select case_value.* into v_case
+ from public.birth_time_rectification_v4_cases case_value
+ where case_value.id = p_case_id and case_value.user_id = p_user_id
+ for update;
+ if not found then
+ raise exception 'rectification_v4_case_not_found' using errcode = 'P0001';
+ end if;
+
+ select receipt.* into v_receipt
+ from public.birth_time_rectification_v4_handoff_attach_receipts receipt
+ where receipt.case_id = p_case_id and receipt.action_id = p_action_id
+ for update;
+ if found then
+ if v_receipt.user_id is distinct from p_user_id
+ or v_receipt.expected_case_version is distinct from p_expected_version
+ or v_receipt.question_fingerprint is distinct from p_question_fingerprint then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+ return v_receipt.response;
+ end if;
+
+ if v_case.version is distinct from p_expected_version then
+ raise exception 'stale_rectification_v4_case' using errcode = 'P0001';
+ end if;
+ if v_case.status = 'abandoned' then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+
+ select handoff.* into v_handoff
+ from public.birth_time_rectification_v4_handoffs handoff
+ where handoff.case_id = p_case_id and handoff.user_id = p_user_id
+ for update;
+
+ if found then
+ if v_handoff.question_fingerprint is distinct from p_question_fingerprint
+ or v_handoff.question is distinct from p_question then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+ else
+ insert into public.birth_time_rectification_v4_handoffs (
+ case_id, user_id, question, question_fingerprint,
+ attached_case_version, attach_action_id, state, attempt, request_id
+ ) values (
+ p_case_id, p_user_id, p_question, p_question_fingerprint,
+ p_expected_version, p_action_id, 'pending', 0,
+ public.conversational_rectification_handoff_request_id(p_case_id, 0)
+ );
+ end if;
+
+ v_response := public.birth_time_rectification_v4_handoff_projection(
+ p_user_id, p_case_id
+ );
+ insert into public.birth_time_rectification_v4_handoff_attach_receipts (
+ case_id, action_id, user_id, expected_case_version,
+ question_fingerprint, response
+ ) values (
+ p_case_id, p_action_id, p_user_id, p_expected_version,
+ p_question_fingerprint, v_response
+ );
+ return v_response;
+end;
+$$;
+
+create function public.load_birth_time_rectification_v4_handoff(
+ p_user_id uuid,
+ p_case_id uuid default null
+) returns jsonb
+language plpgsql stable security definer set search_path = '' as $$
+declare v_case_id uuid;
+begin
+ if p_user_id is null then return null; end if;
+ if p_case_id is not null then
+ v_case_id := p_case_id;
+ else
+ select handoff.case_id into v_case_id
+ from public.birth_time_rectification_v4_handoffs handoff
+ where handoff.user_id = p_user_id and handoff.state <> 'consumed'
+ order by handoff.updated_at desc, handoff.created_at desc
+ limit 1;
+ end if;
+ return public.birth_time_rectification_v4_handoff_projection(p_user_id, v_case_id);
+end;
+$$;
+
+create function public.consume_birth_time_rectification_v4_handoff(
+ p_user_id uuid,
+ p_case_id uuid
+) returns void
+language plpgsql security definer set search_path = '' as $$
+begin
+ update public.birth_time_rectification_v4_handoffs
+ set state = 'consumed', claim_action_id = null, lease_expires_at = null,
+ consumed_at = coalesce(consumed_at, pg_catalog.now()), updated_at = pg_catalog.now()
+ where case_id = p_case_id and user_id = p_user_id;
+end;
+$$;
+
+create function public.claim_birth_time_rectification_v4_handoff(
+ p_user_id uuid,
+ p_case_id uuid,
+ p_expected_version bigint,
+ p_action_id uuid,
+ p_question_fingerprint text
+) returns jsonb
+language plpgsql security definer set search_path = '' as $$
+declare
+ v_case public.birth_time_rectification_v4_cases%rowtype;
+ v_handoff public.birth_time_rectification_v4_handoffs%rowtype;
+ v_request_status text;
+begin
+ if p_user_id is null or p_case_id is null or p_action_id is null
+ or p_expected_version is null or p_expected_version < 0
+ or p_question_fingerprint is null or p_question_fingerprint !~ '^[a-f0-9]{64}$' then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+
+ perform pg_catalog.pg_advisory_xact_lock(
+ pg_catalog.hashtextextended(
+ p_user_id::text || ':' || p_case_id::text || ':rectification-v4-claim', 0
+ )
+ );
+
+ select case_value.* into v_case
+ from public.birth_time_rectification_v4_cases case_value
+ where case_value.id = p_case_id and case_value.user_id = p_user_id
+ for update;
+ if not found then
+ raise exception 'rectification_v4_case_not_found' using errcode = 'P0001';
+ end if;
+ if v_case.version is distinct from p_expected_version then
+ raise exception 'stale_rectification_v4_case' using errcode = 'P0001';
+ end if;
+ if v_case.status is distinct from 'range_ready'
+ or v_case.phase is distinct from 'complete'
+ or v_case.accepted_range_start is null
+ or v_case.accepted_range_end is null then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+
+ select handoff.* into v_handoff
+ from public.birth_time_rectification_v4_handoffs handoff
+ where handoff.case_id = p_case_id and handoff.user_id = p_user_id
+ for update;
+ if not found or v_handoff.question_fingerprint is distinct from p_question_fingerprint then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+ if v_handoff.state = 'consumed' then
+ return public.birth_time_rectification_v4_handoff_projection(p_user_id, p_case_id);
+ end if;
+
+ if v_handoff.state in ('claimed', 'executing')
+ and v_handoff.lease_expires_at > pg_catalog.now() then
+ if v_handoff.state = 'claimed' and v_handoff.claim_action_id = p_action_id then
+ return public.birth_time_rectification_v4_handoff_projection(p_user_id, p_case_id)
+ || pg_catalog.jsonb_build_object('status', 'claimed');
+ end if;
+ return public.birth_time_rectification_v4_handoff_projection(p_user_id, p_case_id)
+ || pg_catalog.jsonb_build_object('status', 'in_progress');
+ end if;
+
+ select request.status into v_request_status
+ from public.consultation_requests request
+ where request.user_id = p_user_id and request.request_id = v_handoff.request_id::text
+ for update;
+ if v_request_status = 'completed' then
+ perform public.consume_birth_time_rectification_v4_handoff(p_user_id, p_case_id);
+ return public.birth_time_rectification_v4_handoff_projection(p_user_id, p_case_id);
+ end if;
+ if v_request_status = 'cancelled' then
+ update public.birth_time_rectification_v4_handoffs
+ set attempt = attempt + 1,
+ request_id = public.conversational_rectification_handoff_request_id(
+ p_case_id, attempt + 1
+ )
+ where case_id = p_case_id and user_id = p_user_id
+ returning * into v_handoff;
+ end if;
+
+ update public.birth_time_rectification_v4_handoffs
+ set state = 'claimed', claim_action_id = p_action_id,
+ lease_expires_at = pg_catalog.now() + interval '2 minutes',
+ claimed_at = pg_catalog.now(), consumed_at = null, updated_at = pg_catalog.now()
+ where case_id = p_case_id and user_id = p_user_id;
+ return public.birth_time_rectification_v4_handoff_projection(p_user_id, p_case_id)
+ || pg_catalog.jsonb_build_object('status', 'claimed');
+end;
+$$;
+
+create function public.begin_birth_time_rectification_v4_handoff_execution(
+ p_user_id uuid,
+ p_case_id uuid,
+ p_expected_version bigint,
+ p_claim_action_id uuid,
+ p_request_id uuid,
+ p_question_fingerprint text
+) returns jsonb
+language plpgsql security definer set search_path = '' as $$
+declare
+ v_case public.birth_time_rectification_v4_cases%rowtype;
+ v_handoff public.birth_time_rectification_v4_handoffs%rowtype;
+ v_request_status text;
+ v_credits integer;
+ v_accepted_range jsonb;
+begin
+ if p_user_id is null or p_case_id is null or p_claim_action_id is null
+ or p_request_id is null or p_expected_version is null or p_expected_version < 0
+ or p_question_fingerprint is null or p_question_fingerprint !~ '^[a-f0-9]{64}$' then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+
+ perform pg_catalog.pg_advisory_xact_lock(
+ pg_catalog.hashtextextended(
+ p_user_id::text || ':' || p_case_id::text || ':rectification-v4-execute', 0
+ )
+ );
+
+ select case_value.* into v_case
+ from public.birth_time_rectification_v4_cases case_value
+ where case_value.id = p_case_id and case_value.user_id = p_user_id
+ for update;
+ select handoff.* into v_handoff
+ from public.birth_time_rectification_v4_handoffs handoff
+ where handoff.case_id = p_case_id and handoff.user_id = p_user_id
+ for update;
+
+ if v_case.id is null or v_handoff.case_id is null
+ or v_case.version is distinct from p_expected_version
+ or v_case.status is distinct from 'range_ready'
+ or v_case.phase is distinct from 'complete'
+ or v_case.accepted_range_start is null
+ or v_case.accepted_range_end is null
+ or v_handoff.question_fingerprint is distinct from p_question_fingerprint
+ or v_handoff.request_id is distinct from p_request_id then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+
+ v_accepted_range := pg_catalog.jsonb_build_object(
+ 'start', v_case.accepted_range_start,
+ 'end', v_case.accepted_range_end
+ );
+
+ if v_handoff.state = 'consumed' then
+ return pg_catalog.jsonb_build_object(
+ 'status', 'consumed', 'requestId', p_request_id, 'acceptedRange', v_accepted_range
+ );
+ end if;
+ if v_handoff.claim_action_id is distinct from p_claim_action_id
+ or v_handoff.lease_expires_at <= pg_catalog.now() then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+ if v_handoff.state = 'executing' then
+ return pg_catalog.jsonb_build_object(
+ 'status', 'in_progress', 'requestId', p_request_id, 'acceptedRange', v_accepted_range
+ );
+ end if;
+ if v_handoff.state is distinct from 'claimed' then
+ return pg_catalog.jsonb_build_object(
+ 'status', 'in_progress', 'requestId', p_request_id, 'acceptedRange', v_accepted_range
+ );
+ end if;
+
+ select request.status into v_request_status
+ from public.consultation_requests request
+ where request.user_id = p_user_id and request.request_id = p_request_id::text
+ for update;
+ if v_request_status = 'completed' then
+ perform public.consume_birth_time_rectification_v4_handoff(p_user_id, p_case_id);
+ return pg_catalog.jsonb_build_object(
+ 'status', 'consumed', 'requestId', p_request_id, 'acceptedRange', v_accepted_range
+ );
+ end if;
+ if v_request_status = 'cancelled' then
+ update public.birth_time_rectification_v4_handoffs
+ set state = 'pending', attempt = attempt + 1,
+ request_id = public.conversational_rectification_handoff_request_id(
+ p_case_id, attempt + 1
+ ),
+ claim_action_id = null, lease_expires_at = null,
+ claimed_at = null, updated_at = pg_catalog.now()
+ where case_id = p_case_id and user_id = p_user_id;
+ return pg_catalog.jsonb_build_object(
+ 'status', 'released', 'requestId', p_request_id, 'acceptedRange', v_accepted_range
+ );
+ end if;
+
+ select profile.credits into v_credits
+ from public.profiles profile where profile.id = p_user_id;
+ update public.birth_time_rectification_v4_handoffs
+ set state = 'executing', lease_expires_at = pg_catalog.now() + interval '2 minutes',
+ updated_at = pg_catalog.now()
+ where case_id = p_case_id and user_id = p_user_id;
+ return pg_catalog.jsonb_build_object(
+ 'status', 'ready',
+ 'requestId', p_request_id,
+ 'billingReused', coalesce(v_request_status = 'reserved', false),
+ 'credits', v_credits,
+ 'acceptedRange', v_accepted_range
+ );
+end;
+$$;
+
+create function public.settle_birth_time_rectification_v4_handoff(
+ p_user_id uuid,
+ p_case_id uuid,
+ p_claim_action_id uuid,
+ p_request_id uuid,
+ p_emitted boolean
+) returns jsonb
+language plpgsql security definer set search_path = '' as $$
+declare
+ v_handoff public.birth_time_rectification_v4_handoffs%rowtype;
+ v_receipt public.birth_time_rectification_v4_handoff_settlements%rowtype;
+ v_success boolean;
+ v_credits integer;
+ v_error text;
+ v_response jsonb;
+begin
+ if p_user_id is null or p_case_id is null or p_claim_action_id is null
+ or p_request_id is null or p_emitted is null then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+
+ perform pg_catalog.pg_advisory_xact_lock(
+ pg_catalog.hashtextextended(
+ p_user_id::text || ':' || p_case_id::text || ':rectification-v4-settle', 0
+ )
+ );
+
+ select settlement.* into v_receipt
+ from public.birth_time_rectification_v4_handoff_settlements settlement
+ where settlement.case_id = p_case_id and settlement.request_id = p_request_id
+ for update;
+ if found then
+ if v_receipt.user_id is distinct from p_user_id
+ or v_receipt.claim_action_id is distinct from p_claim_action_id
+ or v_receipt.emitted is distinct from p_emitted then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+ return v_receipt.response;
+ end if;
+
+ select handoff.* into v_handoff
+ from public.birth_time_rectification_v4_handoffs handoff
+ where handoff.case_id = p_case_id and handoff.user_id = p_user_id
+ for update;
+ if not found or v_handoff.request_id is distinct from p_request_id
+ or v_handoff.claim_action_id is distinct from p_claim_action_id
+ or v_handoff.state not in ('claimed', 'executing') then
+ raise exception 'rectification_v4_handoff_conflict' using errcode = 'P0001';
+ end if;
+
+ if p_emitted then
+ select result.success, result.credits, result.error_code
+ into v_success, v_credits, v_error
+ from public.complete_consultation_credit(p_user_id, p_request_id::text) result;
+ if v_success is not true then
+ raise exception 'rectification_v4_billing_failed' using errcode = 'P0001';
+ end if;
+ perform public.consume_birth_time_rectification_v4_handoff(p_user_id, p_case_id);
+ v_response := pg_catalog.jsonb_build_object(
+ 'status', 'consumed', 'requestId', p_request_id, 'credits', v_credits
+ );
+ else
+ select result.success, result.credits, result.error_code
+ into v_success, v_credits, v_error
+ from public.cancel_consultation_credit(p_user_id, p_request_id::text) result;
+ if v_success is not true then
+ raise exception 'rectification_v4_billing_failed' using errcode = 'P0001';
+ end if;
+ update public.birth_time_rectification_v4_handoffs
+ set state = 'pending', attempt = attempt + 1,
+ request_id = public.conversational_rectification_handoff_request_id(
+ p_case_id, attempt + 1
+ ),
+ claim_action_id = null, lease_expires_at = null,
+ claimed_at = null, updated_at = pg_catalog.now()
+ where case_id = p_case_id and user_id = p_user_id;
+ v_response := pg_catalog.jsonb_build_object(
+ 'status', 'pending', 'requestId', p_request_id, 'credits', v_credits
+ );
+ end if;
+
+ insert into public.birth_time_rectification_v4_handoff_settlements (
+ case_id, request_id, user_id, claim_action_id, emitted, response
+ ) values (
+ p_case_id, p_request_id, p_user_id, p_claim_action_id, p_emitted, v_response
+ );
+ return v_response;
+end;
+$$;
+
+revoke all on function public.create_birth_time_rectification_v4_case(uuid, uuid, uuid, text, text, jsonb, text, text, jsonb, timestamptz) from public, anon, authenticated;
+revoke all on function public.submit_birth_time_rectification_v4_answer(uuid, uuid, uuid, bigint, uuid, uuid, text, uuid, text, text, uuid, timestamptz) from public, anon, authenticated;
+revoke all on function public.revise_birth_time_rectification_v4_event(uuid, uuid, uuid, bigint, jsonb, text, uuid, uuid, timestamptz) from public, anon, authenticated;
+revoke all on function public.transition_birth_time_rectification_v4_case(uuid, uuid, uuid, bigint, text, text, text, text, timestamptz) from public, anon, authenticated;
+revoke all on function public.claim_next_birth_time_rectification_v4_job(uuid, timestamptz) from public, anon, authenticated;
+revoke all on function public.update_birth_time_rectification_v4_job_phase(uuid, uuid, text, timestamptz) from public, anon, authenticated;
+revoke all on function public.complete_birth_time_rectification_v4_job(uuid, uuid, bigint, text, text, text, jsonb, jsonb, jsonb, text, text, timestamptz) from public, anon, authenticated;
+revoke all on function public.fail_birth_time_rectification_v4_job(uuid, uuid, bigint, text, jsonb, timestamptz) from public, anon, authenticated;
+revoke all on function public.birth_time_rectification_v4_handoff_projection(uuid, uuid) from public, anon, authenticated, service_role;
+revoke all on function public.consume_birth_time_rectification_v4_handoff(uuid, uuid) from public, anon, authenticated, service_role;
+revoke all on function public.attach_birth_time_rectification_v4_question(uuid, uuid, bigint, uuid, text, text) from public, anon, authenticated;
+revoke all on function public.load_birth_time_rectification_v4_handoff(uuid, uuid) from public, anon, authenticated;
+revoke all on function public.claim_birth_time_rectification_v4_handoff(uuid, uuid, bigint, uuid, text) from public, anon, authenticated;
+revoke all on function public.begin_birth_time_rectification_v4_handoff_execution(uuid, uuid, bigint, uuid, uuid, text) from public, anon, authenticated;
+revoke all on function public.settle_birth_time_rectification_v4_handoff(uuid, uuid, uuid, uuid, boolean) from public, anon, authenticated;
+grant execute on function public.create_birth_time_rectification_v4_case(uuid, uuid, uuid, text, text, jsonb, text, text, jsonb, timestamptz) to service_role;
+grant execute on function public.submit_birth_time_rectification_v4_answer(uuid, uuid, uuid, bigint, uuid, uuid, text, uuid, text, text, uuid, timestamptz) to service_role;
+grant execute on function public.revise_birth_time_rectification_v4_event(uuid, uuid, uuid, bigint, jsonb, text, uuid, uuid, timestamptz) to service_role;
+grant execute on function public.transition_birth_time_rectification_v4_case(uuid, uuid, uuid, bigint, text, text, text, text, timestamptz) to service_role;
+grant execute on function public.claim_next_birth_time_rectification_v4_job(uuid, timestamptz) to service_role;
+grant execute on function public.update_birth_time_rectification_v4_job_phase(uuid, uuid, text, timestamptz) to service_role;
+grant execute on function public.complete_birth_time_rectification_v4_job(uuid, uuid, bigint, text, text, text, jsonb, jsonb, jsonb, text, text, timestamptz) to service_role;
+grant execute on function public.fail_birth_time_rectification_v4_job(uuid, uuid, bigint, text, jsonb, timestamptz) to service_role;
+grant execute on function public.attach_birth_time_rectification_v4_question(uuid, uuid, bigint, uuid, text, text) to service_role;
+grant execute on function public.load_birth_time_rectification_v4_handoff(uuid, uuid) to service_role;
+grant execute on function public.claim_birth_time_rectification_v4_handoff(uuid, uuid, bigint, uuid, text) to service_role;
+grant execute on function public.begin_birth_time_rectification_v4_handoff_execution(uuid, uuid, bigint, uuid, uuid, text) to service_role;
+grant execute on function public.settle_birth_time_rectification_v4_handoff(uuid, uuid, uuid, uuid, boolean) to service_role;
+
+commit;
diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts
index e83f14f2..f23b2ead 100644
--- a/frontend/tests/consultation-entrypoint.test.ts
+++ b/frontend/tests/consultation-entrypoint.test.ts
@@ -78,47 +78,46 @@ test("ordinary product drafts keep the public question and clear hidden routing
assert.match(source, /setDraft\(pending\.question\);[\s\S]*?setDraftTheme\(pending\.theme\);[\s\S]*?setDraftEntrypoint\(pending\.entrypoint\);/);
});
-test("homepage birth-time card opens the v3 surface instead of ordinary consultation", () => {
+test("homepage birth-time card opens the v4 evidence surface instead of ordinary consultation", () => {
const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
assert.match(source, /function openBirthTimeRectification/);
+ const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
assert.match(source, / {
- 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);
+test("homepage opens the v4 panel without invoking the retired v3 start command", () => {
+ const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+ const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
+ const start = page.indexOf("async function openBirthTimeRectification");
+ const end = page.indexOf("function handleConversationalRectificationTurn", start);
+ const handler = page.slice(start, end);
- assert.match(handler, /sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"/);
- assert.match(
- handler,
- /sendConversationalRectificationCommand\(\{[\s\S]*?type:\s*"start"[\s\S]*?modelId:\s*rectificationSession\.modelId/,
- );
+ assert.doesNotMatch(handler, /sendConversationalRectificationCommand/);
+ const component = readFileSync(new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8");
+ assert.match(page, / {
- 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);
+test("a stale v4 mutation refreshes the same case after a 409", () => {
+ const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
- assert.match(handler, /error instanceof ConversationalRectificationRequestError/);
- assert.match(handler, /error\.status !== 409/);
- assert.match(handler, /const latest = await fetchAccount\(\)/);
- assert.match(handler, /if \(!latest\.rectificationCase\) throw error/);
- assert.match(handler, /type: "resume"[\s\S]*?latest\.rectificationCase\.caseId/);
- assert.match(handler, /latest\.rectificationCase\.turnVersion/);
+ assert.match(hook, /caught instanceof RectificationV4RequestError && caught\.status === 409 && data/);
+ assert.match(hook, /await refresh\(data\.case\.id\)/);
+ assert.match(hook, /loadRectificationV4\(caseId\)/);
});
-test("homepage birth-time card opens its dedicated session before the first turn resolves", () => {
+test("homepage birth-time card opens its dedicated session before v4 data loads", () => {
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);
@@ -134,36 +133,29 @@ test("homepage birth-time card opens its dedicated session before the first turn
assert.match(handler, /rectificationOpenInFlight\.current/);
assert.match(handler, /rectificationOpenInFlight\.current = true;[\s\S]*?finally \{[\s\S]*?rectificationOpenInFlight\.current = false;/);
assert.match(handler, /setRectificationReturnSessionId\(sourceSession\.id\)/);
- assert.match(handler, /type: "start",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
- assert.match(handler, /type: "resume",[\s\S]*?onNarrativeDelta\(text\)[\s\S]*?setRectificationOpeningAssistantText/);
+ assert.doesNotMatch(handler, /onNarrativeDelta|sendConversationalRectificationCommand/);
assert.match(source, /const rectificationSurfaceOpen = activeRectificationSession\s*&& activeSession\.id === rectificationSessionId/);
- assert.match(source, /rectificationSurfaceOpen && \(!visibleRectificationTurn && rectificationError \? \([\s\S]*? {
- 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 turnVisible = handler.indexOf("setRectificationInitialTurn(turn)");
- const backgroundPersist = handler.indexOf("void rectificationPersistence.current.enqueue(");
+test("the v4 panel owns case recovery while the page persists only the dedicated session shell", () => {
+ const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+ const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
+ const start = page.indexOf("async function openBirthTimeRectification");
+ const end = page.indexOf("function handleConversationalRectificationTurn", start);
+ const handler = page.slice(start, end);
- assert.ok(turnVisible >= 0);
- assert.ok(backgroundPersist > turnVisible);
- assert.match(handler, /void rectificationPersistence\.current\.enqueue\([\s\S]*?\(\) => persistSession\([\s\S]*?\.catch\(\(\) => \{[\s\S]*?校正已经开始,但会话关联暂时未同步到云端。/);
+ assert.match(handler, /persistSession\(rectificationSession, "create"\)/);
+ assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
+ assert.match(hook, /existingHandoff[\s\S]*?loadRectificationV4\(existingHandoff\.caseId\)[\s\S]*?createRectificationV4\(\)/);
});
-test("a direct homepage start skips the durable handoff read when no question was handed off", () => {
- 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);
+test("a direct homepage start restores any active v4 case before creating another", () => {
+ const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
- assert.match(handler, /const localHandoff = rectificationQuestionHandoff\.current\.peek\(\)/);
- assert.match(
- handler,
- /const durable = requestedQuestion !== null \|\| localHandoff !== null\s*\? await durableRectificationQuestionHandoff\.current\.load\(\)\s*:\s*null/,
- );
+ assert.match(hook, /const existingHandoff = await loadRectificationV4Handoff\(\)/);
+ assert.match(hook, /existingHandoff\s*\? await loadRectificationV4\(existingHandoff\.caseId\)\s*:\s*await createRectificationV4\(\)/);
+ assert.doesNotMatch(hook, /sendConversationalRectificationCommand/);
});
test("rectification cards render only inside the active rectification session", () => {
@@ -186,32 +178,31 @@ test("selecting a rectification session resumes it without an intermediate confi
assert.match(selectSession, /resumeRectificationSession\.current\(nextSession\)/);
assert.match(source, /resumeRectificationSession\.current\(activeSession\)/);
assert.doesNotMatch(source, /RectificationLoadingState|重试恢复/);
- assert.match(source, /setComposerNotice\(message\)/);
+ assert.match(source, / {
- 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);
+test("homepage reuses the dedicated rectification session while v4 restores the active case", () => {
+ const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+ const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
+ const start = page.indexOf("async function openBirthTimeRectification");
+ const end = page.indexOf("function handleConversationalRectificationTurn", start);
+ const handler = page.slice(start, end);
- assert.match(handler, /const accountResumeCase = action === "resume" \? account\.rectificationCase : null/);
- assert.match(handler, /session\.rectificationCaseId === accountResumeCase\.caseId/);
- assert.match(handler, /resumableSession \?\? createSession/);
+ assert.match(handler, /sessions\.find\(\(session\) => session\.sessionType === "birth_time_rectification"\)/);
+ assert.match(handler, /existing \?\? createSession/);
+ assert.match(hook, /loadRectificationV4Handoff|createRectificationV4/);
});
-test("a bound rectification session resumes its own case while a homepage restart stays dedicated", () => {
- 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);
+test("a bound rectification session and a homepage restart share the v4 active-case loader", () => {
+ const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+ const hook = readFileSync(new URL("../src/hooks/use-rectification-v4.ts", import.meta.url), "utf8");
+ const start = page.indexOf("async function openBirthTimeRectification");
+ const end = page.indexOf("function handleConversationalRectificationTurn", start);
+ const handler = page.slice(start, end);
- assert.match(handler, /const sourceBoundCaseId = sourceSession\.sessionType === "birth_time_rectification"/);
- assert.match(handler, /const resumeTarget = sourceBoundCaseId/);
- assert.match(handler, /caseId: sourceBoundCaseId/);
- assert.match(handler, /if \(!resumeTarget\) \{[\s\S]*?type: "start"/);
- assert.match(handler, /const rectificationSession = canReuseSourceRectificationSession[\s\S]*?: resumableSession \?\? createSession/);
- assert.match(handler, /type: "resume",[\s\S]*?caseId: current\.caseId/);
+ assert.match(handler, /sourceSession\.sessionType === "birth_time_rectification"[\s\S]*?sourceSession[\s\S]*?sessions\.find/);
+ assert.match(hook, /loadRectificationV4Handoff\(\)/);
+ assert.match(hook, /loadRectificationV4\(existingHandoff\.caseId\)/);
});
test("historical completed rectification does not replace the account's unfinished case", () => {
@@ -244,7 +235,8 @@ test("completed handoffs return only after the user clicks and target the source
assert.doesNotMatch(source, /automaticRectificationContinuation/);
assert.match(source, /const returnSession = \(localHandoff/);
assert.match(source, /session\.sessionType === "consultation"/);
- assert.match(source, /onContinueOriginalQuestion=\{\(question\) => void continueRectificationOriginalQuestion\(question\)\}/);
+ assert.match(source, /onContinueOriginalQuestion=\{\(continuation\) => void continueRectificationOriginalQuestion\(continuation\)\}/);
+ assert.match(source, /claimRectificationV4Handoff\(\{[\s\S]*?caseId: continuation\.caseId,[\s\S]*?caseVersion: continuation\.caseVersion,[\s\S]*?question/);
assert.match(source, /sessionId: returnSession\.id/);
assert.match(source, /setActiveSessionId\(context\.sessionId\)/);
assert.match(source, /clearBirthTimeConsultationConsent\([\s\S]*?context\.sessionId/);
diff --git a/frontend/tests/consultation-route-service.test.ts b/frontend/tests/consultation-route-service.test.ts
index 0baa2925..211288f3 100644
--- a/frontend/tests/consultation-route-service.test.ts
+++ b/frontend/tests/consultation-route-service.test.ts
@@ -275,3 +275,51 @@ test("consult route constructs workflow input from the route service rather than
const toolInput = route.slice(route.indexOf("const toolInput = consultationInputSchema.parse"));
assert.doesNotMatch(toolInput.slice(0, toolInput.indexOf("const workflowContext")), /\.\.\.parsed\.data/);
});
+
+test("v4 continuation builds chart boundaries from the durable range without a reported minute", async () => {
+ const order: string[] = [];
+ const prepared = await prepareConsultationRoute({
+ userId: "user-v4",
+ mode: "general_no_birth_time",
+ candidateRange: { start: "05:13", end: "05:15" },
+ loadProfile: async () => {
+ order.push("profile");
+ return {
+ ...profile,
+ reported_birth_time: null,
+ active_birth_time: null,
+ birth_time_source: "period_only",
+ birth_time_status: "reported",
+ };
+ },
+ async resolveTimezoneOffset(value, selectedTime) {
+ order.push(`timezone:${selectedTime}`);
+ return value;
+ },
+ async reserve() {
+ order.push("reserve");
+ return "reserved";
+ },
+ });
+
+ assert.deepEqual(order, ["profile", "timezone:05:13", "reserve"]);
+ assert.equal(prepared.consultationMode, "unverified_birth_time");
+ assert.equal(prepared.serverChart?.toolInput.hour, 5);
+ assert.equal(prepared.serverChart?.toolInput.minute, 13);
+ assert.equal(prepared.serverChart?.truth.selectedTimeKind, "candidate_range_boundary");
+});
+
+test("v4 continuation request has an independent schema and omits client chart minutes", () => {
+ const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
+ const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
+ const schemaStart = route.indexOf("const v4ContinuationRequestSchema");
+ const schemaEnd = route.indexOf("const chartChatRequestSchema");
+ const schema = route.slice(schemaStart, schemaEnd);
+
+ assert.ok(schemaStart >= 0 && schemaEnd > schemaStart);
+ assert.match(schema, /rectificationHandoff:\s*rectificationV4HandoffSchema/);
+ assert.doesNotMatch(schema, /consultationInputSchema/);
+ assert.doesNotMatch(schema, /\bhour\b|\bminute\b|\blat\b|\blon\b|\btz\b/);
+ assert.match(page, /rectificationHandoff\?\.protocol === "rectification-evidence-v4" \? \{\} : \{/);
+ assert.match(route, /candidateRange:\s*handoffExecution\.acceptedRange/);
+});
diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts
index ef5b69f8..8629c22f 100644
--- a/frontend/tests/conversational-rectification-component.test.ts
+++ b/frontend/tests/conversational-rectification-component.test.ts
@@ -263,79 +263,48 @@ test("an active correction target remains cancellable without rendering evidence
assert.doesNotMatch(markup, /(已修订)|已记录的真实经历/);
});
-test("pending markup and responsive CSS expose accessibility contracts", () => {
- const pendingController = controller({
- pending: true,
- draft: "保留中的文字",
- getSnapshot: () => ({
- turn,
- draft: "保留中的文字",
- selectedDomain: "career",
- correctionTarget: null,
- pending: true,
- error: "",
- }),
- });
- const markup = renderToStaticMarkup(React.createElement(
- ConversationalRectificationSurface,
- surfaceProps(pendingController),
- ));
+test("v4 markup and responsive CSS expose accessibility and uncertainty contracts", () => {
const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const component = readFileSync(
+ new URL("../src/components/rectification-v4-panel.tsx", import.meta.url),
+ "utf8",
+ );
+ const wrapper = readFileSync(
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, /Jyotisha 正在核对经历/);
- assert.doesNotMatch(markup, /正在核对这段经历|Enter 发送|已发送,2\.5 秒/);
- assert.match(markup, /Jyotisha 正在分析/);
- assert.match(markup, /