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() { + ) : caseValue.status !== "abandoned" && !accepted ? ( + + ) : null} + + + {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 && } +
+
+ )} + + {caseValue.status === "paused" &&

进度已保存。继续后会从下一道问题开始。

} + {caseValue.status === "abandoned" &&

本次校正已结束,现有出生时间没有被改写。

} + {controller.error &&

{controller.error}

} + + {canAnswer && ( +
+ +