diff --git a/.env.official.example b/.env.official.example index fc017ca8..8f55e5f9 100644 --- a/.env.official.example +++ b/.env.official.example @@ -2,10 +2,12 @@ # The default fast mode intentionally keeps VEDASTRO_TIMEOUT_SECONDS low so the # local app stays responsive and falls back honestly when official evidence is blocked. +VEDASTRO_GATEWAY_MODE=official_first VEDASTRO_API_ENDPOINT=https://api.vedastro.org/api VEDASTRO_ENABLE_NETWORK=1 +# Compatibility flag only; rectification range scans follow VEDASTRO_ENABLE_NETWORK. +VEDASTRO_RANGE_SCAN_NETWORK_ENABLED=1 VEDASTRO_TIMEOUT_SECONDS=20 # Optional. Use only if your VedAstro endpoint requires a key. # VEDASTRO_API_KEY=replace_me - diff --git a/.gitignore b/.gitignore index aba631e4..8038a73c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ coverage.xml htmlcov/ .ruff_cache/ .mypy_cache/ +.cache/jyotishganit/ .hypothesis/ .cline/ .planning/ diff --git a/deploy/README.md b/deploy/README.md index 39301549..ecf923fc 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -66,6 +66,7 @@ It must return `103.117.123.53`. Caddy provisions and renews HTTPS automatically ```dotenv SITE_ADDRESS=https://jyotisha.chat JYOTISH_API_BASE=http://api:5200 +GEOAPIFY_API_KEY= NEXT_PUBLIC_SUPABASE_URL=... NEXT_PUBLIC_SUPABASE_ANON_KEY=... @@ -101,6 +102,8 @@ OPENAI_API_KEY= VEDASTRO_GATEWAY_MODE=official_first VEDASTRO_API_ENDPOINT=https://api.vedastro.org/api VEDASTRO_ENABLE_NETWORK=1 +# Compatibility flag only; rectification range scans follow VEDASTRO_ENABLE_NETWORK. +VEDASTRO_RANGE_SCAN_NETWORK_ENABLED=1 VEDASTRO_TIMEOUT_SECONDS=20 VEDASTRO_API_KEY= ``` diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index e108cada..fcb145e0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -347,6 +347,7 @@ - 修复版本:待提交 ## BUG-020 | 生时校正把语言问答包装成高阻力卡片表单 +## BUG-018 | 生时校正把语言问答包装成高阻力卡片表单 - 状态:resolved - 首次发现:2026-07-22 @@ -363,6 +364,7 @@ - 修复版本:待提交(本地可测) ## BUG-021 | 自然语言真实事件在分钟评分入口被静默丢弃 +## BUG-019 | 自然语言真实事件在分钟评分入口被静默丢弃 - 状态:resolved - 首次发现:2026-07-22 @@ -379,6 +381,11 @@ - 修复版本:待提交(本地可测) ## BUG-022 | 生时校正入口等待首轮请求完成后才切换会话 +- 相关记录:BUG-008、BUG-009、BUG-015、BUG-016、BUG-018 +- 复发自:BUG-018 +- 修复版本:待提交(本地可测) + +## BUG-020 | 生时校正入口等待首轮请求完成后才切换会话 - 状态:resolved - 首次发现:2026-07-22 @@ -395,6 +402,11 @@ - 修复版本:待提交(本地可测) ## BUG-023 | 初始化地址保存后的加载提示仍指向生时评估 +- 相关记录:BUG-016、BUG-018、BUG-019 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-021 | 初始化地址保存后的加载提示仍指向生时评估 - 状态:resolved - 首次发现:2026-07-22 @@ -411,6 +423,11 @@ - 修复版本:待提交(本地可测) ## BUG-024 | 侧栏会话标题与更多操作被拆成两块 +- 相关记录:BUG-020 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-022 | 侧栏会话标题与更多操作被拆成两块 - 状态:resolved - 首次发现:2026-07-22 @@ -427,6 +444,7 @@ - 修复版本:待提交(本地可测) ## BUG-025 | 健康压力追问被数据库误判为校正操作冲突 +## BUG-023 | 健康压力追问被数据库误判为校正操作冲突 - 状态:resolved - 首次发现:2026-07-22 @@ -475,6 +493,11 @@ - 修复版本:待提交(production gate) ## BUG-028 | 生时校正收到具体经历后仍重复泛问 +- 相关记录:BUG-007、BUG-018、BUG-019 +- 复发自:BUG-007 +- 修复版本:待提交(测试 Supabase smoke 通过) + +## BUG-024 | 生时校正收到具体经历后仍重复泛问 - 状态:resolved - 首次发现:2026-07-22 @@ -491,6 +514,7 @@ - 修复版本:待提交(本地可测) ## BUG-029 | 生时校正仍有未回答区分领域时提前结束 +## BUG-025 | 生时校正仍有未回答区分领域时提前结束 - 状态:resolved - 首次发现:2026-07-22 @@ -507,6 +531,11 @@ - 修复版本:待提交(本地可测) ## BUG-030 | 职位和管理职责变化未识别为事业证据 +- 相关记录:BUG-019、BUG-024 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-026 | 职位和管理职责变化未识别为事业证据 - 状态:resolved - 首次发现:2026-07-22 @@ -523,6 +552,11 @@ - 修复版本:待提交(本地可测) ## BUG-031 | 生时纠正未收敛仍永久扣费且事件事实未进入评分契约 +- 相关记录:BUG-024、BUG-025 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-027 | 生时纠正未收敛仍永久扣费且事件事实未进入评分契约 - 状态:resolved - 首次发现:2026-07-22 @@ -539,6 +573,11 @@ - 修复版本:待提交(本地可测) ## BUG-032 | 生时校正覆盖历史回答且完成后无法返回原问题 +- 相关记录:BUG-019、BUG-024、BUG-025 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-028 | 生时校正覆盖历史回答且完成后无法返回原问题 - 状态:resolved - 首次发现:2026-07-23 @@ -555,6 +594,11 @@ - 修复版本:待提交(本地可测) ## BUG-033 | 生时校正缺少连续事件语义导致模板式跳问 +- 相关记录:BUG-018、BUG-019、BUG-024 +- 复发自:BUG-018、BUG-024 +- 修复版本:待提交(本地可测) + +## BUG-029 | 生时校正缺少连续事件语义导致模板式跳问 - 状态:resolved - 首次发现:2026-07-23 @@ -571,6 +615,11 @@ - 修复版本:待提交(本地可测) ## BUG-034 | 生时校正 Agent 降级只记录 200 导致模板回退不可诊断 +- 相关记录:BUG-024、BUG-025、BUG-028 +- 复发自:BUG-024、BUG-028 +- 修复版本:待提交(本地可测) + +## BUG-030 | 生时校正 Agent 降级只记录 200 导致模板回退不可诊断 - 状态:resolved - 首次发现:2026-07-23 @@ -587,6 +636,10 @@ - 修复版本:待提交(本地可测) ## BUG-035 | 生时校正发送错误内容后无法撤回修改 +- 相关记录:BUG-028、BUG-029 +- 修复版本:待提交(本地可测) + +## BUG-031 | 生时校正发送错误内容后无法撤回修改 - 状态:resolved - 首次发现:2026-07-23 @@ -603,6 +656,10 @@ - 修复版本:待提交(本地可测) ## BUG-036 | Agent 化生时校正上线前被旧模板组件断言阻断 +- 相关记录:BUG-018、BUG-028 +- 修复版本:待提交(本地可测) + +## BUG-032 | 连续事件补充回答丢失状态并重新进入日期模板 - 状态:resolved - 首次发现:2026-07-23 @@ -619,6 +676,18 @@ - 修复版本:待提交 ## BUG-037 | 生时校正刷新后把真实对话重建成“已记录”模板 +- 影响面:生时校正多轮问答、事件证据账本、Agent 自然追问 +- 用户现象:用户先提供“2017年5月参加工作”,Agent 继续确认“正式工作还是实习/兼职”;用户回答“正式工作”后,系统却把这句话当成新事件,重新追问“具体是什么年月/只记得年份也可以”,既重复问题又丢失上一轮的年月。 +- 触发条件:Agent 的追问属于已有事件的日期补充或细节补充,但公开 turn 只保存问题文本,没有保存追问目标事件和问题类型。 +- 根因:编排器仅根据当前消息是否含日期决定分支;上一轮没有持久化 `event_date`、`event_detail`、`new_event` 状态和目标 `evidenceId`,导致无日期的补充回答落入固定非评分模板;后续虽让 narrative Agent 输出了目标状态,进度装饰层仍无条件重建 evidence request 并覆盖为 `new_event`;细节合并还会被事件分隔符错误拆成多个证据。 +- 修复:为 evidence request 增加向后兼容的 follow-up 状态机,记录问题类型和目标事件 ID;narrative Agent 被要求在追问已有事实时输出对应状态;进度装饰只维护确定性的下一领域,不再覆盖 Agent 可见问题对应的 follow-up 目标;服务端按状态将日期或细节 append-only 合并到目标事件并保留 `correctsEvidenceIds`,只有 `new_event` 才创建独立事件;细节合并改为单事件解析后覆盖组合摘要,避免标点导致错误拆分。 +- 验证:叙事、编排与存储聚焦测试 81/81 通过;真实两轮编排回归覆盖 Agent 先追问已有事件细节并持久化目标,然后“2017-05 参加工作”后回答“正式工作”仍保留 `2017-05`、形成“参加工作;正式工作”并不再出现日期模板;旧 turn 缺少 follow-up 时仍按兼容路径处理。目标文件 ESLint、相关 TypeScript 检查和 `git diff --check` 通过。 +- 防复发:每个中间轮必须断言追问类型、目标 evidence ID 和下一轮的事件合并结果;模型回退时默认显式 `new_event`,不得把已有事件细节当成新事实;跨轮测试必须断言用户可见回复不重复已回答的日期问题。 +- 相关记录:BUG-024、BUG-029、BUG-030 +- 复发自:BUG-029 +- 修复版本:待提交(本地可测) + +## BUG-033 | 首次进入生时校正长期停留在建立记录 - 状态:resolved - 首次发现:2026-07-23 @@ -635,6 +704,16 @@ - 修复版本:待提交 ## BUG-038 | 老案例摘要被误标成原始对话且第七条事件后无法继续 +- 影响面:首次创建生时校正 case、首条 Agent 引导消息、进入校正页面的等待时间 +- 用户现象:点击生时校正后已经进入统一加载动画,但长时间停留在“正在建立校正记录…”。 +- 根因:建档事务在写入首轮和完成扣点前同步等待 Mastra Agent 的结构化输出;模型 SDK 自带重试,叙事校验层也允许重试一次,慢请求和双重重试会把整段时间全部暴露给用户。实测本地星盘扫描约 0.08 秒,不是主要瓶颈。 +- 修复:首轮叙事的两次校验尝试共享 10 秒中止信号,并关闭 Mastra SDK 内部重试;10 秒内生成成功仍使用 Agent 自然回答,超时或两次校验失败则使用现有的安全、可评分 fallback 完成建档。中间轮和最终总结不受该首轮时限影响。 +- 验证:新增回归断言,确保首轮两次叙事尝试共享同一个 `AbortSignal`;目标 narrative 测试、ESLint、TypeScript 与生产构建通过后记录最终结果。 +- 防复发:首轮建档不得无限等待模型,也不得同时开启 SDK 重试和业务校验重试;耗时预算必须覆盖整个首轮生成,而不是每次尝试单独重新计时。 +- 相关记录:BUG-030 +- 修复版本:待提交(本地可测) + +## BUG-034 | 生时校正入口旧快照重复 start 导致 409 - 状态:resolved - 首次发现:2026-07-23 @@ -651,6 +730,18 @@ - 修复版本:0850619eaf5002736542463394720b0ab1949ce9 ## BUG-039 | 生时校正 Agent 回答等待结束后一次性出现 +- 影响面:生时校正首页入口、未完成 case 恢复、重复 start 防护 +- 用户现象:用户点击进入生时校正时,接口发送新的 `start`,返回 `409 action_conflict`,页面无法进入校正会话。 +- 触发条件:前端账户快照没有包含已有未完成 case,或账户快照在出生声明变更后隐藏了旧 case,但数据库中仍存在该用户的未完成 V3 case。 +- 根因:前端只根据内存账户状态选择 `start/resume`;数据库会阻止同一用户创建第二个未完成 V3 case,旧快照因此被拒绝。 +- 修复:首次 `start` 收到 409 后刷新账户状态;如果刷新得到可恢复 case,自动使用最新 `caseId` 和 `turnVersion` 执行 `resume`,不重复扣点;如果刷新后仍没有声明匹配的 case,则保留原错误,避免未经用户确认自动放弃旧校正记录。 +- 验证:`frontend/tests/consultation-entrypoint.test.ts` 23/23 通过,覆盖 409、刷新账户、使用最新 case 版本恢复;目标文件 ESLint 与 `git diff --check` 通过。完整套件仍有既有 CSS 断言和数据库权限测试失败,与本修复无关。 +- 防复发:生时校正入口必须把 `start` 视为可恢复的幂等操作;收到 case 冲突时先刷新 durable 状态,再决定恢复或提示用户;不得直接再次扣费、创建第二个 case,或在声明不一致时静默删除旧 case。 +- 相关记录:BUG-030、BUG-033 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-035 | 服务层重复 start 未在扣费前复用同一声明的未完成 case - 状态:resolved - 首次发现:2026-07-23 @@ -667,6 +758,18 @@ - 修复版本:本次流式修复提交 ## BUG-040 | Agent 活动状态与回答被错误渲染为互斥状态 +- 影响面:生时校正 start 编排、重复点击/多标签进入、首轮等待时间与扣费预留 +- 用户现象:前端收到新的 `start` 后仍等待模型计算,最终返回 `409 action_conflict`;账户刷新未必能返回可恢复 case,导致用户无法进入会话。 +- 触发条件:同一用户使用不同 `actionId` 重复进入生时校正,或两个入口并发发起 start。 +- 根因:编排器只按当前 `actionId` 查询 case;数据库在 reserve/create 阶段才执行“同一用户只能有一个未完成 V3 case”的约束,应用因此先做了不必要的计算和扣费预留。 +- 修复:在 reserve 前增加账户级未完成 case 查询;同一 `declaredBirthInput` 直接返回已有公开 turn,不重复计算或扣费;声明不一致继续稳定返回冲突。并发竞态在释放本次预留后重新读取账户,复用同声明的胜出 case。 +- 验证:`frontend/tests/conversational-rectification-orchestrator.test.ts` 32/32 通过,覆盖同声明重复 start 复用、声明不一致冲突和既有预留重试;后续需补充线上部署后的真实 smoke。 +- 防复发:start 幂等性必须同时在入口、编排器和 durable RPC 三层成立;任何新增 actionId 的 start 都必须先检查账户级未完成 case,不能把数据库冲突当成正常流程。 +- 相关记录:BUG-030、BUG-033、BUG-034 +- 复发自:BUG-034 +- 修复版本:待提交(本地可测) + +## BUG-036 | 旧数据库校验器把首条新事件回答误报为 409 - 状态:resolved - 首次发现:2026-07-23 @@ -683,6 +786,16 @@ - 修复版本:`4a9b1dc` ## BUG-041 | 生时校正生成回答时消息列表不自动跟随到底部 +- 影响面:生时校正首条及后续普通新事件回答 +- 用户现象:case 与请求版本同为 0,提交首条经历仍返回 `409 action_conflict`。 +- 根因:应用已为证据请求加入可选 `followUp` 状态,但当前数据库校验器仍只接受旧字段;显式的 `new_event` 与旧默认语义相同,却在保存边界被拒绝。 +- 修复:保存任何 turn 时都移除语义冗余的 `followUp: new_event`,兼容旧校验器;`event_date` 与 `event_detail` 仍等待对应数据库迁移后持久化。 +- 验证:直接 RPC 探针确认旧格式为 `true`、带 `followUp` 为 `false`;新增 store 回归测试覆盖首轮与后续 turn 的兼容投影。 +- 防复发:新增可选持久化字段时,默认语义必须能向旧数据库降级;数据库迁移未应用前不得把兼容字段直接写入 durable RPC。 +- 相关记录:BUG-032、BUG-034、BUG-035 +- 修复版本:待提交(本地可测) + +## BUG-037 | 非评分回答绕过 Agent 并显示固定澄清模板 - 状态:resolved - 首次发现:2026-07-23 @@ -729,3 +842,438 @@ - 相关记录:BUG-020、BUG-034、BUG-042 - 复发自:BUG-020 - 修复版本:待提交 +- 影响面:生时校正缺少年月、未来事件、换方向和非评分更正后的自然问答 +- 用户现象:用户用自然语言补充“化学专业”“后来换了工作”等内容后,页面直接显示“你提到……具体内容我已经记下了。它大致是什么年月?”,与前后 Agent 语气断裂,也可能忽略刚才已经建立的事件上下文。 +- 根因:编排器发现本轮暂时没有可评分事件后,直接进入同步 `nonScoringTurn()`;该函数完全没有调用 narrative Agent,而是用固定字符串生成可见回复。Mastra 和模型没有获得生成这轮回答的机会。 +- 修复:非评分分支先用当前候选技术包、完整事件账本、最新用户原话和未决证据调用现有 narrative Agent;状态机只在后台固定本轮追问属于 `event_date`、`event_detail` 还是 `new_event`,不再替代 Agent 的可见措辞。模型或技术包调用失败时仍保留确定性安全文案,避免正常澄清变成 5xx。 +- 验证:编排回归 32/32 通过;相关叙事、编排和存储测试 81/81 通过。新增断言确认无年月事件会得到 Agent 针对该事件生成的单一自然问题,并持久化 `event_date + evidenceId`,不再出现旧固定模板;未来事件、换方向和已有评分事件的行为保持兼容。 +- 防复发:任何能继续对话的业务分支都必须优先经过 narrative Agent;状态机可以决定证据目标和可评分性,但不得直接占用正常用户回复。确定性模板只能作为模型或技术计算失败时的技术兜底。 +- 相关记录:BUG-029、BUG-030、BUG-032 +- 复发自:BUG-029 +- 修复版本:待提交(本地可测) + +## BUG-038 | 全球地点字段未迁移导致账户余额接口整体 500 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`GET /api/account`、首页账户初始化、余额和生时校正入口 +- 用户现象:已登录用户访问本地首页时,账户接口返回 `500 {"error":"暂时无法读取账户余额"}`,余额与账户状态均无法加载。 +- 根因:账户 GET 为全球出生地点新增了 `birth_place_*` 与 `timezone_*` 字段查询,但当前 Supabase 尚未应用对应迁移,PostgREST 返回 `42703 column profiles.birth_place_label does not exist`。PATCH 已有旧表回退,GET 缺少同等兼容路径,并把资料字段错误误报成余额错误。 +- 修复:GET 检测缺列或 schema cache 错误后,回退到迁移前的 profile 字段集合;全球地点字段在响应中返回空值,余额、出生时间状态和未完成校正 case 继续正常读取。迁移应用后自动使用完整查询。 +- 验证:服务角色直接查询确认修复前错误为 `42703`;`frontend/tests/account-api.test.ts` 增加旧表回退回归,另运行 TypeScript、目标 ESLint 与本地登录接口 smoke。 +- 防复发:向账户初始化查询增加非关键资料字段时,应用发布必须兼容迁移前后的数据库形状;不能让可选地点元数据阻断余额与核心账户状态。 +- 相关记录:BUG-034、BUG-035 +- 修复版本:待提交(本地可测) + +## BUG-039 | 全球地点迁移漏掉服务角色列权限导致账户保存 500 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`PATCH /api/account`、初始化出生地点保存、全球地点资料更新 +- 用户现象:读取账户已经恢复,但提交出生资料返回 `500 {"error":"暂时无法核对现有出生资料"}`。 +- 根因:账户 PATCH 的并发保护和缺失 profile 恢复路径通过服务角色读取、插入及更新 `profiles`;全球地点迁移只授予了 `authenticated` 更新权限,遗漏服务角色对六个新字段的列级 `SELECT / INSERT / UPDATE`,PostgREST 返回 `42501 permission denied for table profiles`。 +- 修复:在全球地点迁移中为服务角色补齐六个新字段的最小列级读取、插入和更新权限,不恢复表级宽权限。 +- 验证:迁移权限静态回归、生产事务 dry-run、正式授权、字段权限查询和真实登录 PATCH smoke。 +- 防复发:扩展服务端账户 upsert 字段时,必须同时审计 authenticated 自助保存和 service_role 并发读取/upsert 两条权限链。 +- 相关记录:BUG-034、BUG-038 +- 修复版本:待提交(本地可测) + +## BUG-040 | 选择全球地点后重复搜索且候选列表被卡片裁切 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:初始化出生地点搜索、全球地点选择与资料保存 +- 用户现象:选择“中国 · 河北省 · 邯郸市 · 峰峰矿区”后,完整标签又触发一次搜索并返回泛化的 Geoapify 结果;候选列表还会被出生地点卡片截断,后续选项看不全。 +- 根因:位置服务在没有完整出生时刻时合法返回 `timezoneOffset: null`,但页面只把数字 offset 视为已选择地点,因此父组件继续向 combobox 传入空值,选中标签被当成新查询;同时 onboarding 卡片使用 `overflow: hidden` 裁切了绝对定位的候选列表。 +- 修复:地点完整性改为接受“合法坐标 + IANA timezoneId”,数字 offset 可暂时为空;选中地点时使在途搜索序列失效并清空候选;出生时间过渡卡片允许候选列表溢出显示。 +- 验证:地点选择、出生资料完整性和全球地点目标测试覆盖 nullable offset、缺失 timezoneId、选择竞态保护与候选列表可见性。 +- 防复发:前端地点完整性必须与位置 API 的 nullable offset 合同一致;选择类异步组件必须在 commit selection 时废弃旧请求,浮层祖先不得无意裁切。 +- 相关记录:BUG-038、BUG-039 +- 修复版本:待提交(本地可测) + +## BUG-041 | 全球地点资料已保存但初始化接口仍判定未完成 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`POST /api/onboarding`、全球出生地点用户进入首页 +- 用户现象:用户已填写称呼、出生日期、时间线索和旧金山地点,保存成功后进入首页仍返回 `出生资料尚未完成`。 +- 根因:onboarding 服务端完整性判断仍把中国 `province_code + city_code` 写死为必填,没有识别已经持久化的全球地点标签、经纬度和 IANA 时区。 +- 修复:服务端资料投影补齐全球地点字段;完整性判断接受“有效全球地点”或“旧中国行政区地点”,并复用出生资料共享校验。 +- 验证:新增旧金山 `period_only + timezoneOffset null + America/Los_Angeles` 回归用例,确认可生成首页初始问题。 +- 防复发:读取全球地点的服务端流程不得继续以中国行政区代码作为唯一地点完成条件。 +- 相关记录:BUG-038、BUG-040 +- 修复版本:待提交(本地可测) + +## BUG-042 | 全球地点缺少数字时区偏移导致生时校正入口 409 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`POST /api/birth-time-conversation`、仅填写时间段的全球出生地点用户 +- 用户现象:旧金山资料已完成并能进入首页,但点击生时校正返回 `profile_incomplete`。 +- 根因:地点搜索在没有具体出生分钟时只保存 IANA 时区,数字历史 offset 合法为空;生时校正入口起初没有在计算前补算。补算 offset 后,Geoapify 的七位小数坐标又超过校正持久化合同的六位小数边界,仍被统一映射成 `profile_incomplete`。 +- 修复:生时校正读取资料后复用现有本地时区服务,按出生日期和已申报时间或时间段参考时刻解析历史 offset;进入持久化合同前把经纬度规范为六位小数;旧 case 导入走同一路径。 +- 验证:旧金山 `1955-02-24 + evening + America/Los_Angeles` 回归确认请求历史时区服务、得到 `-8`、规范化 Geoapify 坐标后进入校正。 +- 防复发:资料保存可以暂缺 offset,但任何进入分钟计算的路径必须先通过 IANA 历史时区解析;外部地理编码坐标必须在进入耐久 JSON 合同前规范化,不能把 offset null 或坐标精度问题误报为资料缺失。 +- 相关记录:BUG-040、BUG-041 +- 修复版本:待提交(本地可测) + +## BUG-043 | 全球出生地点在兄弟接口中被误判、冲突或静默退化 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`GET /api/account`、`POST /api/consult`、`POST /api/birth-time-journey`、今日星语、合盘与遗留生时评估接口 +- 用户现象:全球出生地点已保存且可进入首页,但已有生时校正记录无法恢复并反复触发 `action_conflict 409`;正式咨询可能在扣点前返回资料不可用;部分旧功能仍要求中国行政区代码,合法全球资料会静默退回模板或 503。 +- 根因:全球地点迁移只修复了 onboarding 与 conversational rectification 主入口,多个兄弟读取路径仍各自维护旧合同:强制 `timezone_offset` 为数字、直接比较 Geoapify 七位小数坐标,或把中国省市区中心当作唯一地点真值。账户接口因此看不到数据库中实际存在的未完成 case,前端误判为需要重新创建。 +- 修复:抽取共享历史时区解析器,兼容数据库 snake_case 与浏览器 camelCase 资料,并按实际申报/确认时间调用本地 IANA 历史时区服务;账户恢复使用已存 case 的不可变 offset 作为仅用于匹配的 fallback,并把坐标统一到六位耐久精度;咨询在扣点前使用最终 active time 补算 offset;journey 入口和 case loader 在严格解析前补算;今日星语、合盘和遗留评估优先使用真实全球经纬度与时区,中国行政区仅作兼容 fallback。 +- 验证:账户 nullable offset + 七位坐标可恢复旧金山已有 case,时区 ID 或地点 ID 不一致仍拒绝恢复;verified consultation 使用最终 active time 解析 `-8` 且解析失败不调用扣点;journey、今日星语、合盘和遗留评估的全球地点回归通过。两组聚焦套件共 84/84 通过,目标文件 ESLint、TypeScript `--noEmit` 与 `git diff --check` 通过。 +- 防复发:出生地点完成性与计算就绪性必须分层;资料层可保存 IANA timezoneId 且 offset 暂空,但所有星盘计算入口必须统一补算历史 offset。不得在新接口复制中国专用地点解析,也不得用未经规范化的外部坐标直接比较持久化声明。 +- 相关记录:BUG-040、BUG-041、BUG-042 +- 修复版本:待提交(本地可测) + +## BUG-044 | 数据库出生地点声明校验落后导致创建校正记录误报 409 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`POST /api/birth-time-conversation`、Geoapify 等全球地点用户首次创建生时校正记录 +- 用户现象:账户资料完整且不存在未完成 case,点击生时纠正仍返回 `409 action_conflict`;重试同一个已释放的 action 后可能转为计费失败。 +- 根因:TypeScript 持久化声明已经支持 `placeId`、`placeType`、`provider`、`timezoneId` 和 `timezoneSource`,但数据库函数 `conversational_rectification_valid_declared_birth_input` 仍只允许旧中国地点字段。合法全球地点声明在 `create_conversational_rectification_case` 内被判无效,并被 RPC 统一映射为 `conversational_action_conflict`。 +- 修复:新增向前迁移同步数据库地点声明白名单,接受全球地点身份、IANA 时区来源及坐标字段;地点身份允许 `city`、`cityCode` 或 `placeId` 任一存在,同时保留旧中国地点兼容和数值边界校验。 +- 验证:数据库迁移单独执行成功;使用新的 actionId 对本地接口真实 smoke 返回 `200 active`,创建可恢复 case,首轮 narrative 非空;相同 actionId 重放仍返回同一 case,账户积分只从 100 扣至 99;无待交接内容时 handoff 按合同返回 `204`。相关持久化与全球地点测试 28/28 通过。 +- 防复发:任何扩展 `declaredBirthInput.birthplace` 的应用字段都必须同步更新数据库 JSON 校验函数并增加迁移文本回归断言;不得把声明校验失败笼统诊断为旧 case 冲突。 +- 相关记录:BUG-034、BUG-035、BUG-042、BUG-043 +- 修复版本:待提交(本地可测) + +## BUG-045 | 数据库追问合同落后导致第一条回答误报 409 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`POST /api/birth-time-conversation`、已创建校正 case 的第一条及后续自然语言回答 +- 用户现象:校正记录能够创建和恢复,但提交第一条人生事件后返回 `409 action_conflict`;case 的 `turnVersion` 保持不变,积分没有再次扣除。 +- 根因:应用的 evidence request 已支持带目标事件的 `followUp: { kind, evidenceId }`,但共享 Supabase 尚未执行仓库已有迁移 `20260723010000_align_conversational_follow_up_request.sql`。旧版 `conversational_rectification_valid_evidence_request` 拒绝该合法字段,保存 RPC 将校验失败统一映射成 `conversational_action_conflict`。 +- 修复:在 Supabase 项目 `vtvnfqmonbfuxmqkqdlc` 单独执行已有向前迁移,使数据库 evidence request 校验与当前 TypeScript 持久化合同一致;未执行其他迁移,也未重新部署应用。 +- 验证:对原 case 使用新 actionId 重试第一条回答返回 HTTP 200,`turnVersion` 从 0 递增到 1,narrative 和目标 `event_detail` follow-up 正常返回;用同一 actionId 重放返回相同 turn,账户积分保持 99。服务端日志两次均为 `actionKind=answer`、`resultCategory=success`、`billingState=unchanged`;持久化与迁移聚焦测试 27/27 通过。 +- 防复发:应用扩展 durable JSON 合同时,数据库迁移必须进入环境迁移账本并在发布验收中执行第一条真实写入 smoke;不能只用迁移文件存在或静态测试通过代替远端数据库合同验证。 +- 相关记录:BUG-034、BUG-035、BUG-044 +- 修复版本:数据库向前迁移已应用;应用代码待提交(本地可测) + +## BUG-046 | 生时校正刷新丢失首条引导并堆叠历史用户气泡 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正会话首次进入、连续问答及刷新后的历史恢复 +- 用户现象:首次进入时 Agent 的引导消息刷新后消失;已录入的人生事件被集中恢复成多条连续的右侧用户气泡,最后只剩一条 Agent 回复,破坏真实的一问一答顺序。 +- 根因:新会话没有把完整问答 transcript 持久化到 session;恢复接口只返回最新 turn,前端只能把最新 turn 中累计的 `evidenceRecap` 误当成逐轮聊天历史。数据库实际保存了每轮 `birth_time_rectification_turns.narrative`,用户原文也可通过 `birth_time_rectification_event_evidence.source_turn_id` 与 `raw_text` 恢复,但旧恢复链路没有读取这些数据。 +- 修复:新会话从首条 Agent narrative 开始持久化完整消息序列,每次回答后保存真实的 `Agent → 用户 → Agent` transcript;resume 接口按 turn version 读取 narrative,并用 evidence 的 source turn 关联用户原文,返回耐久的交替历史;页面优先用数据库历史修复旧 session。无法可靠恢复用户原文的残缺旧 turn 不再伪造模板回复,相邻 Agent 状态只保留最新一条;最末级 legacy fallback 也只恢复最近一组可靠消息,不再展开全部累计 evidence。 +- 验证:client、controller、route 聚焦测试 52/52 通过;component 非 Chromium 测试 8/8 通过;TypeScript `--noEmit`、目标文件 ESLint 与 `git diff --check` 通过。按用户要求未运行 Chrome/Playwright。 +- 防复发:聊天历史必须以逐轮 durable transcript 或可验证的 turn/evidence 关联为真源;累计业务证据只能用于评分与摘要,不能被前端推断成消息列表,也不能为缺失历史生成看似真实的 Agent 文案。 +- 相关记录:BUG-024、BUG-029、BUG-032、BUG-045 +- 修复版本:待提交(本地可测) + +## BUG-047 | 相对日期缺少上下文解析并触发模板式重复追问 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正连续追问、自然语言事件提取与澄清合并 +- 用户现象:Agent 已知用户在 `1972年12月正式退学`,继续追问彻底离校时间后,用户回答“来年1月份我彻底离开的学校”,系统仍重复询问具体年份和月份。 +- 根因:事件提取器把“来年、次年”等相对日期视为无日期文本,但不读取正在讨论的事件年份;澄清合并又只接受不含描述的纯日期回答;非评分分支随后用固定澄清模板覆盖模型已经生成的上下文回答。 +- 修复:对 `event_date` 和 `event_detail` 追问使用目标事件或最近明确事件作为年份锚点,将“来年、次年、第二年、翌年”和“同年、当年、那年”的月份上下文化后再提取,同时保留用户原始文本;允许带自然描述的日期补充完成原事件,但仅在纯日期、同领域或低信息细节时合并,避免把“搬家”等明确新事件吞入上一条澄清;普通澄清优先显示 narrative Agent 的自然回答,确定性模板仅保留为模型失败兜底。 +- 验证:事件提取与编排聚焦测试 59/59 通过,覆盖“2022年12月正式退学 → 来年1月彻底离校”解析为 `2023-01`、带描述日期补充、相对日期无上下文时不臆造年份,以及不同领域新事件不误合并。目标 TypeScript、ESLint 与补丁检查通过;按用户要求未运行 Chrome/Playwright。 +- 防复发:事件状态机只负责持久化目标、证据质量与评分边界,不得替代 Agent 的可见对话;相对日期必须在明确的当前事件上下文内解析,缺少可靠锚点时保持待澄清;澄清合并必须同时判断日期、摘要和领域连续性。 +- 相关记录:BUG-029、BUG-032、BUG-037 +- 修复版本:待提交(本地可测) + +## BUG-048 | 生时校正消息更新后没有自动滚动到最新内容 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正聊天区、用户发送、Agent 思考态及回答更新 +- 用户现象:用户发送经历后,Agent 开始思考并生成回答,但消息列表停留在原位置,需要手动向下滚动才能看到最新内容。 +- 根因:普通 session 在消息列表末尾设置了滚动锚点和 `scrollIntoView` effect;生时校正使用独立的 `rectification-message-list` 滚动容器,却没有对应的末尾锚点和消息更新监听。 +- 修复:在生时校正消息列表末尾增加滚动锚点,并在消息数量、最新回答文本、思考状态、提交状态、turn version 和错误状态变化时滚动到末尾;生成中即时跟随,完成后平滑滚动,同时尊重 `prefers-reduced-motion`。 +- 验证:组件目标回归测试通过;TypeScript `--noEmit`、目标 ESLint 与 `git diff --check` 通过。按用户要求未运行 Chrome/Playwright。 +- 防复发:新增独立聊天滚动容器时必须同时提供末尾锚点,并覆盖乐观用户消息、生成状态和回答文本更新三类触发源。 +- 相关记录:BUG-046 +- 修复版本:待提交(本地可测) + +## BUG-049 | 生时校正 Agent 提问悬空截断且回答缺少消息操作 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正自然语言回答、Agent 消息操作、当前轮次重跑 +- 用户现象:Agent 已经理解并复述了用户的教育经历,但回答最后停在“我需要确认一个关键信息——”,没有显示真正的问题;每条 Agent 回答下方也缺少赞、踩、复制和重跑操作。 +- 根因:Mastra 返回的结构化 JSON 可以通过校验,但其中 `narrative` 自身可能以破折号、冒号或“关键信息”等悬空引导语结束;旧逻辑只要在正文中看见“确认”等疑问词,就误判为已经包含可见问题,因此没有把同一结构化输出中的完整 `evidenceRequest.prompt` 补到正文末尾。 +- 修复:增加悬空提问结尾识别;中间轮次若正文没有完整问题或停在悬空引导语,自动追加模型结构化输出中的具体问题。Agent 回答下方增加赞、踩、复制和重跑图标;赞踩互斥并保留在当前页面,复制写入剪贴板,只有最新回答可以重跑。 +- 重跑边界:新增独立、幂等的 `regenerate` 命令,使用上一轮已经持久化的用户原文和事件台账重新生成当前 Agent narrative;不重新提取事件、不追加用户消息、不重复评分、不改变候选范围、不扣点,并在当前消息列表和刷新后的耐久历史中替换原 Agent 回答。 +- 验证:TypeScript `--noEmit` 通过;叙事、Controller、client、route、orchestrator 聚焦测试 119/119 通过,覆盖悬空提问补全、无 payload 重跑、消息原位替换、证据与候选保持不变;组件静态/SSR 检查 9 项通过;目标 ESLint 与 `git diff --check` 通过。测试过滤器未按预期排除文件内的真实 Chromium 用例,该用例误启动后以 `SIGTRAP` 退出,未作为本次 UI 验收依据,且不再重试。 +- 防复发:结构化回答校验必须区分“出现疑问相关词”与“存在完整可回答的问题”;任何重新生成操作都必须与 evidence mutation、评分和计费分离,并通过相同 action receipt 保持幂等。 +- 相关记录:BUG-029、BUG-046、BUG-047、BUG-048 +- 修复版本:待提交(本地可测) + +## BUG-050 | 生时校正重跑期间重复显示旧回答和新思考气泡 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正 Agent 回答重跑交互 +- 用户现象:点击重跑后旧回答仍保留,列表底部额外出现一个思考消息,生成完成后旧回答才被替换。 +- 根因:重跑只复用了通用 `pending` 状态;消息列表继续渲染旧回答,同时通用 pending 分支又在列表末尾追加思考气泡。 +- 修复:组件记录当前重跑消息,立即在原消息位置显示思考态并隐藏操作栏;重跑期间不渲染通用末尾思考气泡,成功后原位显示新回答,失败后恢复旧回答。 +- 验证:组件静态回归、目标 ESLint、TypeScript 与补丁检查通过;按既定限制未运行 Chrome/Playwright。 +- 防复发:原位更新类操作必须把进行中状态绑定到目标消息,不得同时复用追加新消息的通用 pending UI。 +- 相关记录:BUG-048、BUG-049 +- 修复版本:待提交(本地可测) + +## BUG-051 | 既有事件的原因补充被误判为无日期新事件 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正自然语言事件补充、事件台账合并、Agent 可见回答 +- 用户现象:Agent 已围绕一条带年月的事件追问原因,用户回答原因后,系统仍固定回复“还差时间定位”,再次索要年份和月份。 +- 触发条件:模型可见问题是在追问既有事件的原因或具体表现,但结构化 `followUp` 偶尔错标为 `new_event`;用户随后用不带日期的自然语言回答。 +- 根因:澄清合并逻辑完全信任结构化 `followUp`,遇到 `new_event` 立即退出;原因回答因此被提取成新的无日期事件,并触发确定性的日期澄清模板覆盖正常对话。 +- 修复:当上一轮可见问题明确包含原因、表现或“哪些方面”等细节追问语义时,即使 metadata 错标为 `new_event`,也把回答合并回最近一条带日期的有效事件;同时移除“这件事很有用,但还差时间定位”的固定话术,保留不假定上下文的简短兜底。 +- 验证:orchestrator 回归覆盖“1972年12月退学 → 追问压力原因但 metadata 错标 → 经济负担导致无法继续”,断言沿用原日期、合并为同一有效事件且不再出现固定索时模板;目标 ESLint、TypeScript 与补丁检查。 +- 防复发:事件追问的可见语义必须能够兜底结构化 metadata 的偶发错标;已有日期事件的原因、性质和具体表现回答不得强制再次提供日期。 +- 相关记录:BUG-029、BUG-047 +- 复发自:BUG-047 +- 修复版本:待提交(本地可测) + +## BUG-052 | 确定性流程模板覆盖生时校正 Agent 的自然回答 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正首轮引导、中间事件追问、方向切换、未来事件、暂停、放弃与确认消息 +- 用户现象:模型已经结合上下文生成回答后,网页仍显示“已记录”“当前累计”“下一步”“还差时间定位”等重复话术;部分操作还会新增程序模板气泡,使对话像问卷而不是连续的一问一答。 +- 根因:编排层把事件提取结果、`followUp` metadata 和收敛状态当成可见文案的唯一真源,在模型生成之后又根据分支拼接或替换 narrative。原本用于事实安全和失败兜底的确定性逻辑因此越界成了对话作者,并且 metadata 的偶发误判会直接中断正常语义交流。 +- 修复:可见文案统一以 Agent narrative 为准;删除首轮候选前后缀、事件保存与计数进度、领域推荐、方向切换、无日期、未来事件、暂停、放弃和确认成功等确定性模板。事件提取、未来事件不计分、去重、更正、候选计算和确认时间原子落库继续作为不可见业务状态运行,提取失败或 metadata 标错不得覆盖模型回答。增加脱敏诊断日志,仅记录阶段、校验问题代码、重试和 fallback 类别,不记录用户原文或完整模型输出。 +- 表格边界:稳定层/敏感层、技法审计和事件证据三类表可在中间轮按上下文需要出现;最终确认前必须提供完整汇总。表中候选、范围、分盘、技法和引用只能来自 technical packet,不得编造、泄露私有分数或声称运行了未执行的技法。 +- 验证:叙事与编排单元测试覆盖 Agent 原文保留、结构化 metadata 不覆盖自然回答、未来事件后台排除、原因补充合并、暂停/放弃/确认无新增模板气泡,以及最终确认仍更新正式出生时间;合成 E2E 覆盖首轮、换方向、无日期和未来事件均保留模型可见回答。目标 ESLint、TypeScript、测试与补丁检查结果见本轮验证记录。 +- 防复发:状态机只能决定允许的动作和持久化状态,不能编写用户可见话术;新增任何确定性文本前必须证明它属于安全错误而非正常对话,并增加“模型输出未被覆盖”的回归断言。 +- 相关记录:BUG-046、BUG-047、BUG-049、BUG-051 +- 修复版本:待提交(本地可测) + +## BUG-053 | 首轮结构化结果为空时错误展示确定性引导模板 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正首轮 Agent 引导、Mastra 结构化输出兼容、首轮等待时限 +- 用户现象:新建生时校正后固定显示“当前仍在核对……先说一件已经发生的学业事件”,看起来像 Agent 回答,实际没有使用模型生成的自然文案。 +- 触发条件:DeepSeek 已完成一次 Mastra 调用,但 `result.object` 未形成结构化对象;提供商返回的文本结果未被继续解析,两次尝试均被记录为 schema 失败。 +- 根因:生产叙事适配器只解析 `result.object`,没有兼容 Mastra/模型组合返回 JSON 文本的情况;首轮失败后又调用 `fallbackNarrative()`,把程序模板伪装成了 Agent 消息。脱敏 receipt 为 `fallbackUsed=true`、`schemaValidated=false`、`issues=["root:invalid_type"]`,不是超时错误。 +- 修复:优先使用通过 schema 的 `result.object`,为空时把非空 `result.text` 交回既有 JSON 提取和 packet 校验流程;首轮连续两次仍失败时返回可重试服务错误并释放预留计费,不再持久化或展示确定性首轮模板;中间轮既有安全 fallback 暂不改变。首轮等待策略的后续修复见 BUG-054。 +- 验证:叙事测试覆盖“两次不合格时拒绝而非展示模板”;路由测试覆盖 `result.object` 为空但 `result.text` 有 JSON 的兼容路径;目标叙事、编排和路由测试通过。 +- 防复发:模型适配层必须兼容结构化对象与 JSON 文本两种合法承载方式;正常首轮不得用程序模板冒充 Agent 成功回答,timeout、schema 和 grounding 失败必须在脱敏日志中分别可辨认。 +- 相关记录:BUG-046、BUG-052 +- 修复版本:待提交(本地可测) + +## BUG-054 | 首轮两次生成共用超时信号导致重试立即失败 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`POST /api/birth-time-conversation` 首轮生成与重新生成 +- 用户现象:移除首轮固定模板后,重新生成等待约 30 秒返回可重试的 `service_unavailable 503`。 +- 触发条件:首轮模型生成超过共享的 30 秒 deadline;第二次尝试继承已经取消的 `AbortSignal`,无法获得独立生成时间。 +- 根因:两次尝试在循环外创建并共用一个超时信号;诊断日志又只保留最后一次异常,把首次超时误记为 `schema_invalid`。 +- 修复:每次首轮尝试创建独立的 45 秒超时信号,累计两次尝试的脱敏问题代码,并将 `TimeoutError` / `AbortError` 明确归类为 `timeout`。 +- 验证:本地开发日志复现请求在 `30331ms` 失败;回归测试锁定两次尝试使用不同信号并将超时记录为 `timeout`,目标测试、ESLint、TypeScript 与补丁检查通过。 +- 防复发:重试不得复用已取消的信号;超时、schema 和事实校验必须在脱敏日志中保持不同类别。 +- 相关记录:BUG-016、BUG-053 +- 复发自:BUG-053 +- 修复版本:待提交(本地可测) + +## BUG-055 | 首轮完整技术输出与重复重试放大模型延迟并触发 503 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`POST /api/birth-time-conversation` 首轮 Agent 引导、Mastra 结构化输出、60 秒路由预算 +- 用户现象:本地首轮请求等待约 49–90 秒后返回可重试的 `service_unavailable 503`;同一模型的轻量探针正常。 +- 触发条件:正式首轮把候选状态、时间范围、稳定层、敏感层、引用与完整 expert workflow 全部交给模型重复输出;一次超时后又立即进行第二次完整生成。 +- 根因:模型承担了程序已经确定的 packet 字段复制工作,正式 prompt 曾达到约 8.4k 字符;提供商延迟波动时,两次 45 秒调用可超过路由声明的 60 秒预算。一次 26 秒成功结果还曾因旧引用正则误判被丢弃。 +- 修复:生产模型只生成 `narrative` 与 `evidenceRequest`,候选状态、时间、范围、分盘层、引用和领域依据由服务端从 packet 确定性补全;首轮 prompt 精简到必要边界和建议领域;第一次纯超时不再触发第二次完整调用;单次等待上限调整为 47 秒,校验失败的短重试最多 7 秒;首轮若只写了介绍但漏掉问题,追加模型自己生成的 `evidenceRequest.prompt`,不使用程序话术模板。 +- 验证:正式本地认证请求的 prompt 从 4785 字符进一步降到 1399 字符,provider 在 29066ms 返回,接口 HTTP 200 并生成自然首轮回答;定向测试 66/66、ESLint、TypeScript 与补丁检查通过。 +- 防复发:模型结构化输出不得重复由服务器掌握的确定性 packet;生成总预算必须小于路由平台预算;首轮 timeout、schema、grounding 继续使用脱敏分类日志,不记录提示词、用户资料或模型正文。 +- 相关记录:BUG-053、BUG-054 +- 复发自:BUG-054 +- 修复版本:待提交(本地可测) + +## BUG-056 | 关键词领域分类阻断自然语义并在生成失败后回退到错误领域模板 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正自然语言事件分类、中间轮 Agent 回答与候选评分输入 +- 用户现象:带明确年月的研究院实习、研究员经历被保存为 `other`;同轮模型生成失败后,页面又固定追问学业事件,看起来像 Agent 没有理解刚才的事业经历。 +- 根因:确定性关键词分类被当作最终语义判断,事业词表没有覆盖所有自然表达;中间轮叙事连续失败后仍允许 `fallbackNarrative()` 生成固定业务话术,并按技术 packet 的首个建议领域继续提问。 +- 修复:单条事件落入 `other` 时,优先调用 narrative Agent 结合最近事件上下文返回允许的语义领域;模型不可用、超时或仍返回 `other` 时才保留确定性结果。首轮和中间轮叙事连续失败统一返回可重试服务错误且不保存本轮,只有最终确认阶段保留包含安全边界与三类表的确定性 fallback。 +- 验证:新增“2020年4月去石油化工研究院实习做研究员”语义分类回归,确认保存为 `career` 并可评分;新增中间轮不合格输出回归,确认返回 `service_unavailable`、不保存事件、不推进版本且不产生模板消息。事件提取、叙事、编排和路由定向测试 134/134 通过。 +- 防复发:正则只负责低成本初筛和模型不可用时的降级,不得覆盖 Agent 对自然语言事件的语义判断;非最终轮生成失败不得用业务模板伪装成成功回答;候选时间、分盘事实、未来事件和重复计分仍由程序校验。 +- 相关记录:BUG-029、BUG-032、BUG-047、BUG-053 +- 复发自:BUG-053 +- 修复版本:待提交(本地可测) + +## BUG-057 | 中间轮重跑在 Pro 模型延迟波动时直接返回 503 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`POST /api/birth-time-conversation` 的 `regenerate` 与其他非最终叙事轮、Mastra 模型降级、validation receipt +- 用户现象:本地生时校正点击重跑后返回可重试的 `service_unavailable 503`,原回答仍保留但无法得到新的 Agent 回答。 +- 触发条件:中间轮技术 packet、事件账本和上下文组成约 5.7k 字符的提示词,首选 Pro 模型在提供商延迟波动时超过 47 秒单次等待上限。 +- 根因:BUG-055 为避免两次慢模型调用超过 60 秒路由预算,曾规定第一次纯超时直接结束;这避免了重复 Pro 调用,却也让瞬时延迟直接变成 503。受控本地复现中,同一重跑请求成功时总耗时约 45 秒,其中模型约 32 秒,证明服务和 Mastra 结构化输出可用,但原策略没有快速恢复路径。 +- 修复:将叙事预算调整为首选模型 38 秒和独立恢复模型 7 秒;第一次调用使用 `deepseek-v4-pro`,超时、schema 或事实校验失败后以新的 `AbortSignal` 调用 `deepseek-v4-flash`。生成结果携带实际模型 ID,validation receipt 记录真正成功的模型;两次均失败时继续返回 503,不恢复固定业务模板。 +- 验证:新增 Pro 超时后 Flash 成功的回归,断言两次调用使用独立 signal、第二次 attempt 标记正确、receipt 写入 Flash 模型且不使用模板;新增 regenerate 双模型均失败的事务回归,断言不推进 `turnVersion`、不覆盖上一条 Agent 消息、不新增事件、不改变候选和计费。叙事、路由与编排定向测试 110/110 通过。 +- 防复发:慢模型与快速恢复模型必须共享明确的总预算而不是各自获得完整路由时限;receipt 必须记录实际成功 provider/model;生成失败只能作为可重试技术错误,不能提交半轮状态或用业务模板伪装成功。 +- 相关记录:BUG-053、BUG-054、BUG-055、BUG-056 +- 复发自:BUG-055 +- 修复版本:待提交(本地可测) + +## BUG-058 | 私有事件路由与未执行技法清单泄露到 Agent 回答并中断追问 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正中间轮自然语言回答、事件评分路由、Technique Audit 上下文与下一问题生成 +- 用户现象:用户提供带年月的真实经历后,Agent 主动解释内部“事件归类”,展示大量 `not_evaluated`、`blocked` 技法和确认权限,却没有继续提出下一个问题;回答像调试报告而不是连续的一问一答。 +- 根因:叙事上下文直接暴露事件的私有 `domain`、建议领域和完整 expert workflow 状态,模型因此围绕工程元数据组织回答;同时问题修复器只处理首轮或明显截断的句子,语法完整但没有问号的中间回答会原样通过。技法保持 `not_evaluated` 的直接原因则是当前有效、受支持且可评分的事件不足三条,路由尚未进入 `scoreEvents()`,并非 Mastra 无法调用技法。 +- 修复:新增叙事专用上下文,移除事件 `domain`、建议领域和内部评分;证据采集阶段只允许暴露已实际 `used` 或 `partial` 的技法,不再展示未调用清单、阻塞项或确认权限。私有语义领域仍保留在服务端用于事件去重、跨领域路由和候选评分,但不得进入用户可见文案。所有非最终回答必须包含且只引导一个下一问题;模型正文漏问时追加同一次模型生成的 `evidenceRequest.prompt`,不使用固定业务模板。 +- 验证:新增回归覆盖事件正文可见但内部领域不可见、建议领域不进入叙事 packet、未调用技法不在采集轮展示,以及完整陈述漏问时补入模型自写问题;叙事、路由和编排聚焦测试通过,目标 ESLint、TypeScript `--noEmit` 与 `git diff --check` 通过。 +- 防复发:私有语义路由与用户叙事必须使用不同数据视图;未执行的技术状态不得被包装成已完成分析;每个非最终 Agent 回答都必须断言存在一个明确的下一问题,同时最终轮仍须使用真实技术 packet 生成可审计的三类表。 +- 相关记录:BUG-052、BUG-053、BUG-055、BUG-056、BUG-057 +- 修复版本:待提交(本地可测) + +## BUG-059 | Flash 恢复窗口过短导致模型波动时偶发 503 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:`POST /api/birth-time-conversation` 非最终叙事轮、Pro 超时后的 Flash 恢复请求 +- 用户现象:同一条合法回答偶发返回可重试 `service_unavailable 503`,随后原 action 幂等重试又能正常生成回答。 +- 根因:首选 Pro 模型等待上限为 38 秒,但恢复模型只有 7 秒;提供商延迟稍高时,两次尝试都会在接口 60 秒总预算之前被主动中止。原请求幂等重试约 43 秒成功并正常推进一轮,排除了数据库、case 版本和技术 packet 故障。 +- 修复:保留 38 秒 Pro 窗口,将独立 Flash 恢复窗口从 7 秒放宽到 14 秒;最坏模型等待仍为 52 秒,为鉴权、技术计算和持久化保留约 8 秒,不引入第三次请求或固定模板回退。 +- 验证:使用原 action 幂等重试返回成功并生成自然下一问;叙事、路由和编排聚焦测试、目标 ESLint、TypeScript `--noEmit` 与 `git diff --check` 通过。 +- 防复发:两次模型尝试的总预算必须显式小于路由 `maxDuration`;恢复模型窗口不能短于其实际常见尾延迟;非最终轮仍不得用确定性业务模板伪装生成成功。 +- 相关记录:BUG-055、BUG-057、BUG-058 +- 复发自:BUG-057 +- 修复版本:待提交(本地可测) + +## BUG-060 | 生时校正模型选择器显示 GPT-5.5 但请求实际使用默认 DeepSeek + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正首次引导、用户回答和重跑的叙事模型选择,以及失败操作的幂等身份 +- 用户现象:生时校正输入框下方明确选择 GPT-5.5,但接口请求没有携带模型标识;Agent 回答的延迟和实际调用模型与界面选择不一致。 +- 根因:普通会话会把当前会话的 `modelId` 发送给 `/api/consult`,生时校正控制器和首页首次 `start` 请求却没有把同一字段写入命令;服务端叙事生成器因此只能使用 `RECTIFICATION_NARRATIVE_MODEL_ID`,未配置时默认选择 DeepSeek。模型选择器此前只改变了会话 UI 状态,没有贯通生时校正 API。 +- 修复:为会生成 Agent 文本的 `start`、`answer`、`regenerate` 命令加入可选 `modelId`;首页首次启动使用新建或恢复的生时校正会话模型,控制器每次发送时读取当前选择。路由从既有服务端模型目录解析所选模型,合法的 GPT-5.5 优先于配置默认模型,原恢复模型仍作为第二次尝试;过期或不可用模型以安全的 `model_unavailable 409` 返回,且不扣点。操作身份同时纳入模型 ID,避免失败后切换模型仍复用旧 action fingerprint。 +- 验证:增加请求序列化、首次启动、回答、切换模型后重跑、路由模型解析、不可用模型拒绝和服务分发回归;receipt 继续记录实际成功的 provider/model,而不是只记录界面选择。 +- 防复发:任何带模型选择器的会话入口都必须证明 UI 会话模型、发出的命令和服务端解析结果一致;新增生成命令时必须同时更新 schema、幂等身份、客户端和路由测试。 +- 相关记录:BUG-055、BUG-057、BUG-059 +- 修复版本:待提交(本地可测) + +## BUG-061 | 同版本恢复响应被丢弃导致本轮双方消息同时消失 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正回答成功后的消息同步、冲突恢复与持久化对话历史 +- 用户现象:接口已经成功返回新的自然语言回答和完整 `conversationMessages`,但页面没有显示本轮 Agent 回答,本轮用户气泡也在请求结束后消失。 +- 触发条件:请求在途时,父级状态先同步到与成功响应相同的 `turnVersion`;临时用户气泡随旧版本结束而移除,随后控制器的同版本保护又直接丢弃成功响应。 +- 根因:控制器把“同版本业务 turn 不应被晚响应覆盖”扩大成了“同版本响应的全部内容均不可采用”,没有区分业务状态与更完整的持久化消息 transcript。 +- 修复:更高版本响应仍优先、较旧响应仍丢弃;同版本响应只有在携带更完整的持久化历史,且其最后一条 Agent 文案与当前轮一致时,才只补齐消息列表并保留当前 turn。新版本响应也优先采用服务端持久化历史,避免本地推断覆盖耐久真源。 +- 验证:新增在途回答期间父级先同步到版本 8、随后恢复响应携带完整 `Agent → 用户 → Agent` 历史的回归;确认本轮用户与 Agent 气泡均恢复,同时原有“外部同版本 turn 不被晚响应覆盖”测试继续通过。controller、client、route 聚焦测试 61/61 通过。 +- 防复发:同版本保护必须分别比较业务 turn 与消息 transcript;完整且与当前 narrative 一致的耐久历史可以修复 UI,但不得用晚响应覆盖同版本外部业务状态。 +- 相关记录:BUG-046、BUG-048 +- 复发自:BUG-046 +- 修复版本:待提交(本地可测) + +## BUG-062 | 两位年份与模型领域标签导致合法经历被降级为未完成分析 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正自然语言事件提取、非最终轮叙事校验与恢复模型调用 +- 用户现象:用户用“21年”“23年”描述一段连续经历后,系统没有正常理解时间线,而是显示“这次分析暂时没有完成”的固定失败提示。 +- 根因:事件提取器只识别四位年份,两位年份会被视为缺少日期;同时,首选模型虽然成功生成了完整回答,但旧版结构化输出中的下一问题领域标签不在技术 packet 当前建议集合内,整段回答因此被判为 `ungrounded_evidence_domain`。恢复模型随后超时,编排层最终保存了确定性失败提示。 +- 修复:两位中文年份根据当前日期展开为最近且不晚于当前年的四位年份;旧版完整模型输出继续校验候选时间、范围、分盘和引用等事实字段,但下一问题的内部领域只保留与 packet 建议集合的交集,交集为空时才使用 packet 建议,不再因无害的领域措辞差异丢弃自然回答。 +- 验证:新增“21年元旦回家备考、21年底封控、在家到23年”的提取回归,以及旧版模型选择非建议领域的叙事回归;事件提取、叙事和编排聚焦测试共 109 项通过。 +- 防复发:自然语言年份解析必须覆盖常见缩写表达;服务端安全校验只约束可核验事实,不应让模型的对话选题标签覆盖或中断已通过事实校验的回答。 +- 相关记录:BUG-052、BUG-057、BUG-059 +- 修复版本:待提交(本地可测) + +## BUG-063 | 精确日期追问收到月日后重复确认同一节点 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正事件日期追问、上下文日期补全与 append-only 事件修订 +- 用户现象:Agent 已询问 2026 年 7 月上旬决定开公司的具体日期,用户回答“7 月 10 号”后,Agent 又询问是否指 2026 年 7 月 10 日及其对应节点。 +- 根因:日期提取器只直接识别带年份的中文日期;编排器只会补全“来年/同年”等相对年月,并且补全逻辑仅支持空日期,不支持把已有 `2026-07` 提升为 `2026-07-10`。简短回答因此被保存成新的未定日期事件,而不是目标事件的精度修订。 +- 修复:仅在明确指向既有事件的 `event_date` 追问中,从目标事件继承年份或年月;允许兼容的年到月、月到日精度提升,并继续以 `correctsEvidenceIds` 保存修订链。普通新事件不猜测缺失年份。 +- 验证:新增 `2026-07 · 决定开公司 → 7 月 10 号` 回归,断言生成 `2026-07-10` 修订、保留原事件、沿用原摘要且不再次询问日期归属。 +- 防复发:无年份月日只能在定向日期追问且存在可靠目标日期时补全;日期精度提升必须与目标已有年份或月份前缀一致。 +- 相关记录:BUG-024、BUG-047、BUG-062 +- 修复版本:待提交(本地可测) + +## BUG-064 | 窄候选被本地稳定性前置门控阻断导致长对话无法进入 VedAstro 验证 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正事件评分、三引擎校验、VedAstro 外部验证与最终确认阶段 +- 用户现象:用户跨教育、事业、搬迁、关系和财务等领域补充大量带时间事件后,候选范围已经缩窄,但系统仍持续追问,始终不给出可确认的纠正时间。 +- 触发条件:本地评分已经产生唯一领先且不超过 15 分钟的候选范围,但领先幅度、正负 1/2/5 分钟邻域稳定或 leave-one-event-out 诊断未全部通过。 +- 根因:外部验证入口错误依赖本地 `can_apply=true`;而本地 `can_apply` 又把邻域稳定和 leave-one-event-out 当作硬门槛。最终确认合同同时要求 VedAstro 通过,形成“本地未完全稳定则不调用 VedAstro、未调用 VedAstro则永远不能确认”的循环门控。 +- 修复:新增独立的外部验证就绪判定;当至少 3 条事件、2 个领域、必需分层完整、候选唯一领先且范围不超过 15 分钟时,即进入三引擎和 VedAstro 事件区分验证。邻域稳定与 leave-one-event-out 继续保留在审计 gate 和置信度诊断中,但不再进入 `hard_blockers`;最终确认仍要求本地窄候选、必需分层、三引擎一致、VedAstro 官方响应与事件区分全部通过,并继续等待用户明确确认后才写入出生时间。 +- 验证:将一组 12 条、覆盖 5 个领域的长对话事件固化为回归;修复前本地得到 `05:07–05:08` 且 VedAstro 调用次数为 0,测试失败;修复后同一 fixture 进入外部验证,模拟官方事件扫描严格区分首选与次选后返回 `confirm_minute`。活动事件评分、API 与技法合同相关测试 42/42 通过;前端相关 95 项中 94 项通过,唯一失败为既有“最多十条事件”兼容测试,与当前已取消最大事件条数的产品规则冲突,不属于本次改动。 +- 防复发:测试必须同时覆盖“本地低置信但已形成窄候选”和“VedAstro 未执行或未区分时仍禁止确认”;研究稳定性诊断不得再次被用作外部验证的前置开关。 +- 相关记录:BUG-051、BUG-055、BUG-058 +- 修复版本:待提交(本地可测) + +## BUG-065 | 真实 VedAstro 事件扫描请求乘法导致生时校正数分钟无响应 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:生时校正进入外部候选区分后的同步响应时间、VedAstro `SearchEvents` 调用量 +- 用户现象:本地候选已经缩窄并开始真实 VedAstro 验证后,请求运行超过五分钟仍未完成,网页表现为长时间等待或最终服务不可用。 +- 触发条件:高严谨生时校正包含多条月级或年级事件,并且至少有两个候选分钟需要外部比较。 +- 根因:每个候选都会扫描全部可映射事件,而 VedAstro adapter 又把月级事件拆成约十二个采样日;十二条事件、两个候选时最坏接近 288 次同步 HTTP 请求。mock 每次立即返回,因此原回归只证明进入了 VedAstro,没有暴露真实网络延迟。 +- 修复:生时校正专用调用层按 VedAstro 原生 `career`、`wealth`、`marriage` 领域各选择一条最强事件;直接映射优先于代理映射,日级优先于月级和年级,同精度选择较新事件。月级和年级事件只取范围中点作为代表日,两个候选最多发起六次外部事件扫描;响应明确记录符合条件事件数、实际选中数和选择策略,不再暗示全部事件均已外部验证。通用 range scan 保持不变。 +- 验证:长对话回归断言十二条符合映射的事件只产生六次扫描,两个候选各覆盖事业、财富、关系三领域,所有扫描均为单日;相关 Python 测试 42/42 通过。使用真实 VedAstro key 重跑同一十二事件 fixture,首次完整命令约 9.7 秒、缓存后约 3.6 秒,官方响应状态为 `official_verified`。真实 `SearchEvents` 对 `05:07` 与第二候选 `05:21` 返回相同的三领域命中与 36 分信号,因此正确保持 `vedastro_candidate_not_discriminated`,没有把无法区分的外部结果伪装成分钟确认。 +- 防复发:任何真实外部服务回归都必须同时锁定请求数量与代表事件选择,不得只用零延迟 mock 证明“已调用”;新增事件或采样策略时必须评估候选数 × 事件数 × 日期采样数的乘法上限。 +- 相关记录:BUG-055、BUG-064 +- 复发自:无 +- 修复版本:待提交(本地可测) + +## BUG-066 | SearchEvents 被误作最终分钟裁判且未比较官方分钟敏感层 + +- 状态:resolved +- 首次发现:2026-07-24 +- 最近更新:2026-07-24 +- 影响面:高严谨生时校正候选比较、VedAstro 官方验证与最终分钟确认 +- 用户现象:本地事件评分已经得到首选与次选分钟后,系统仍依赖 VedAstro `SearchEvents` 的事件命中差异决定能否确认;两个候选即使 Ascendant、宫位、D9、D10 或 Dasha 技术状态不同,只要事件搜索指标相同就无法继续收敛。 +- 触发条件:进入 VedAstro 外部验证的两个窄候选在 `SearchEvents` 中返回相同事件数量和 signal lift,或反过来事件搜索指标不同但官方分钟敏感快照相同。 +- 根因:旧外部验证合同把事件搜索指标当成候选分钟的最终判别器,却没有从 VedAstro 官方完整快照提取并比较 Ascendant/宫位边界、D9、D10 和 Dasha 边界身份。`SearchEvents` 适合补充事件背景,不足以独立证明某个出生分钟正确。 +- 修复:为两个排名最高的候选生成不含原始响应的 VedAstro 官方分钟快照,分别对 Ascendant 与宫位、D9、D10、Dasha 边界生成安全 fingerprint;至少一个官方分钟敏感层存在差异时才通过候选身份区分 gate。候选排序继续以本地已发生事件评分为准;`SearchEvents` 改为 `event_background_validation`,明确 `used_for_decision=false`。KP cusp/sub-lord 因当前已验证官方接口没有可审计结果而保持 unsupported,不以 `IsPlanetInHouseKP` 冒充。 +- 验证:官方快照适配器测试锁定四个分钟敏感层均生成非空 fingerprint、KP 明确 unsupported,且响应不泄露 API key 或 raw response;活动事件集成测试锁定 SearchEvents 指标完全相同时仍可由分钟敏感层差异完成区分,并锁定 SearchEvents 即使明显偏向首选、分钟快照完全相同时也必须保持 `can_apply=false` 和 `vedastro_minute_sensitive_layers_not_discriminated`。相关聚焦测试通过。 +- 防复发:任何最终分钟确认都必须区分“本地事件排序”“VedAstro 官方分钟技术身份验证”和“SearchEvents 背景验证”三种职责;两个官方快照有差异只证明候选技术状态不同,不得描述为 VedAstro 已独立证明本地首选分钟正确。 +- 相关记录:BUG-055、BUG-064、BUG-065 +- 复发自:无 +- 修复版本:待提交(本地可测) diff --git a/docs/testing/birth-time-rectification-known-answer-web-e2e.md b/docs/testing/birth-time-rectification-known-answer-web-e2e.md new file mode 100644 index 00000000..0117df46 --- /dev/null +++ b/docs/testing/birth-time-rectification-known-answer-web-e2e.md @@ -0,0 +1,258 @@ +# 生时纠正网页端 AA 级已知答案盲测集 + +目的:用仓库已登记的公开 AA 级案例完整手测网页端自然语言校时,验证 Agent 能持续跨领域追问、调用 VedAstro 比较候选、输出审计表,并在用户显式确认后写回出生时间。 + +本文件不含真实用户隐私。测试人物及事件均来自公开名人资料;操作者仅以第一人称复述公开履历。此测试既验产品流程,也验已知答案回收能力,但单个案例通过不能证明普遍分钟级准确率。 + +## 角色分工与盲测规则 + +- **操作者**:只看“公开给操作者”部分,逐轮输入,不查看验收密封区。 +- **验收者**:记录页面、接口和脱敏后端证据;流程结束后才打开密封区核对正确分钟。 +- **Agent / 开发者**:不得把正确分钟、案例名或 `case_id` 注入提示词、候选排序或 VedAstro 请求。 +- 每轮只问一个问题、只答一个问题。允许 Agent 改变措辞,但不允许跳过语义承接。 +- 不设置最大事件数、最大轮数、连续不收敛停止或领域耗尽停止;未满足外部判别条件时继续问最有区分度的问题。 + +## 测试前提 + +- 本地前端:`http://localhost:3000` +- 本地占星后端:`http://127.0.0.1:5200` +- 使用专用测试账号;开始前清空其生时纠正会话与出生时间写回状态,保留点数、充值和消费历史。 +- `VEDASTRO_ENABLE_NETWORK=1`,VedAstro 凭据只存在本地/部署环境,不写入截图、日志或本文。 +- 浏览器 Network 和后端日志可用;所有证据必须脱敏,不记录 Cookie、JWT、邮箱、用户 ID、case ID 或完整模型提示词。 +- 初始出生时间状态必须是“不确定”,不能预先保存密封答案。 + +## 公开给操作者:出生输入 + +| 字段 | 输入 | +|---|---| +| 出生日期 | `1955-02-24` | +| 出生地点 | `San Francisco, California, United States` | +| 纬度 / 经度 | `37.7749 / -122.4194` | +| 时区 | `UTC-08:00`,当地当日无夏令时 | +| 家庭记忆 | `大约晚上七点左右,可能前后差四十五分钟。` | +| 初始候选范围 | `18:30–20:00` | + +不要在用户可见输入中出现人物姓名或已知正确分钟。 + +## 场景 0:入口、首条消息与幂等 + +1. 从首页点击“生时纠正”。 +2. 在加载期间再次点击一次入口,随后刷新页面一次。 +3. 等待 Agent 首条引导消息,不先发送人生事件。 + +通过条件: + +- 点击后立即出现同一套连续加载反馈;不能先后出现彼此割裂的“正在建立记录”“正在准备问题”两套遮罩。 +- 只创建或恢复一个有效校时 case;重复点击、刷新和 handoff 重放不得创建第二个 case。 +- 首条 Agent 消息自动出现,说明会逐步询问已经发生的事件,并且只提出一个开场问题。 +- 首条消息以流式方式进入同一气泡;活动状态可与答案同时显示,完成后由进行态切换为完成态。 +- 消息列表跟随新增内容滚动到底部;右侧滚动条默认隐藏,滚动时出现。 +- 不显示候选卡片、历史录入卡片或“当前候选 xx:xx · 待验证”等额外操作区。 + +## 场景 1:教育事件、自然承接与模糊日期 + +按 Agent 的问题自然发送以下答案;不要一次粘贴多条。 + +| 轮次 | 操作者回答 | 预期 Agent 行为 | +|---:|---|---| +| 1 | `1972年秋天我进入里德学院读书。` | 承接“进入大学”,若需要时间精度,只问秋天大致是几月;不能只回复“已记录”。 | +| 2 | `大概是9月,但我只确定是秋季。` | 保留“1972-09,月级近似”而不是虚构具体日;继续追问这段教育经历发生了什么变化。 | +| 3 | `读了一个学期以后我退学了,但之后还在学校旁听了一段时间。` | 把退学和上一条教育经历关联;因缺时间可只追问退学年月,不得重新问入学年月。 | +| 4 | `正式退学大约是1972年12月。` | 合并为同一教育轨迹,识别 D24 教育层;下一问转向尚未覆盖的高区分领域。 | + +检查:Agent 的每次回应都应先用自然语言说明这条事实为何有用或如何修正了当前理解,再问一个问题;不得连续输出“当前累计 / 下一步 / 本轮区分重点”模板。 + +## 场景 2:迁移、旅行与拒绝伪精确 + +| 轮次 | 操作者回答 | 预期 Agent 行为 | +|---:|---|---| +| 5 | `1974年我离开美国去了印度,待了几个月,对之后的生活方式影响很大。` | 识别迁移/远行与价值观变化;若月份缺失,只问时间精度。 | +| 6 | `我只记得是1974年,月份不确定,不要替我补。` | 保存年级精度并停止追问不存在的月份;不得反复要求年月。 | +| 7 | `回国后我先在加州工作,后来开始做自己的电脑项目。` | 承接回国和职业转折,但因两个事实混在一句,可选其中最有区分度的一件只问一个问题。 | + +## 场景 3:事业、财务与同一事件补日期 + +| 轮次 | 操作者回答 | 预期 Agent 行为 | +|---:|---|---| +| 8 | `1976年4月我和伙伴正式创办了第一家公司。` | 识别创业事件并询问职责/转折等一个细节;不得再问年份月份。 | +| 9 | `我主要负责产品方向、设计判断和把技术变成普通人能用的东西。` | 作为 1976-04 创业事件的详情保存,不得新增“日期待补充”的独立事件。 | +| 10 | `后来公司上市,我的资产在很短时间里发生了很大变化。` | 识别财务事件并只追问上市时间。 | +| 11 | `是1980年12月12日。` | 合并回上市/资产变化事件,进入 D2/D11 财务层;不得保存成无主题日期。 | +| 12 | `1985年10月我离开了自己创办的公司。` | 记录为事业重大中断,并追问离开性质或之后去向中的一个。 | +| 13 | `更正一下,不是10月,是1985年9月;是权力冲突后离开管理层。` | 建立修订链:旧月份退出有效评分,新月份参与评分,不重复计分。 | +| 14 | `之后我创办了NeXT,1996年12月因为收购又回到了原来的公司体系。` | 分开识别新创业与回归,但只追问缺失且最有价值的一项;1996-12 不得再次询问。 | + +## 场景 4:家庭、关系、健康与事故否定证据 + +| 轮次 | 操作者回答 | 预期 Agent 行为 | +|---:|---|---| +| 15 | `1978年5月我的第一个孩子出生。` | 识别家庭事件;若日期已足够,不追问具体日。 | +| 16 | `1991年3月18日我结婚。` | 识别关系事件并进入 D9 + UL/A7 验证;不再问年月。 | +| 17 | `2003年我被诊断出严重的胰腺疾病,最初没有立刻手术。` | 识别健康压力;允许年级精度,不把“未立刻手术”拆成第二条计分事件。 | +| 18 | `2004年7月底我接受了相关手术。` | 与 2003 诊断关联,但作为独立治疗节点评分;保持“月底”精度。 | +| 19 | `2009年4月我接受了肝移植。` | 识别第二个独立健康节点;不得又回到教育或事业模板。 | +| 20 | `没有经历过需要住院的重大车祸或骨折。` | 保存为事故领域的否定证据,不虚构事件、不强迫用户提供一个事故。 | + +## 场景 5:停止生成与草稿恢复 + +1. 在 Agent 下一问出现后输入:`1985年10月我离开公司。` +2. 发送后立刻点击“停止”。 +3. 检查该次请求未写入事件、未扣除本轮生成次数,文本完整恢复到输入框草稿。 +4. 把草稿改为:`1985年9月我离开公司;这是一条界面撤回测试,不要重复计分。` +5. 再次发送。 + +通过条件:恢复的是用户原文,不是 Agent 改写;草稿可编辑;历史消息不丢失;最终只保留一次有效事实。普通 session 也应使用同一停止/草稿恢复行为。 + +## 场景 6:流式、历史、滚动和单问单答 + +从第 1 轮持续检查: + +- 用户气泡按发送顺序位于右侧,Agent 气泡位于左侧;旧答案永不被新答案覆盖。 +- Agent 文本按流式增量显示;不能等完整 JSON 返回后一次性铺开。 +- 活动状态与正在生成的答案同时可见,不是互斥 `if`;完成后保留完成态图标。 +- 用户停留在底部时自动跟随;用户主动上滚查看历史时不要强制抢回底部,出现“回到最新”入口即可。 +- 一轮只出现一个明确问题。若用户已给日期,问题应询问性质、影响或下一个领域,不能重复问“几年几月”。 +- 不使用固定确认模板替代模型回答;技术状态可以结构化保存,但用户可见回复必须承接具体事实。 + +## 场景 7:VedAstro winner / runner-up 强制判别 + +当本地候选排序已形成第一名和第二名时,后端必须执行 VedAstro 外部验证。验收者只查看脱敏结构化证据: + +1. 同一组出生地点、时区、Ayanamsa、Node mode 和历史事件分别扫描 winner 与 runner-up。 +2. winner 与 runner-up 都必须有官方 VedAstro 原始响应的哈希/receipt;HTTP `200` 或 `available: true` 本身不算判别成功。 +3. 至少一个有日期的已发生事件被 VedAstro 支持;未来窗口不得计分。 +4. winner 必须同时严格领先 runner-up: + - `matched_event_count` + - `signal_lift` + - `event_hit_count` +5. 对 winner 执行 `±1 / ±2 / ±5` 分钟稳定性探针,并把变化写入候选差异表。 +6. 以下任一情况都不得进入确认:VedAstro 不可用、调用失败、无 runner-up、无支持事件、三项指标任一打平/落后、输入口径不一致、响应无法语义归一化。 + +若未通过,Agent 应自然说明“还不能区分这两个候选”,继续提出一个最能区分候选的问题;不能抛出 409/500,也不能用本地分数冒充 VedAstro 结论。 + +## 场景 8:最终回答的三类表 + +达到可确认条件时,最终消息必须先用自然语言说明为何收敛,再显示以下三张 Markdown 表。允许增列,不得缺表或以卡片替代。 + +### 1. Technique Audit Table + +至少包含: + +| 技法/边界 | 最低验收内容 | +|---|---| +| Birth-time uncertainty | 初始范围、最终候选、精度边界 | +| D1 / Lagna | winner 与 runner-up 的稳定/差异 | +| Vimshottari Dasha | 已执行及关键事件命中状态 | +| Narayana Dasha | 已执行及与 Vimshottari 的一致/冲突 | +| D24 | 教育事件判别 | +| D10 + A10 | 事业事件判别;A10 不可静默省略 | +| D2 / D11 | 财务事件判别 | +| D9 + UL / A7 | 关系事件判别 | +| D30 | 健康/事故与否定证据 | +| Functional Benefic/Malefic | used / blocked 及对置信度的影响 | +| VedAstro official | winner/runner-up 双扫描、receipt、判别状态 | +| PyJHora / jyotishganit | used / partial / blocked,不能伪称完成 | +| MEVG / Global Web Evidence | used / blocked | +| Real Case Calibration | 本例为公开 AA 已知答案盲测;揭盲前不得引用答案 | + +### 2. 事件验证表 + +每行至少包含:事件、日期与精度、领域、是否参与评分、支持的候选、Dasha/分盘依据、VedAstro 命中、冲突/备注。更正前的 `1985-10` 必须标记 superseded 且不计分;“无重大事故”必须标为否定证据。 + +### 3. 候选时间差异表 + +至少列出 winner 和 runner-up,字段包括:候选时间、本地排名、关键分盘差异、Vimshottari、Narayana、VedAstro `matched_event_count`、`signal_lift`、`event_hit_count`、`±1/2/5` 稳定性、主要支持、主要冲突。不得只展示 winner。 + +## 场景 9:显式确认与写回 + +Agent 在三表之后必须询问是否将 winner 写入出生资料,不能自动写回。操作者先回答: + +`先不要更新,请再告诉我第二名为什么输。` + +检查:Agent 解释 runner-up 的具体落后证据,不写库、不丢失三表。随后验收者揭盲;只有 winner 与密封答案一致且 VedAstro 强制判别全部通过,操作者才发送: + +`我确认采用这个结果,请把出生时间更新为你刚才给出的第一候选。` + +通过条件: + +- 确认前出生资料不变;确认后仅写入已经展示的 winner。 +- 刷新首页、普通 session 和再次进入生时纠正后均读取同一最终时间。 +- 重放相同确认 action 不产生第二次写入、重复扣费或 409。 +- 写回审计保留旧值、新值、确认消息关联和版本,但用户界面不暴露内部 ID。 + +## 场景 10:409 / 并发回归 + +在独立重置后的第二轮执行: + +1. 打开两个同账号标签页,同时进入生时纠正。 +2. 标签 A 先提交一条事件;标签 B 用旧页面提交另一条事件。 +3. 快速双击一次发送按钮,并重放一次相同 handoff/confirmation 请求。 + +通过条件: + +- 服务端对相同 `action_id` 幂等;同一用户动作只保存一次。 +- stale version 自动加载最新进度并安全重试,或把 B 的草稿原样恢复供用户确认;不能把“校正操作发生冲突,请加载最新进度后再试”作为终态。 +- 冲突恢复后两标签都能继续;不返回首页、不丢历史、不创建重复 case。 +- `/api/birth-time-conversation/handoff` 必须有明确响应;不能无限 pending 或空响应。 +- 技术冲突不应被 Agent 当作人生事件,也不应改变候选排名。 + +## 总通过标准 + +以下任一项失败即整套测试失败: + +1. 初始引导消息缺失,或入口等待期间无连续反馈。 +2. 已给出的日期被重复询问,细节被误存成新事件,或修订前事实仍计分。 +3. 新消息覆盖旧消息、非流式输出、底部不跟随,或活动状态与答案互相排斥。 +4. 停止后草稿未恢复,已停止请求仍写入/计费。 +5. Agent 因事件数、轮数、平台期或领域耗尽自行停止追问。 +6. 缺少 winner/runner-up 双扫描,或 VedAstro 未严格区分三项指标便允许确认。 +7. 缺少任一张最终表,或把内部审计信息只塞进不可见日志。 +8. 未显式确认就写回,或显式确认后最终时间没有跨页面一致更新。 +9. 入口、发送、handoff、确认重放导致重复 case、重复事件、重复扣费、不可恢复 409 或空响应。 +10. 揭盲后第一候选不等于密封正确分钟。 + +## 验收记录模板 + +| 项目 | 结果 | 证据(截图/脱敏日志/请求序号) | 备注 | +|---|---|---|---| +| 入口与首条消息 | pass/fail | | | +| 自然语言承接 | pass/fail | | | +| 模糊日期与更正 | pass/fail | | | +| 跨领域持续追问 | pass/fail | | | +| 停止与草稿恢复 | pass/fail | | | +| 历史/流式/活动/滚动 | pass/fail | | | +| 入口/发送/确认幂等 | pass/fail | | | +| 409 自动恢复 | pass/fail | | | +| VedAstro 双候选判别 | pass/fail | | | +| 三类最终表 | pass/fail | | | +| 显式确认写回 | pass/fail | | | +| 已知答案回收 | pass/fail | | | + +--- + +
+仅验收者:已知答案密封区(完成候选排序后再展开) + +### 正确答案 + +- 公开案例:Steve Jobs(仅用于 AA 级已知答案回收,不得注入 Agent 上下文) +- 正确出生时间:`1955-02-24 19:15:00` +- 地点:San Francisco, California, United States +- 坐标:`37.7749, -122.4194` +- 时区:`UTC-08:00` +- 数据等级:仓库公开案例注册表标记为 `public_astro_databank_aa` + +### 揭盲判定 + +- 第一候选必须是 `19:15`;输出范围包含 `19:15` 但第一候选不是 `19:15`,不算已知分钟回收通过。 +- runner-up 不预先固定,由本轮真实排序产生,防止测试为答案定制。 +- 如果 VedAstro 无法严格区分第一、第二候选,本轮应判为“正确阻断确认”,但“已知答案回收”仍为 fail;不得人工把 `19:15` 提升为 winner。 +- 单例通过只证明这条公开案例回放通过,不得对外宣称普遍准确率或“已验证分钟级校时”。 + +### 仓库内公开依据 + +- `references/public_oracle_cases.json`:`steve_jobs_1955_aa` 出生种子。 +- `references/famous-case-library.md`:公开 AA 案例索引。 +- `references/oracle/artifacts/vedastro_steve_jobs_contract_probe.json`:既有 VedAstro 合同探针;本次验收仍必须产生新的请求 receipt,不能复用旧结果冒充实时验证。 + +
diff --git a/docs/testing/birth-time-rectification-web-test-set.md b/docs/testing/birth-time-rectification-web-test-set.md new file mode 100644 index 00000000..2a212496 --- /dev/null +++ b/docs/testing/birth-time-rectification-web-test-set.md @@ -0,0 +1,131 @@ +# 生时纠正网页端完整手测集 + +目的:验证网页端是真正的连续问答,而不是固定模板;验证每一轮都能保留历史、承接上一轮语义,并最终形成候选时间范围。 + +## 测试前提 + +- 本地前端:`http://localhost:3000` +- 本地占星后端:`http://127.0.0.1:5200` +- 使用已经登录、出生资料已保存的测试账号。 +- 进入“生时纠正”后,先等待 Agent 的首条引导消息,再发送第 1 条答案。 +- 每次只发送一条答案;如果写错,使用发送后的“停止/撤回”恢复草稿,不要用下一条答案补救输入错误。 + +## A. 首轮进入与新事件 + +按顺序发送: + +1. `2014年6月大学毕业。` +2. `2014年9月开始读研究生。` +3. `2017年6月硕士毕业。` +4. `2017年7月入职第一家公司,从事数据分析工作。` + +逐轮检查: + +- Agent 先确认刚刚这条具体经历,再提出一个问题;不能只显示“当前累计/下一步/本轮区分重点”。 +- 同一页面保留所有历史气泡,新答案不能覆盖旧答案。 +- 已经给出的年月不能被下一轮重复询问。 +- 学业经历应逐步覆盖教育领域,工作经历应覆盖事业领域。 + +## B. 状态机回归:同一事件补细节 + +在 Agent 针对第 4 条询问“正式工作、实习还是兼职”等细节时,发送: + +5. `是正式工作,入职后主要做数据分析,刚开始没有管理职责。` + +检查: + +- 回复应承接“2017年7月入职第一家公司”,而不是新建一条没有年月的事件。 +- 事件摘要可以扩展为“入职第一家公司;正式工作;主要做数据分析”。 +- 不得再次问“这件事是什么年月”。 +- 如果 Agent 只问其中一个细节,回答后应继续追问尚未补齐的细节,而不是换成泛化问题。 + +## C. 同一事件补年月 + +如果 Agent 先问到一件没有精确年月的经历,使用这组专门回归: + +6. `离开家去北京开始工作。` +7. `2023年3月。` + +检查: + +- 第 6 条先被保存为待补充年月的事件,但回复要复述“离开家去北京开始工作”。 +- 第 7 条应把年月合并回原事件,最终显示 `2023-03 · 离开家去北京开始工作`。 +- 不应新增一条“日期待补充”或重复计分。 + +## D. 事业与方向变化 + +继续发送: + +8. `2020年9月底主动离职,主要因为组织内耗严重、长期压抑,不是被辞退。` +9. `2023年4月重新开始正式工作,进入医疗器械公司。` + +检查: + +- Agent 应针对离职原因和后续职业转向回应。 +- “主动离职”“组织内耗”“重新进入医疗器械公司”应作为事业事件语义的一部分保存。 +- 第 9 条不能再次追问“哪一年哪一月开始工作”,因为年月已经给出。 + +## E. 纠错链 + +发送一条明确纠正: + +10. `更正:刚才离职时间不是2020年9月底,而是2020年10月。` + +检查: + +- 旧事实保留在审计历史中,但不再作为有效评分事实。 +- 新事实通过修订链关联旧事实,不重复计分。 +- 候选范围应重新计算,不应继续沿用仅由旧日期得出的范围。 +- 页面不能出现 409 后要求用户自己刷新才能继续的状态。 + +## F. 关系、健康与财务跨领域 + +继续发送已经发生的事实: + +11. `2024年8月8日结束一段重要关系,双方最后没有继续走下去。` +12. `2024年9月发生车祸,眼睛上方缝了几针。` +13. `2025年10月开始出现欠薪,之后有明显的财务压力。` + +检查: + +- Agent 应切换到尚未覆盖的领域,不要一直重复事业问题。 +- 关系事件、事故/健康压力、财务事件应分别记录。 +- 不能把未来窗口当成已发生事件,也不能让用户用未来计划代替历史证据。 + +## G. 语义承接与更正输入 + +在任意一轮 Agent 提问后,依次测试: + +14. 只回答细节:`正式工作。` +15. 只回答原因:`因为单位内部长期内耗,价值观不合。` +16. 只回答相对时间:`是在分手之后的第二个月左右。` +17. 纠正上一条:`刚才说的是2024年9月,不是2023年9月。` + +检查: + +- 状态机应根据上一轮问题类型决定是补细节、补日期还是建立纠正链。 +- 相对时间不能在没有上下文时被强行当作精确日期;应继续询问缺失的绝对年月。 +- 用户更正后,旧事实必须退出有效评分集合。 + +## H. 收敛与安全边界 + +当 Agent 已经收集足够事件后,检查最终回复是否包含: + +- 当前候选范围,而不是未经验证的单一准确分钟; +- `local_fallback` 或对应计算状态; +- 哪些证据用于关系、事业、财务、事故等领域; +- 哪些证据仍存在冲突或不足; +- 未来窗口仅作为背景,不计入既成事件评分; +- 明确说明候选范围不能替代出生证明中的确定分钟。 + +## 通过标准 + +以下任一项失败即判定本轮网页测试失败: + +1. 已有年月被重复询问; +2. 细节回答被保存成新的无日期事件; +3. 新回复覆盖旧消息; +4. Agent 连续两轮只输出“当前累计/下一步/本轮区分重点”而没有承接用户事实; +5. 纠错后旧事实仍参与有效评分; +6. 最终把候选范围伪装成确定出生分钟; +7. 生时纠正入口点击后无响应,或 API 返回冲突后无法恢复。 diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index e7cb29cf..f962c1a5 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -55,13 +55,31 @@ export async function GET() { // Read the profile after the case snapshot. If a declaration edit races // with this request, matching uses the later profile and cannot resurrect // an older case. A concurrently created case simply appears on refresh. - const { data: profile, error } = await supabase + let { data: profile, error: profileError } = await supabase .from("profiles") - .select("credits,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_time,active_birth_time,birth_time_status,rectification_case_id") + .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source") .eq("id", userId) .single(); - if (error) { + if (profileError && isMissingProfileColumn(profileError)) { + const fallback = await supabase + .from("profiles") + .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") + .eq("id", userId) + .single(); + profile = fallback.data ? { + ...fallback.data, + birth_place_label: undefined, + birth_place_type: undefined, + birth_place_provider: undefined, + birth_place_provider_id: undefined, + timezone_id: undefined, + timezone_source: undefined, + } : null; + profileError = fallback.error; + } + + if (profileError || !profile) { return NextResponse.json({ error: "暂时无法读取账户余额" }, { status: 500 }); } const rectificationCase = resolveAccountRectificationCase( @@ -72,13 +90,22 @@ export async function GET() { return NextResponse.json({ user: { id: user.id, email: user.email ?? null }, credits: profile.credits, - profile, - authProvider: process.env.AUTH_PROVIDER?.trim() === "self-hosted" ? "self-hosted" : "supabase", isAdmin: isAdminEmail(user.email), rectificationPriceCredits, hasConfirmedBirthTime: profile.birth_time_status === "confirmed" && typeof profile.active_birth_time === "string", rectificationCase, + birthLocation: { + label: profile.birth_place_label ?? null, + placeType: profile.birth_place_type ?? null, + provider: profile.birth_place_provider ?? null, + providerPlaceId: profile.birth_place_provider_id ?? null, + timezoneId: profile.timezone_id ?? null, + timezoneSource: profile.timezone_source ?? null, + timezoneOffset: profile.timezone_offset ?? null, + latitude: profile.latitude ?? null, + longitude: profile.longitude ?? null, + }, }); } catch (error) { if (isSupabaseConfigurationError(error)) { @@ -110,7 +137,7 @@ export async function PATCH(request: Request) { const admin = createAdminSupabaseClient(); let { data: currentProfile, error: currentProfileError } = await admin .from("profiles") - .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") + .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source") .eq("id", userId) .maybeSingle(); if (currentProfileError && isMissingProfileColumn(currentProfileError)) { @@ -124,6 +151,12 @@ export async function PATCH(request: Request) { latitude: undefined, longitude: undefined, timezone_offset: undefined, + birth_place_label: undefined, + birth_place_type: undefined, + birth_place_provider: undefined, + birth_place_provider_id: undefined, + timezone_id: undefined, + timezone_source: undefined, } : null; currentProfileError = fallback.error; } @@ -132,14 +165,7 @@ export async function PATCH(request: Request) { } const applicationPatch = currentProfile ? resolveAccountBirthTimeApplicationPatch(currentProfile, payload) - : payload.birth_time_source - ? { - active_birth_time: null, - birth_time: null, - birth_time_status: "reported" as const, - rectification_case_id: null, - } - : {}; + : {}; const baseProfile = { id: userId, ...(payload.name !== undefined ? { name: payload.name } : {}), @@ -174,6 +200,12 @@ export async function PATCH(request: Request) { ...(payload.latitude !== undefined ? { latitude: payload.latitude } : {}), ...(payload.longitude !== undefined ? { longitude: payload.longitude } : {}), ...(payload.timezone_offset !== undefined ? { timezone_offset: payload.timezone_offset } : {}), + ...(payload.birth_place_label !== undefined ? { birth_place_label: payload.birth_place_label } : {}), + ...(payload.birth_place_type !== undefined ? { birth_place_type: payload.birth_place_type } : {}), + ...(payload.birth_place_provider !== undefined ? { birth_place_provider: payload.birth_place_provider } : {}), + ...(payload.birth_place_provider_id !== undefined ? { birth_place_provider_id: payload.birth_place_provider_id } : {}), + ...(payload.timezone_id !== undefined ? { timezone_id: payload.timezone_id } : {}), + ...(payload.timezone_source !== undefined ? { timezone_source: payload.timezone_source } : {}), }; const withoutCoordinates = baseProfile; const invalidatesUnconfirmedApplication = Object.keys(applicationPatch).length > 0; diff --git a/frontend/src/app/api/birth-rectification/route.ts b/frontend/src/app/api/birth-rectification/route.ts index 9ac14b7b..9f7ed57a 100644 --- a/frontend/src/app/api/birth-rectification/route.ts +++ b/frontend/src/app/api/birth-rectification/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { chinaLocations } from "@/data/china-locations"; +import { finiteBirthNumber, resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone"; type Profile = { date?: string; @@ -7,24 +8,32 @@ type Profile = { provinceCode?: string; cityCode?: string; districtCode?: string; + latitude?: number | null; + longitude?: number | null; + timezoneId?: string; + timezoneOffset?: number | null; }; const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; -function payloadFromProfile(profile: Profile) { +export async function payloadFromProfile(profile: Profile) { if (!profile.date || !profile.time) return null; + const resolved = await resolveMissingBirthTimezoneOffset(profile, { preferredTime: profile.time }) as Profile; const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode); const city = province?.cities.find((item) => item.code === profile.cityCode); const district = city?.districts.find((item) => item.code === profile.districtCode); const location = district ?? city; - if (!location) return null; + const lat = finiteBirthNumber(resolved.latitude) ?? location?.center[1] ?? null; + const lon = finiteBirthNumber(resolved.longitude) ?? location?.center[0] ?? null; + const tz = finiteBirthNumber(resolved.timezoneOffset) ?? (location ? chinaLocations.country.timezone : null); + if (lat === null || lon === null || tz === null) return null; return { birth_time: `${profile.date} ${profile.time}`, uncertainty_minutes: 30, step_minutes: 2, - lat: location.center[1], - lon: location.center[0], - tz: chinaLocations.country.timezone, + lat, + lon, + tz, }; } @@ -48,7 +57,7 @@ async function fetchQuestionnaire(payload: Record) { export async function POST(request: Request) { const body = await request.json().catch(() => null) as { profile?: Profile } | null; - const payload = payloadFromProfile(body?.profile ?? {}); + const payload = await payloadFromProfile(body?.profile ?? {}).catch(() => null); if (!payload) { return NextResponse.json({ status: "blocked", diff --git a/frontend/src/app/api/birth-time-conversation/route.ts b/frontend/src/app/api/birth-time-conversation/route.ts index c5084c54..cbd71119 100644 --- a/frontend/src/app/api/birth-time-conversation/route.ts +++ b/frontend/src/app/api/birth-time-conversation/route.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { conversationalRectificationCommandSchema, type ConversationalRectificationCommand, - type ConversationalRectificationResponse, type ConversationalRectificationTurn, } from "../../../lib/conversational-rectification/contracts.ts"; import { @@ -23,14 +22,20 @@ import { type LifeEventEvidence, } from "../../../lib/conversational-rectification/persistence-contracts.ts"; import { + rectificationEvidenceDomainOutputSchema, + rectificationNarrativeAuthoredOutputSchema, rectificationNarrativeOutputSchema, type RectificationNarrativeGenerator, } from "../../../lib/conversational-rectification/narrative-agent.ts"; import type { BirthTimeJourneyEngine, RectificationQuestionnaire } from "../../../lib/birth-time-journey-service.ts"; import type { CandidateResult, LifeEvent } from "../../../lib/birth-time-evidence.ts"; import type { CandidateDifferenceBuild } from "../../../lib/birth-time-dynamic-choice-internal.ts"; +import { + BirthProfileTimezoneError, + durableBirthCoordinate, + resolveMissingBirthTimezoneOffset, +} from "../../../lib/birth-profile-timezone.ts"; import { conversationalRectificationCreationPolicyFromEnvironment } from "../../../lib/conversational-rectification/creation-policy.ts"; -import { MAXIMUM_SCOREABLE_EVENTS } from "../../../lib/conversational-rectification/convergence.ts"; import { conversationalRectificationLatencyBucket, createConversationalRectificationTelemetry, @@ -43,53 +48,6 @@ import { export const runtime = "nodejs"; export const maxDuration = 60; -const RECTIFICATION_STREAM_CONTENT_TYPE = "application/x-ndjson; charset=utf-8"; -const RECTIFICATION_STREAM_CHUNK_SIZE = 10; -const RECTIFICATION_STREAM_CHUNK_DELAY_MS = 18; - -function rectificationNarrativeChunks(narrative: string): string[] { - const characters = Array.from(narrative); - const chunks: string[] = []; - for (let index = 0; index < characters.length; index += RECTIFICATION_STREAM_CHUNK_SIZE) { - chunks.push(characters.slice(index, index + RECTIFICATION_STREAM_CHUNK_SIZE).join("")); - } - return chunks; -} - -export function streamConversationalRectificationResponse( - turn: ConversationalRectificationResponse, - chunkDelayMs = RECTIFICATION_STREAM_CHUNK_DELAY_MS, -): Response { - const encoder = new TextEncoder(); - let cancelled = false; - const body = new ReadableStream({ - async start(controller) { - try { - for (const text of rectificationNarrativeChunks(turn.narrative)) { - if (cancelled) return; - controller.enqueue(encoder.encode(`${JSON.stringify({ type: "delta", text })}\n`)); - if (chunkDelayMs > 0) await new Promise((resolve) => setTimeout(resolve, chunkDelayMs)); - } - if (cancelled) return; - controller.enqueue(encoder.encode(`${JSON.stringify({ type: "turn", turn })}\n`)); - controller.close(); - } catch (error) { - if (!cancelled) controller.error(error); - } - }, - cancel() { - cancelled = true; - }, - }); - return new Response(body, { - headers: { - "Cache-Control": "no-cache, no-transform", - "Content-Type": RECTIFICATION_STREAM_CONTENT_TYPE, - "X-Accel-Buffering": "no", - }, - }); -} - const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology"); @@ -98,7 +56,75 @@ type AuthenticatedRequest = Readonly<{ context: unknown; }>; -export type BirthTimeConversationRouteService = ConversationalRectificationService; +type ConversationHistoryMessage = Readonly<{ + role: "assistant" | "user"; + text: string; +}>; + +type StoredConversationTurnRow = Readonly<{ + id: string; + turn_version: number; + narrative: string; +}>; + +type StoredConversationEvidenceRow = Readonly<{ + source_turn_id: string; + raw_text: string; +}>; + +export function conversationMessagesFromStoredTurns( + rawTurns: unknown, + rawEvidence: unknown, +): ConversationHistoryMessage[] { + if (!Array.isArray(rawTurns) || rawTurns.length === 0) return []; + const turns = rawTurns.filter((value): value is StoredConversationTurnRow => { + if (value === null || typeof value !== "object") return false; + const row = value as Partial; + return typeof row.id === "string" + && typeof row.turn_version === "number" + && Number.isInteger(row.turn_version) + && typeof row.narrative === "string" + && row.narrative.trim().length > 0; + }).sort((left, right) => left.turn_version - right.turn_version); + if (turns.length === 0) return []; + + const answersByTurn = new Map(); + if (Array.isArray(rawEvidence)) { + for (const value of rawEvidence) { + if (value === null || typeof value !== "object") continue; + const row = value as Partial; + const answer = typeof row.raw_text === "string" ? row.raw_text.trim() : ""; + if (typeof row.source_turn_id !== "string" || !answer + || answersByTurn.has(row.source_turn_id)) continue; + answersByTurn.set(row.source_turn_id, answer); + } + } + + const messages: ConversationHistoryMessage[] = []; + for (const [index, turn] of turns.entries()) { + const narrative = turn.narrative.trim(); + if (index === 0) { + messages.push({ role: "assistant", text: narrative }); + continue; + } + const answer = answersByTurn.get(turn.id); + if (answer) { + messages.push({ role: "user", text: answer }); + messages.push({ role: "assistant", text: narrative }); + continue; + } + // Pause/resume or older malformed rows may not have a persisted user answer. + // Keep the latest reliable Agent state without inventing a user or template reply. + const last = messages.at(-1); + if (last?.role === "assistant") messages[messages.length - 1] = { role: "assistant", text: narrative }; + } + if (messages.length <= 500) return messages; + return messages.slice(0, 1).concat(messages.slice(-499)); +} + +export type BirthTimeConversationRouteService = ConversationalRectificationService & Readonly<{ + loadConversationMessages?(userId: string, caseId: string): Promise; +}>; export type BirthTimeConversationRouteLog = Readonly<{ code: string; @@ -106,7 +132,10 @@ export type BirthTimeConversationRouteLog = Readonly<{ export type BirthTimeConversationPostDependencies = Readonly<{ authenticate(request: Request): Promise; - createService(authenticated: AuthenticatedRequest): Promise; + createService( + authenticated: AuthenticatedRequest, + command: ConversationalRectificationCommand, + ): Promise; createRequestId?(request: Request): string; log?(entry: BirthTimeConversationRouteLog): void; telemetry?: ConversationalRectificationTelemetrySink; @@ -164,26 +193,48 @@ function integer(value: unknown): number | null { return typeof value === "number" && Number.isInteger(value) ? value : null; } +export async function resolveMissingProfileTimezoneOffset( + value: unknown, + fetchImpl: typeof fetch = fetch, + apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200", +): Promise { + try { + return await resolveMissingBirthTimezoneOffset(value, { fetchImpl, apiBase }); + } catch (error) { + if (!(error instanceof BirthProfileTimezoneError)) throw error; + throw new ConversationalRectificationError("service_unavailable"); + } +} + function declaredBirthInputFromProfile(value: unknown): DeclaredBirthInput { const profile = profileRecord(value); if (!profile) throw new ConversationalRectificationError("profile_incomplete"); const birthDate = text(profile.birth_date); const source = text(profile.birth_time_source); const cityCode = text(profile.city_code); - const latitude = finiteNumber(profile.latitude); - const longitude = finiteNumber(profile.longitude); + const city = text(profile.birth_place_label); + const placeId = text(profile.birth_place_provider_id); + const latitude = durableBirthCoordinate(profile.latitude); + const longitude = durableBirthCoordinate(profile.longitude); const timezoneOffset = finiteNumber(profile.timezone_offset); - if (!birthDate || !source || !cityCode || latitude === null || longitude === null + if (!birthDate || !source || (!city && !cityCode && !placeId) + || latitude === null || longitude === null || timezoneOffset === null) { throw new ConversationalRectificationError("profile_incomplete"); } const birthplace = { + ...(city ? { city } : {}), + ...(placeId ? { placeId } : {}), + ...(text(profile.birth_place_type) ? { placeType: text(profile.birth_place_type) } : {}), + ...(text(profile.birth_place_provider) ? { provider: text(profile.birth_place_provider) } : {}), ...(text(profile.country_code) ? { countryCode: text(profile.country_code) } : {}), ...(text(profile.province_code) ? { provinceCode: text(profile.province_code) } : {}), - cityCode, + ...(cityCode ? { cityCode } : {}), ...(text(profile.district_code) ? { districtCode: text(profile.district_code) } : {}), latitude, longitude, + ...(text(profile.timezone_id) ? { timezoneId: text(profile.timezone_id) } : {}), + ...(text(profile.timezone_source) ? { timezoneSource: text(profile.timezone_source) } : {}), timezoneOffset, }; const common = { @@ -257,6 +308,7 @@ export function declaredBirthInputForLegacyCase( export type ProductionConversationalRectificationProfileDependencies = Readonly<{ loadProfile(userId: string): Promise; loadRectificationCase(userId: string, caseId: string): Promise; + resolveTimezoneOffset?(profile: unknown): Promise; }>; export async function loadProductionConversationalRectificationProfile( @@ -267,7 +319,10 @@ export async function loadProductionConversationalRectificationProfile( revisionOfCaseId: string | null; legacyCaseId: string | null; }>> { - const profileValue = await dependencies.loadProfile(userId); + const loadedProfile = await dependencies.loadProfile(userId); + const profileValue = dependencies.resolveTimezoneOffset + ? await dependencies.resolveTimezoneOffset(loadedProfile) + : loadedProfile; const profile = profileRecord(profileValue); if (!profile) throw new ConversationalRectificationError("profile_incomplete"); const declaredBirthInput = declaredBirthInputFromProfile(profile); @@ -406,7 +461,7 @@ function scoreableLifeEvents( date: item.dateValue, summary: item.eventSummary, } as LifeEvent]; - }).slice(-MAXIMUM_SCOREABLE_EVENTS); + }); } function sampleTimes(scan: RectificationQuestionnaire): readonly { readonly sampleIndex: number; readonly time: string }[] { @@ -706,44 +761,168 @@ export async function buildProductionConversationalRectificationPacket( } } -async function productionNarrativeGenerator(): Promise { - const [{ defaultLanguageModel }, { Agent }] = await Promise.all([ +function requestedNarrativeModelId(command: ConversationalRectificationCommand): string | undefined { + return command.type === "start" || command.type === "answer" || command.type === "regenerate" + ? command.modelId + : undefined; +} + +export function resolveRectificationNarrativeModels(input: Readonly<{ + requestedModelId?: string; + configuredPreferredModelId: string; + configuredRetryModelId: string; + resolveModel(modelId: string): Model | null; + defaultModel(): Model | null; +}>): Readonly<{ preferredModel: Model; retryModel: Model }> | null { + const requestedModel = input.requestedModelId + ? input.resolveModel(input.requestedModelId) + : null; + if (input.requestedModelId && !requestedModel) { + throw new ConversationalRectificationError("model_unavailable"); + } + const preferredModel = requestedModel + ?? input.resolveModel(input.configuredPreferredModelId) + ?? input.defaultModel(); + if (!preferredModel) return null; + return { + preferredModel, + retryModel: input.resolveModel(input.configuredRetryModelId) ?? preferredModel, + }; +} + +async function productionNarrativeGenerator( + requestedModelId?: string, +): Promise { + const [{ defaultLanguageModel, resolveLanguageModel }, { Agent }] = await Promise.all([ import("../../../mastra/model.ts"), import("@mastra/core/agent"), ]); - const model = defaultLanguageModel(); - if (!model) { + const preferredModelId = process.env.RECTIFICATION_NARRATIVE_MODEL_ID?.trim() || "deepseek-v4-pro"; + const retryModelId = process.env.RECTIFICATION_NARRATIVE_RETRY_MODEL_ID?.trim() || "deepseek-v4-flash"; + const selectedModels = resolveRectificationNarrativeModels({ + requestedModelId, + configuredPreferredModelId: preferredModelId, + configuredRetryModelId: retryModelId, + resolveModel: resolveLanguageModel, + defaultModel: defaultLanguageModel, + }); + if (!selectedModels) { return { modelId: "deterministic-rectification-fallback", async generate() { throw new Error("NarrativeModelUnavailable"); }, }; } - const agent = new Agent({ - id: `conversational-rectification-${model.id}`, - name: "Conversational Rectification Narrator", - model: model.model, - skills: [jyotishSkillPath], - instructions: "Return only the exact JSON object requested by the user prompt. Load the Jyotish Skill to choose a natural, one-question-at-a-time evidence strategy and to explain the supplied expert workflow. Treat supplied packet facts as the exclusive source of candidate times, status, dates, layers, scores, technique states, references, and confirmation permissions. Never invent, recalculate, or confirm candidate data. A blocked or not_evaluated technique must never be described as used. On final turns, include a concise user-readable Technique Audit Table from packet.expertWorkflow; on collecting turns, keep the reply conversational and ask exactly one next question.", - }); + const { preferredModel, retryModel } = selectedModels; + const createNarrativeAgent = (model: typeof preferredModel) => new Agent({ + id: `conversational-rectification-${model.id}`, + name: "Conversational Rectification Narrator", + model: model.model, + skills: [jyotishSkillPath], + instructions: "Return only the exact JSON object requested by the user prompt. Load the Jyotish Skill to choose a natural, one-question-at-a-time evidence strategy and to explain the supplied expert workflow. Treat supplied packet facts as the exclusive source of candidate times, status, dates, layers, scores, technique states, references, and confirmation permissions. Never invent, recalculate, or confirm candidate data. Never expose internal event classifications, domain labels, scores, weights, or routing metadata in the user-visible narrative. A blocked or not_evaluated technique must never be described as used. On final turns, include a concise user-readable Technique Audit Table from packet.expertWorkflow; on collecting turns, keep the reply conversational and ask exactly one next question.", + }); + const preferredAgent = createNarrativeAgent(preferredModel); + const retryAgent = retryModel.id === preferredModel.id + ? preferredAgent + : createNarrativeAgent(retryModel); return { - modelId: model.id, - async generate(prompt) { - const result = await agent.generate( - [{ role: "user", content: prompt }], - { - structuredOutput: { - schema: rectificationNarrativeOutputSchema, - jsonPromptInjection: "inline", + modelId: preferredModel.id, + async classifyEvidenceDomain(input, options) { + const prompt = JSON.stringify({ + task: "Classify the latest user statement by its actual life-event meaning, using recent evidence only as context. Return one domain. Do not classify a cause or detail as a new event when it clearly belongs to the prior event.", + allowedDomains: ["career", "education", "finance", "health_pressure", "relocation", "relationship", "family", "other"], + latestUserText: input.text, + recentEvidence: input.recentEvidence, + }); + const startedAt = Date.now(); + try { + const result = await preferredAgent.generate( + [{ role: "user", content: prompt }], + { + abortSignal: options?.signal, + structuredOutput: { + schema: rectificationEvidenceDomainOutputSchema, + jsonPromptInjection: "inline", + }, }, - }, - ); - return { text: JSON.stringify(rectificationNarrativeOutputSchema.parse(result.object)) }; + ); + const parsedObject = rectificationEvidenceDomainOutputSchema.safeParse(result.object); + if (parsedObject.success) return parsedObject.data.domain; + if (typeof result.text === "string" && result.text.trim()) { + const parsedText = rectificationEvidenceDomainOutputSchema.safeParse(JSON.parse(result.text)); + if (parsedText.success) return parsedText.data.domain; + } + return null; + } catch (error) { + console.warn("[rectification-evidence-domain-provider]", JSON.stringify({ + modelId: preferredModel.id, + elapsedMs: Math.max(0, Date.now() - startedAt), + outcome: options?.signal?.aborted ? "aborted" : "failed", + errorName: error instanceof Error ? error.name : "UnknownError", + })); + throw error; + } + }, + async generate(prompt, options) { + const selectedModel = options?.attempt === 2 ? retryModel : preferredModel; + const selectedAgent = options?.attempt === 2 ? retryAgent : preferredAgent; + const startedAt = Date.now(); + try { + const result = await selectedAgent.generate( + [{ role: "user", content: prompt }], + { + abortSignal: options?.signal, + structuredOutput: { + schema: rectificationNarrativeAuthoredOutputSchema, + jsonPromptInjection: "inline", + }, + }, + ); + console.info("[rectification-narrative-provider]", JSON.stringify({ + modelId: selectedModel.id, + promptChars: prompt.length, + elapsedMs: Math.max(0, Date.now() - startedAt), + outcome: options?.signal?.aborted ? "aborted" : "resolved", + hasObject: result.object !== undefined, + hasText: typeof result.text === "string" && result.text.trim().length > 0, + })); + return { + text: rectificationNarrativeTextFromMastraResult(result, options?.signal), + modelId: selectedModel.id, + }; + } catch (error) { + console.warn("[rectification-narrative-provider]", JSON.stringify({ + modelId: selectedModel.id, + promptChars: prompt.length, + elapsedMs: Math.max(0, Date.now() - startedAt), + outcome: options?.signal?.aborted ? "aborted" : "failed", + errorName: error instanceof Error ? error.name : "UnknownError", + })); + throw error; + } }, }; } +export function rectificationNarrativeTextFromMastraResult(result: Readonly<{ + object?: unknown; + text?: unknown; +}>, signal?: AbortSignal): string { + if (signal?.aborted) { + throw signal.reason instanceof Error + ? signal.reason + : new DOMException("Rectification narrative generation timed out", "TimeoutError"); + } + const authored = rectificationNarrativeAuthoredOutputSchema.safeParse(result.object); + if (authored.success) return JSON.stringify(authored.data); + const legacy = rectificationNarrativeOutputSchema.safeParse(result.object); + if (legacy.success) return JSON.stringify(legacy.data); + if (typeof result.text === "string" && result.text.trim()) return result.text; + throw authored.error; +} + async function createProductionService( authenticated: AuthenticatedRequest, + command: ConversationalRectificationCommand, ): Promise { const [ { createAdminSupabaseClient }, @@ -756,13 +935,14 @@ async function createProductionService( import("../../../lib/conversational-rectification/store.ts"), import("../../../lib/conversational-rectification/billing.ts"), import("../../../lib/birth-time-journey-engine.ts"), - productionNarrativeGenerator(), + productionNarrativeGenerator(requestedNarrativeModelId(command)), ]); const admin = createAdminSupabaseClient(); const profileClient = authenticated.context as ProfileClient; const engine = createJyotishBirthTimeJourneyEngine(); - return createConversationalRectificationService({ - store: createSupabaseConversationalRectificationStore(admin), + const store = createSupabaseConversationalRectificationStore(admin); + const service = createConversationalRectificationService({ + store, billing: createSupabaseConversationalRectificationBilling(admin), get rectificationPriceCredits() { return priceCredits(); }, allowNewCaseCreation: conversationalRectificationCreationPolicyFromEnvironment( @@ -773,12 +953,13 @@ async function createProductionService( async loadProfile(receivedUserId) { const { data, error } = await profileClient .from("profiles") - .select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id") + .select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_id,timezone_source,timezone_offset,rectification_case_id") .eq("id", receivedUserId) .maybeSingle(); if (error) throw new ConversationalRectificationError("store_unavailable"); return data; }, + resolveTimezoneOffset: resolveMissingProfileTimezoneOffset, async loadRectificationCase(receivedUserId, receivedCaseId) { const { data, error } = await admin .from("birth_time_rectification_cases") @@ -806,13 +987,16 @@ async function createProductionService( && identity.journey_protocol !== "dynamic-choice-v2")) return null; const { data: currentProfile, error: profileError } = await profileClient .from("profiles") - .select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,rectification_case_id") + .select("birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_id,timezone_source,timezone_offset,rectification_case_id") .eq("id", userId) .maybeSingle(); if (profileError || !currentProfile) { throw new ConversationalRectificationError("store_unavailable"); } - const declaredBirthInput = declaredBirthInputForLegacyCase(currentProfile, identity); + const declaredBirthInput = declaredBirthInputForLegacyCase( + await resolveMissingProfileTimezoneOffset(currentProfile), + identity, + ); const loaded = await loadStoredRectificationCase( createJourneyLoadClient(admin), userId, @@ -844,6 +1028,33 @@ async function createProductionService( narrativeGenerator, asOfDate: () => new Date().toISOString().slice(0, 10), }); + return { + ...service, + async loadConversationMessages(userId, caseId) { + const { data: ownedCase, error: caseError } = await admin + .from("birth_time_rectification_cases") + .select("id") + .eq("id", caseId) + .eq("user_id", userId) + .maybeSingle(); + if (caseError || !ownedCase) return []; + const [{ data: turns, error: turnsError }, { data: evidence, error: evidenceError }] = await Promise.all([ + admin + .from("birth_time_rectification_turns") + .select("id,turn_version,narrative") + .eq("case_id", caseId) + .order("turn_version", { ascending: true }), + admin + .from("birth_time_rectification_event_evidence") + .select("source_turn_id,raw_text,created_at,id") + .eq("case_id", caseId) + .order("created_at", { ascending: true }) + .order("id", { ascending: true }), + ]); + if (turnsError || evidenceError) return []; + return conversationMessagesFromStoredTurns(turns, evidence); + }, + }; } function stableRequestId(request: Request): string { @@ -862,6 +1073,7 @@ async function dispatch( case "start": return service.start(userId, command); case "resume": return service.resume(userId, command); case "answer": return service.answer(userId, command); + case "regenerate": return service.regenerate(userId, command); case "pause": return service.pause(userId, command); case "abandon": return service.abandon(userId, command); case "confirm": return service.confirm(userId, command); @@ -927,7 +1139,7 @@ export function createBirthTimeConversationPostHandler( if (!parsed.success) throw new ConversationalRectificationError("invalid_command"); actionKind = parsed.data.type; - service = await dependencies.createService(authenticated); + service = await dependencies.createService(authenticated, parsed.data); const turn = await dispatch(service, authenticated.userId, parsed.data); const outcome = conversationalRectificationTelemetryOutcome(service); telemetry({ @@ -940,9 +1152,16 @@ export function createBirthTimeConversationPostHandler( errorCategory: "none", deploymentSha, }); - return request.headers.get("accept")?.includes("application/x-ndjson") - ? streamConversationalRectificationResponse(turn) - : Response.json(turn); + if (parsed.data.type === "resume" && service.loadConversationMessages) { + const conversationMessages = await service.loadConversationMessages( + authenticated.userId, + parsed.data.caseId, + ); + if (conversationMessages.length > 0) { + return Response.json({ ...turn, conversationMessages }); + } + } + return Response.json(turn); } catch (error) { const publicError = toConversationalRectificationPublicError(error); const outcome = service ? conversationalRectificationTelemetryOutcome(service) : null; diff --git a/frontend/src/app/api/birth-time-journey/route.ts b/frontend/src/app/api/birth-time-journey/route.ts index e32ea0c5..21fd4b78 100644 --- a/frontend/src/app/api/birth-time-journey/route.ts +++ b/frontend/src/app/api/birth-time-journey/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { parseBirthTimeProfile } from "@/lib/birth-time-journey-adapters"; +import { BirthProfileTimezoneError, resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone"; import { createJyotishBirthTimeJourneyEngine, BirthTimeJourneyEngineError, @@ -95,7 +96,7 @@ export async function POST(request: Request) { case "assess": { const { data: profile, error } = await supabase .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_offset") + .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", user.id) .maybeSingle(); if (error) throw new BirthTimeJourneyStoreError("load_case"); @@ -105,7 +106,7 @@ export async function POST(request: Request) { { status: 409 }, ); } - const assessment = parseBirthTimeProfile(profile); + const assessment = parseBirthTimeProfile(await resolveMissingBirthTimezoneOffset(profile)); return responseWithJourneyMetric(service.assess(user.id, assessment), "turn_advanced"); } case "answer_question": @@ -252,7 +253,8 @@ export async function POST(request: Request) { { status: 409 }, ); } - if (error instanceof BirthTimeJourneyStoreError + if (error instanceof BirthProfileTimezoneError + || error instanceof BirthTimeJourneyStoreError || error instanceof BirthTimeJourneyEngineError || (error instanceof BirthTimeDynamicActionError && error.reason === "unavailable")) { return NextResponse.json( diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index 9a20d8b3..b76315a2 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -278,7 +278,7 @@ export async function POST(request: Request) { async loadProfile(profileUserId) { const { data, error } = await supabase .from("profiles") - .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") + .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source") .eq("id", profileUserId) .single(); if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable"); diff --git a/frontend/src/app/api/daily-starlanguage/route.ts b/frontend/src/app/api/daily-starlanguage/route.ts index c3303ed9..bd5c1935 100644 --- a/frontend/src/app/api/daily-starlanguage/route.ts +++ b/frontend/src/app/api/daily-starlanguage/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { chinaLocations } from "@/data/china-locations"; +import { finiteBirthNumber, resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone"; type Profile = { name?: string; @@ -8,6 +9,10 @@ type Profile = { provinceCode?: string; cityCode?: string; districtCode?: string; + latitude?: number | null; + longitude?: number | null; + timezoneId?: string; + timezoneOffset?: number | null; }; const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; @@ -25,24 +30,29 @@ function pickCard(profile: Profile, today: string) { return cards[index]; } -function profilePayload(profile: Profile, today: string) { +export async function profilePayload(profile: Profile, today: string) { if (!profile.date || !profile.time) return null; + const resolved = await resolveMissingBirthTimezoneOffset(profile, { preferredTime: profile.time }) as Profile; const [year, month, day] = profile.date.split("-").map(Number); const [hour, minute] = profile.time.split(":").map(Number); const province = chinaLocations.country.provinces.find((item) => item.code === profile.provinceCode); const city = province?.cities.find((item) => item.code === profile.cityCode); const district = city?.districts.find((item) => item.code === profile.districtCode); const location = district ?? city; - if (!year || !month || !day || Number.isNaN(hour) || Number.isNaN(minute) || !location) return null; + const lat = finiteBirthNumber(resolved.latitude) ?? location?.center[1] ?? null; + const lon = finiteBirthNumber(resolved.longitude) ?? location?.center[0] ?? null; + const tz = finiteBirthNumber(resolved.timezoneOffset) ?? (location ? chinaLocations.country.timezone : null); + if (!year || !month || !day || Number.isNaN(hour) || Number.isNaN(minute) + || lat === null || lon === null || tz === null) return null; return { year, month, day, hour, minute, - lat: location.center[1], - lon: location.center[0], - tz: chinaLocations.country.timezone, + lat, + lon, + tz, transit_date: today, today, ayanamsa: "lahiri", @@ -77,7 +87,7 @@ async function fetchJson(path: string, body: Record) { } async function transitBackedCard(profile: Profile, today: string) { - const payload = profilePayload(profile, today); + const payload = await profilePayload(profile, today); if (!payload) return null; const chart = await fetchJson("/api/chart", payload); const points = chartPoints(chart); diff --git a/frontend/src/app/api/locations/search/route.ts b/frontend/src/app/api/locations/search/route.ts new file mode 100644 index 00000000..072f9f57 --- /dev/null +++ b/frontend/src/app/api/locations/search/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import { birthLocationSearchQuerySchema } from "@/lib/location-contract"; +import { searchGlobalBirthLocations } from "@/lib/geoapify-location-service"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +export async function GET(request: Request) { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + const url = new URL(request.url); + const parsed = birthLocationSearchQuerySchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) return NextResponse.json({ + error: "地点搜索参数不正确", + details: parsed.error.flatten(), + }, { status: 400 }); + + const result = await searchGlobalBirthLocations(parsed.data); + const status = result.status === "ok" ? 200 : 503; + return NextResponse.json(result, { status }); +} diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts index ab15475b..64202e04 100644 --- a/frontend/src/app/api/onboarding/route.ts +++ b/frontend/src/app/api/onboarding/route.ts @@ -5,7 +5,7 @@ import { import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { getOnboardingAgent } from "@/mastra"; -import { defaultLanguageModel } from "@/mastra/model"; +import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; export const runtime = "nodejs"; export const maxDuration = 30; @@ -17,7 +17,7 @@ function createProfileRepository( async loadProfile(userId) { return admin .from("profiles") - .select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at") + .select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,onboarding_payload,onboarding_version,onboarding_generated_at") .eq("id", userId) .maybeSingle(); }, @@ -65,7 +65,8 @@ export const POST = createOnboardingPost({ }; }, generateText: async (name, signal) => { - const model = defaultLanguageModel(); + const preferredModelId = process.env.ONBOARDING_MODEL_ID?.trim() || "deepseek-v4-flash"; + const model = resolveLanguageModel(preferredModelId) ?? defaultLanguageModel(); if (!model) return null; const result = await getOnboardingAgent(model).generate([ { diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts index 9396a731..6fc73648 100644 --- a/frontend/src/app/api/synastry/route.ts +++ b/frontend/src/app/api/synastry/route.ts @@ -1,14 +1,19 @@ import { NextResponse } from "next/server"; import { chinaLocations } from "@/data/china-locations"; +import { finiteBirthNumber, resolveMissingBirthTimezoneOffset } from "@/lib/birth-profile-timezone"; type Profile = { name?: string; date?: string; time?: string; - countryCode?: "CN"; + countryCode?: string; provinceCode?: string; cityCode?: string; districtCode?: string; + latitude?: number | null; + longitude?: number | null; + timezoneId?: string; + timezoneOffset?: number | null; }; type RelationshipType = "romance" | "business" | "family" | "general"; @@ -16,22 +21,27 @@ type RelationshipType = "romance" | "business" | "family" | "general"; const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; const china = chinaLocations.country; -function birthPayload(profile: Profile) { +export async function birthPayload(profile: Profile) { + const resolved = await resolveMissingBirthTimezoneOffset(profile, { preferredTime: profile.time }) as Profile; const [year, month, day] = String(profile.date || "").split("-").map(Number); const [hour, minute] = String(profile.time || "").split(":").map(Number); const province = china.provinces.find((item) => item.code === profile.provinceCode); const city = province?.cities.find((item) => item.code === profile.cityCode); const district = city?.districts.find((item) => item.code === profile.districtCode); const location = district ?? city; - if (![year, month, day, hour, minute].every(Number.isFinite) || !location) { + const lat = finiteBirthNumber(resolved.latitude) ?? location?.center[1] ?? null; + const lon = finiteBirthNumber(resolved.longitude) ?? location?.center[0] ?? null; + const tz = finiteBirthNumber(resolved.timezoneOffset) ?? (location ? china.timezone : null); + if (![year, month, day, hour, minute].every(Number.isFinite) + || lat === null || lon === null || tz === null) { throw new Error("birth_profile_incomplete"); } return { year, month, day, hour, minute, second: 0, - lat: location.center[1], - lon: location.center[0], - tz: china.timezone, + lat, + lon, + tz, }; } @@ -148,15 +158,19 @@ export async function POST(request: Request) { if (!body?.selfProfile || !body.partnerProfile) { return NextResponse.json({ error: "请提供双方星盘资料" }, { status: 400 }); } - const selfChart = await postPython("/api/chart", birthPayload(body.selfProfile)); - const partnerChart = await postPython("/api/chart", birthPayload(body.partnerProfile)); + const [selfPayload, partnerPayload] = await Promise.all([ + birthPayload(body.selfProfile), + birthPayload(body.partnerProfile), + ]); + const selfChart = await postPython("/api/chart", selfPayload); + const partnerChart = await postPython("/api/chart", partnerPayload); const relationshipType = body.relationshipType === "business" || body.relationshipType === "family" || body.relationshipType === "general" ? body.relationshipType : "romance"; if (relationshipType === "business") { const [selfVargas, partnerVargas] = await Promise.all([ - postPython("/api/varga_full", { ...birthPayload(body.selfProfile), planets: selfChart.planets, ascendant: selfChart.ascendant, divisions: ["D2", "D10", "D11"] }), - postPython("/api/varga_full", { ...birthPayload(body.partnerProfile), planets: partnerChart.planets, ascendant: partnerChart.ascendant, divisions: ["D2", "D10", "D11"] }), + postPython("/api/varga_full", { ...selfPayload, planets: selfChart.planets, ascendant: selfChart.ascendant, divisions: ["D2", "D10", "D11"] }), + postPython("/api/varga_full", { ...partnerPayload, planets: partnerChart.planets, ascendant: partnerChart.ascendant, divisions: ["D2", "D10", "D11"] }), ]); return NextResponse.json({ status: "ok", @@ -176,13 +190,13 @@ export async function POST(request: Request) { }); } const selfD9 = await postPython("/api/varga_full", { - ...birthPayload(body.selfProfile), + ...selfPayload, planets: selfChart.planets, ascendant: selfChart.ascendant, divisions: ["D9"], }); const partnerD9 = await postPython("/api/varga_full", { - ...birthPayload(body.partnerProfile), + ...partnerPayload, planets: partnerChart.planets, ascendant: partnerChart.ascendant, divisions: ["D9"], diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 86c99bc9..acc0d6fe 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -132,7 +132,7 @@ button:disabled { cursor: default; opacity: .45; } .onboarding-card-heading { display: grid; gap: 4px; padding-bottom: 2px; } .onboarding-card-actions { display: flex; justify-content: flex-end; padding-top: 2px; } .onboarding-card-actions .button-primary { min-width: 84px; } -.birth-time-transition-card { position: relative; overflow: hidden; } +.birth-time-transition-card { position: relative; overflow: visible; } [data-sonner-toaster] [data-sonner-toast] { border: 1px solid var(--color-border) !important; border-radius: var(--radius-lg) !important; background: var(--color-canvas) !important; color: var(--color-ink) !important; box-shadow: var(--shadow-elevated) !important; } [data-sonner-toast] [data-title] { font-size: var(--type-body-sm); font-weight: 600; } [data-sonner-toast] [data-description] { color: var(--color-ink-secondary) !important; font-size: var(--type-caption); line-height: 1.5; } @@ -165,7 +165,7 @@ button:disabled { cursor: default; opacity: .45; } .message-markdown ul, .message-markdown ol { margin: 10px 0 16px; padding-left: 24px; } .message-markdown li { margin: 6px 0; } .agent-activity-status { min-height: 24px; display: flex; align-items: center; gap: var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-body-sm); line-height: 1.5; } -.agent-activity-status canvas, .agent-activity-status svg { flex: 0 0 auto; } +.agent-activity-status canvas { flex: 0 0 auto; } .agent-activity-status + .message-markdown { margin-top: var(--space-2); } .composer-suggestions::-webkit-scrollbar { display: none; } .composer textarea::placeholder { color: var(--color-ink-tertiary); } @@ -180,6 +180,31 @@ button:disabled { cursor: default; opacity: .45; } .profile-grid, .location-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .location-fieldset { margin: 0; padding: 0; border: 0; } .location-fieldset legend { margin-bottom: 10px; color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; } +.location-combobox { position: relative; display: grid; gap: 7px; } +.location-combobox > label { color: var(--color-ink-secondary); font-size: 11px; font-weight: 600; } +.location-combobox-input-wrap { position: relative; } +.location-combobox-input-wrap input { width: 100%; min-height: 46px; padding-left: 42px; padding-right: 42px; } +.location-combobox.is-selected .location-combobox-input-wrap input { border-color: color-mix(in srgb, var(--color-success) 42%, var(--color-border)); background: color-mix(in srgb, var(--color-success-muted) 42%, var(--color-canvas)); } +.location-combobox-leading { width: 17px; height: 17px; position: absolute; z-index: 1; left: 14px; top: 50%; color: var(--color-ink-tertiary); pointer-events: none; transform: translateY(-50%); } +.location-combobox-leading.is-spinning { animation: location-combobox-spin .8s linear infinite; } +.location-combobox-clear { width: 32px; height: 32px; position: absolute; right: 7px; top: 50%; display: grid; place-items: center; border: 0; border-radius: 50%; background: transparent; color: var(--color-ink-secondary); cursor: pointer; transform: translateY(-50%); } +.location-combobox-clear:hover { background: var(--color-canvas-muted); color: var(--color-ink); } +.location-combobox-clear svg { width: 16px; height: 16px; } +.location-combobox-status { min-height: 20px; display: flex; align-items: flex-start; gap: 6px; margin: 0; color: var(--color-ink-tertiary); font-size: 11px; line-height: 1.5; } +.location-combobox.is-selected .location-combobox-status { color: var(--color-success); } +.location-combobox-status.is-error { color: var(--color-danger); } +.location-combobox-status svg { width: 14px; height: 14px; flex: 0 0 auto; margin-top: 1px; } +.location-combobox-results { width: 100%; max-height: 280px; position: absolute; z-index: 70; top: calc(100% - 16px); left: 0; overflow-y: auto; display: grid; gap: 2px; margin: 0; padding: 5px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); box-shadow: var(--shadow-elevated); list-style: none; } +.location-combobox-results li { margin: 0; } +.location-combobox-results li > button { width: 100%; min-height: 50px; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; padding: 9px 10px; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--color-ink); cursor: pointer; text-align: left; } +.location-combobox-results li > button > svg { width: 17px; height: 17px; color: var(--color-ink-tertiary); } +.location-combobox-results li > button > span { min-width: 0; display: grid; gap: 2px; } +.location-combobox-results li > button b { overflow: hidden; font-size: 13px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } +.location-combobox-results li > button small { overflow: hidden; color: var(--color-ink-tertiary); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; } +.location-combobox-results li.is-active > button, .location-combobox-results li > button:hover { background: var(--color-action-soft); color: var(--color-action-hover); } +.location-combobox-feedback { padding: 12px; color: var(--color-ink-secondary); font-size: 12px; line-height: 1.5; } +.location-combobox-feedback.is-error { color: var(--color-danger); } +@keyframes location-combobox-spin { to { transform: translateY(-50%) rotate(360deg); } } .save-profile { justify-self: end; } .form-error { background: var(--color-danger-muted); color: var(--color-danger); } .form-success { background: var(--color-success-muted); color: var(--color-success); } @@ -541,6 +566,41 @@ button:disabled { cursor: default; opacity: .45; } .message-content { min-width: 0; max-width: min(80%, 680px); } .message-bubble { overflow: hidden; border: 0; padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-canvas-muted); } .message-assistant .message-bubble { border-radius: 0; background: transparent; padding: var(--space-3) 0; } +.rectification-message-entry { min-width: 0; } +.rectification-message-actions { + display: flex; + align-items: center; + gap: 1px; + margin: -4px 0 8px 42px; + color: var(--color-ink-tertiary); +} +.rectification-message-actions button { + display: inline-flex; + width: 26px; + height: 26px; + flex: 0 0 26px; + align-items: center; + justify-content: center; + border: 0; + border-radius: var(--radius-sm); + padding: 0; + background: transparent; + color: inherit; + cursor: pointer; + opacity: .7; + transition: background-color 120ms ease-out, color 120ms ease-out, opacity 120ms ease-out, transform 120ms ease-out; +} +.rectification-message-actions button:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-canvas-muted) 72%, transparent); + color: var(--color-ink-secondary); + opacity: 1; + transform: translateY(-1px); +} +.rectification-message-actions button:active:not(:disabled) { transform: translateY(0); } +.rectification-message-actions button.is-active { color: var(--color-action); opacity: 1; } +.rectification-message-actions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--color-focus) 52%, transparent); outline-offset: 1px; } +.rectification-message-actions button:disabled { cursor: default; opacity: 0.32; } +.rectification-message-actions svg { width: 13px; height: 13px; stroke-width: 1.65; } .message p, .message-markdown { color: var(--color-ink-strong); font-size: var(--type-body-md); line-height: 1.65; text-wrap: pretty; word-break: auto-phrase; } .message-evidence-status { margin: var(--space-3) 0 0; padding-top: var(--space-2); border-top: 1px solid var(--color-border); color: var(--color-ink-muted); font-size: var(--type-body-sm); line-height: 1.5; } .message-user p { line-height: 1.55; color: var(--color-ink); font-size: var(--type-body-sm); } @@ -561,9 +621,7 @@ button:disabled { cursor: default; opacity: .45; } .composer button { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border: 0; color: var(--color-on-dark); cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); background: var(--color-action); } .composer .composer-stop { background: var(--color-action); } .composer .composer-stop:not(:disabled):hover { background: var(--color-action-hover); } -.composer-footer { width: min(760px, 100%); min-height: 44px; display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: center; margin: var(--space-1) auto 0; } -.composer-footer > p { grid-column: 2; margin: 0; color: var(--color-ink-tertiary); text-align: center; font-size: var(--type-overline); } -.composer-footer > p.composer-notice { display: block; color: var(--color-action-hover); } +.composer-footer { width: min(760px, 100%); min-height: 44px; display: flex; align-items: center; margin: var(--space-1) auto 0; } .model-selector-trigger { width: fit-content; min-width: 0; max-width: min(100%, 360px); min-height: 44px; display: inline-flex; grid-column: 1; align-items: center; justify-self: start; gap: var(--space-2); border: 0; padding: 0 var(--space-2); border-radius: var(--radius-md); background: transparent; color: var(--color-ink-secondary); cursor: pointer; transition: background-color 120ms ease-out, color 120ms ease-out; } .model-selector-trigger > b { min-width: 0; overflow: visible; overflow-wrap: anywhere; text-align: left; white-space: normal; font-size: var(--type-caption); font-weight: 500; line-height: 1.35; } .model-selector-trigger svg { width: 16px; height: 16px; flex: 0 0 auto; color: var(--color-ink-tertiary); transition: transform 120ms ease-out; } @@ -582,9 +640,6 @@ button:disabled { cursor: default; opacity: .45; } .model-selector-option[data-selected] .model-selector-check { opacity: 1; } @media (max-width: 900px) { - .composer-footer { grid-template-columns: minmax(0, 1fr); } - .composer-footer > p:not(.composer-notice) { display: none; } - .composer-footer > p.composer-notice { grid-column: 1; grid-row: 2; margin: 0 auto; } .model-selector-trigger { max-width: 100%; grid-column: 1; grid-row: 1; } } @@ -782,44 +837,13 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: /* Conversational rectification shell */ .conversation.is-rectification { overflow: hidden; padding-bottom: 0; } .rectification-chat { height: 100%; min-height: 0; display: flex; flex-direction: column; color: var(--color-ink); } -.rectification-message-list { - min-height: 0; - flex: 1; - overflow-y: auto; - overscroll-behavior: contain; - padding-bottom: var(--space-6); - scrollbar-color: transparent transparent; - scrollbar-gutter: stable; - scrollbar-width: thin; -} -.rectification-message-list.is-scrollbar-visible, -.rectification-message-list:focus-within { - scrollbar-color: color-mix(in srgb, var(--color-ink-tertiary) 46%, transparent) transparent; -} -.rectification-message-list::-webkit-scrollbar { width: 10px; } -.rectification-message-list::-webkit-scrollbar-track { background: transparent; } -.rectification-message-list::-webkit-scrollbar-thumb { - min-height: 40px; - border: 3px solid transparent; - border-radius: 999px; - background: transparent; - background-clip: padding-box; - transition: background-color 160ms ease; -} -.rectification-message-list.is-scrollbar-visible::-webkit-scrollbar-thumb, -.rectification-message-list:focus-within::-webkit-scrollbar-thumb { - background: color-mix(in srgb, var(--color-ink-tertiary) 42%, transparent); - background-clip: padding-box; -} -.rectification-message-list::-webkit-scrollbar-thumb:hover { - background: color-mix(in srgb, var(--color-ink-secondary) 62%, transparent); - background-clip: padding-box; -} +.rectification-message-list { min-height: 0; flex: 1; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--space-6); } .rectification-correction-target button { min-height: 44px; padding-inline: var(--space-3); border: 0; background: transparent; color: var(--color-action); cursor: pointer; } .rectification-composer-wrap { position: static; flex: 0 0 auto; } .rectification-correction-target { width: min(760px, 100%); display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); margin: 0 auto var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-caption); } .rectification-correction-target p { margin: 0; } .conversational-empty-state { width: min(760px, 100%); margin: var(--space-8) auto; color: var(--color-ink-secondary); } +.conversational-loading { min-height: min(52vh, 460px); display: grid; place-items: center; padding: var(--space-6); } @media (max-width: 430px) { .rectification-correction-target { align-items: stretch; display: grid; } @@ -1239,11 +1263,9 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: width: min(1040px, 100%); margin-left: 0; padding: var(--space-6); - border: 1px solid var(--color-border); - border-radius: var(--radius-xl); - background: var(--color-canvas-soft); - color: var(--color-ink-secondary); - font-size: var(--type-body-sm); + min-height: min(52vh, 460px); + display: grid; + place-items: center; } .starter-note { @@ -1536,3 +1558,105 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: border-radius: var(--radius-lg); } } + +/* Birth-time intake: make the next action obvious without adding another component. */ +.onboarding-card.birth-time-transition-card { max-width: 760px; padding: clamp(20px, 3vw, 32px); border-color: color-mix(in srgb, var(--color-action) 18%, var(--color-border)); border-radius: var(--radius-xl); background: var(--color-canvas); box-shadow: var(--shadow-elevated); } +.onboarding-card-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--space-6); padding-bottom: var(--space-2); } +.onboarding-card-heading-copy { display: grid; gap: 5px; } +.onboarding-card-kicker { color: var(--color-action); font-size: var(--type-overline); font-weight: 700; letter-spacing: .08em; } +.onboarding-card-heading b { font-size: clamp(22px, 2.6vw, 30px); letter-spacing: -.03em; } +.onboarding-card-heading small { max-width: 260px; color: var(--color-ink-secondary); line-height: 1.55; font-size: var(--type-caption); text-wrap: pretty; } +.birth-time-intake { gap: var(--space-6); } +.birth-time-source-fieldset { padding-top: var(--space-5); border-top: 1px solid var(--color-border); } +.birth-time-source-fieldset legend { margin-bottom: 5px; color: var(--color-ink); font-size: var(--type-title-sm); font-weight: 700; } +.birth-time-source-intro { margin: 0 0 var(--space-3); color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.55; } +.birth-time-source-list { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); } +.birth-time-source-option { min-height: 118px; grid-template-columns: 20px minmax(0, 1fr); align-items: start; gap: var(--space-3); padding: 18px; border-color: var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); transition: border-color 160ms ease-out, background-color 160ms ease-out, box-shadow 160ms ease-out, transform 160ms var(--ease-out); } +.birth-time-source-option:hover { transform: translateY(-2px); box-shadow: 0 8px 20px -18px var(--color-ink); } +.birth-time-source-option.is-selected { border-color: var(--color-action); background: var(--color-action-soft); box-shadow: inset 0 0 0 1px var(--color-action), 0 10px 22px -20px var(--color-action); } +.birth-time-source-option input { appearance: none; position: relative; width: 16px; min-width: 16px; height: 16px; min-height: 16px; margin: 3px 0 0; border: 1.5px solid var(--color-border-strong); border-radius: 50%; background: var(--color-canvas); } +.birth-time-source-option input:checked { border-color: var(--color-action); background: var(--color-action); box-shadow: inset 0 0 0 4px var(--color-action-soft); } +.birth-time-source-option > span { gap: 7px; } +.birth-time-source-option b { font-size: var(--type-title-sm); line-height: 1.3; } +.birth-time-source-option small { font-size: var(--type-body-sm); line-height: 1.55; } +.birth-time-detail-grid { grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); } +.birth-time-period-details { align-items: start; } +.birth-time-period-details > label { align-self: start; } +.birth-time-detail-grid > label { gap: 7px; } +.birth-time-detail-grid > label > span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 700; } +.birth-time-detail-grid textarea { + width: 100%; + min-width: 0; + min-height: 84px; + resize: vertical; + padding: 12px 13px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background: var(--color-canvas); + color: var(--color-ink); + line-height: 1.55; + transition: border-color 160ms ease-out, box-shadow 160ms ease-out, background-color 160ms ease-out; +} +.birth-time-detail-grid textarea:hover { + border-color: color-mix(in srgb, var(--color-action) 42%, var(--color-border-strong)); +} +.birth-time-detail-grid textarea:focus { + border-color: var(--color-action); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 16%, transparent); +} +.birth-time-detail-grid textarea::placeholder { + color: var(--color-ink-tertiary); +} +.birth-time-detail-grid textarea::-webkit-resizer { + background: color-mix(in srgb, var(--color-border-strong) 70%, transparent); +} +.birth-time-period-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 40px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); } +.birth-time-period-details .birth-time-skip-button:hover { background: color-mix(in srgb, var(--color-action-soft) 72%, transparent); } +.birth-time-detail-note[role="status"] { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid color-mix(in srgb, var(--color-action) 25%, var(--color-border)); border-left: 3px solid var(--color-action); border-radius: var(--radius-lg); background: var(--color-action-soft); } +.birth-time-detail-note[role="status"] p { margin: 0; } +.onboarding-card-actions { align-items: center; justify-content: space-between; gap: var(--space-4); padding-top: var(--space-2); } +.onboarding-card-action-hint { color: var(--color-ink-secondary); font-size: var(--type-caption); line-height: 1.4; } + +@media (max-width: 620px) { + .onboarding-card-heading { align-items: flex-start; flex-direction: column; gap: var(--space-2); } + .birth-time-source-list, .birth-time-detail-grid { grid-template-columns: 1fr; } + .birth-time-period-details .birth-time-skip-button { grid-column: auto; justify-self: stretch; } + .onboarding-card-actions { align-items: stretch; flex-direction: column; } + .onboarding-card-actions .button-primary { width: 100%; } +} + +/* shadcn-style select used in onboarding; the native controls remain untouched elsewhere. */ +.select-trigger { width: 100%; min-height: 44px; display: inline-flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 0 13px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); color: var(--color-ink); font: inherit; text-align: left; transition: border-color 160ms ease-out, box-shadow 160ms ease-out, background-color 160ms ease-out; } +.select-trigger:hover { border-color: color-mix(in srgb, var(--color-action) 42%, var(--color-border)); background: var(--color-canvas-soft); } +.select-trigger:focus-visible { border-color: var(--color-action); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 18%, transparent); } +.select-trigger[data-popup-open] { border-color: var(--color-action); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 14%, transparent); } +.select-trigger[data-placeholder] { color: var(--color-ink-tertiary); } +.select-trigger:disabled { cursor: not-allowed; opacity: .55; } +.select-trigger-icon { display: inline-flex; flex: 0 0 auto; color: var(--color-ink-secondary); transition: transform 160ms ease-out; } +.select-trigger[data-popup-open] .select-trigger-icon { transform: rotate(180deg); } +.select-positioner { z-index: 60; } +.select-content { width: var(--anchor-width); min-width: 190px; max-width: min(360px, calc(100vw - 32px)); height: auto !important; max-height: min(360px, calc(100vh - 32px)); overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas); padding: 5px; box-shadow: var(--shadow-elevated); transform-origin: var(--transform-origin); animation: select-content-in 140ms var(--ease-out) both; } +.select-list { display: grid; gap: 2px; max-height: min(320px, 50vh) !important; overflow-y: auto !important; outline: none; scrollbar-width: none; -ms-overflow-style: none; } +.select-list::-webkit-scrollbar { width: 0; height: 0; display: none; } +.select-item { min-height: 40px; position: relative; display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 9px 10px; border-radius: var(--radius-sm); color: var(--color-ink); font-size: var(--type-body-sm); line-height: 1.35; cursor: pointer; outline: none; user-select: none; } +.select-item:hover, .select-item[data-highlighted] { background: var(--color-action-soft); color: var(--color-action-hover); } +.select-item[data-selected] { color: var(--color-action-hover); font-weight: 650; } +.select-item[data-disabled] { cursor: not-allowed; opacity: .45; } +.select-item-indicator { display: inline-flex; flex: 0 0 auto; color: var(--color-action); } +@keyframes select-content-in { from { opacity: 0; transform: translateY(-3px) scale(.98); } to { opacity: 1; transform: translateY(0) scale(1); } } + +.birth-time-source-option { grid-template-columns: minmax(0, 1fr); } +.birth-time-source-option input { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; } +.birth-time-source-option:focus-within { border-color: var(--color-action); outline: none; box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 16%, transparent); } +.birth-time-source-option.is-selected:focus-within { box-shadow: inset 0 0 0 1px var(--color-action), 0 0 0 3px color-mix(in srgb, var(--color-action) 16%, transparent); } +.birth-time-source-option > span { min-width: 0; } + +/* onboarding controls */ + +.birth-time-clock-selects { width: min(100%, 250px); display: grid !important; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; gap: 8px; } +.birth-time-clock-separator { color: var(--color-ink-secondary); font-size: var(--type-title-sm); font-weight: 700; } +.birth-time-clock-selects .select-trigger { min-width: 0; } + +.birth-time-clock-menu.select-content { width: 108px; min-width: 108px; } +.birth-time-clock-menu .select-item { justify-content: flex-start; } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e1d96a1c..40eb9a11 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import dynamic from "next/dynamic"; import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react"; import { useGSAP } from "@gsap/react"; -import gsap from "gsap"; +import { gsap } from "gsap"; import { useEffect, useRef, useState } from "react"; import type { FormEvent, KeyboardEvent } from "react"; import { AppSidebar } from "@/components/app-sidebar"; @@ -13,10 +13,15 @@ import { type BirthTimeAssessmentPhase, } from "@/components/birth-time-assessment-overlay"; import { BirthTimeIntakeFields } from "@/components/birth-time-intake"; +import { AppLoadingIndicator } from "@/components/app-loading-indicator"; import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification"; import { ChatMessageContent } from "@/components/chat-message-content"; import { AgentAvatar, ChatMessageRow } from "@/components/chat-message-row"; import { ModelSelector } from "@/components/model-selector"; +import { + LocationSearchCombobox, + type ResolvedBirthLocation, +} from "@/components/location-search-combobox"; import { Button } from "@/components/ui/button"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { Textarea } from "@/components/ui/textarea"; @@ -48,17 +53,19 @@ import { type RectificationCardAction, } from "@/lib/birth-time-consultation-consent"; import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode"; -import { sendConversationalRectificationCommand } from "@/lib/conversational-rectification/client"; -import type { - ConversationalRectificationResponse, - ConversationalRectificationTurn, -} from "@/lib/conversational-rectification/contracts"; +import { + ConversationalRectificationRequestError, + conversationalRectificationHistoryForTurn, + sendConversationalRectificationCommand, +} from "@/lib/conversational-rectification/client"; +import type { ConversationalRectificationTurn } from "@/lib/conversational-rectification/contracts"; 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"; import { requestBirthTimeAssessment, type JourneyClientResponse, @@ -92,7 +99,6 @@ import { resolveSessionModelId, type PublicLanguageModelCatalog, } from "@/lib/public-models"; -import { selfHostedOtpActions } from "@/modules/identity/client"; import { createBrowserSupabaseClient } from "@/lib/supabase/client"; gsap.registerPlugin(useGSAP); @@ -109,10 +115,19 @@ type Theme = ReplyTheme; type Message = ChatMessage; type Profile = BirthTimeDraft & { name: string; - countryCode: "CN"; + countryCode: string; provinceCode: string; cityCode: string; districtCode: string; + birthPlaceLabel: string; + birthPlaceType: string; + birthPlaceProvider: string; + birthPlaceProviderId: string; + timezoneId: string; + timezoneSource: string; + latitude: number | null; + longitude: number | null; + timezoneOffset: number | null; rectificationCaseId: string; }; type ChartLibraryRecord = { @@ -158,14 +173,26 @@ type ChatSession = { sessionType: ChatSessionType; rectificationCaseId: string | null; }; + +function durableRectificationMessages( + messages: readonly ConversationalRectificationMessage[], +): Message[] { + const durable = messages.map(({ role, text }) => ({ role, text })); + if (durable.length <= 500) return durable; + return [durable[0]!, ...durable.slice(-(500 - 1))]; +} type RequestError = { sessionId: string; message: string }; type StreamingReply = { sessionId: string; text: string }; -type BirthPlace = { label: string; lat: number; lon: number; tz: number }; +type BirthPlace = { + label: string; + lat: number; + lon: number; + tz: number | null; + timezoneId: string; +}; type Account = { user: { id: string; email: string | null }; credits: number; - profile: unknown; - authProvider: "supabase" | "self-hosted"; isAdmin: boolean; rectificationPriceCredits: number; hasConfirmedBirthTime: boolean; @@ -266,6 +293,15 @@ const emptyProfile: Profile = { provinceCode: "", cityCode: "", districtCode: "", + birthPlaceLabel: "", + birthPlaceType: "", + birthPlaceProvider: "", + birthPlaceProviderId: "", + timezoneId: "", + timezoneSource: "", + latitude: null, + longitude: null, + timezoneOffset: null, }; function timestamp() { @@ -297,6 +333,20 @@ function findCity(province: ProvinceNode | undefined, code: string) { } function selectedBirthPlace(profile: Profile): BirthPlace | null { + if (profile.birthPlaceLabel + && Number.isFinite(profile.latitude) + && Number.isFinite(profile.longitude) + && profile.timezoneId.trim() + && (profile.timezoneOffset === null || Number.isFinite(profile.timezoneOffset))) { + return { + label: profile.birthPlaceLabel, + lat: profile.latitude as number, + lon: profile.longitude as number, + tz: profile.timezoneOffset, + timezoneId: profile.timezoneId, + }; + } + const province = findProvince(profile.provinceCode); const city = findCity(province, profile.cityCode); if (!province || !city) return null; @@ -309,7 +359,35 @@ function selectedBirthPlace(profile: Profile): BirthPlace | null { .filter((name, index, names) => Boolean(name) && names.indexOf(name) === index) .join(" · "); - return { label, lat: location.center[1], lon: location.center[0], tz: china.timezone }; + return { + label, + lat: location.center[1], + lon: location.center[0], + tz: china.timezone, + timezoneId: "Asia/Shanghai", + }; +} + +function selectedLocationValue(profile: Profile): ResolvedBirthLocation | null { + const birthPlace = selectedBirthPlace(profile); + if (!birthPlace) return null; + return { + id: profile.birthPlaceProviderId || `legacy-cn:${profile.provinceCode}:${profile.cityCode}:${profile.districtCode}`, + label: birthPlace.label, + placeType: profile.birthPlaceType || "legacy_china_admin", + provider: profile.birthPlaceProvider || "legacy_china_locations", + providerPlaceId: profile.birthPlaceProviderId, + countryCode: profile.countryCode || "CN", + countryName: profile.countryCode === "CN" ? "中国" : "", + admin1: "", + admin2: "", + locality: "", + latitude: birthPlace.lat, + longitude: birthPlace.lon, + timezoneId: birthPlace.timezoneId, + timezoneOffset: birthPlace.tz, + timezoneSource: profile.timezoneSource || "legacy_fixed_offset", + }; } function chartLibraryStorageKey(accountId: string) { @@ -527,6 +605,15 @@ function readProfile(value: unknown): Profile { province_code?: unknown; city_code?: unknown; district_code?: unknown; + birth_place_label?: unknown; + birth_place_type?: unknown; + birth_place_provider?: unknown; + birth_place_provider_id?: unknown; + timezone_id?: unknown; + timezone_source?: unknown; + latitude?: unknown; + longitude?: unknown; + timezone_offset?: unknown; }; const date = typeof profile.birth_date === "string" ? profile.birth_date : profile.date; const legacyTime = typeof profile.birth_time === "string" ? profile.birth_time.slice(0, 5) : profile.time; @@ -550,6 +637,20 @@ function readProfile(value: unknown): Profile { const provinceCode = typeof profile.province_code === "string" ? profile.province_code : profile.provinceCode; const cityCode = typeof profile.city_code === "string" ? profile.city_code : profile.cityCode; const districtCode = typeof profile.district_code === "string" ? profile.district_code : profile.districtCode; + const countryCode = typeof profile.country_code === "string" ? profile.country_code : profile.countryCode; + const birthPlaceLabel = typeof profile.birth_place_label === "string" ? profile.birth_place_label : profile.birthPlaceLabel; + const birthPlaceType = typeof profile.birth_place_type === "string" ? profile.birth_place_type : profile.birthPlaceType; + const birthPlaceProvider = typeof profile.birth_place_provider === "string" ? profile.birth_place_provider : profile.birthPlaceProvider; + const birthPlaceProviderId = typeof profile.birth_place_provider_id === "string" ? profile.birth_place_provider_id : profile.birthPlaceProviderId; + const timezoneId = typeof profile.timezone_id === "string" ? profile.timezone_id : profile.timezoneId; + const timezoneSource = typeof profile.timezone_source === "string" ? profile.timezone_source : profile.timezoneSource; + const latitude = typeof profile.latitude === "number" && Number.isFinite(profile.latitude) ? profile.latitude : null; + const longitude = typeof profile.longitude === "number" && Number.isFinite(profile.longitude) ? profile.longitude : null; + const timezoneOffset = typeof profile.timezone_offset === "number" && Number.isFinite(profile.timezone_offset) + ? profile.timezone_offset + : typeof profile.timezoneOffset === "number" && Number.isFinite(profile.timezoneOffset) + ? profile.timezoneOffset + : null; return { name: typeof profile.name === "string" ? profile.name.slice(0, 80) : "", @@ -563,10 +664,19 @@ function readProfile(value: unknown): Profile { uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null, birthTimeStatus: status, rectificationCaseId: typeof profile.rectification_case_id === "string" ? profile.rectification_case_id : "", - countryCode: "CN", + countryCode: typeof countryCode === "string" && countryCode ? countryCode : "CN", provinceCode: typeof provinceCode === "string" ? provinceCode : "", cityCode: typeof cityCode === "string" ? cityCode : "", districtCode: typeof districtCode === "string" ? districtCode : "", + birthPlaceLabel: typeof birthPlaceLabel === "string" ? birthPlaceLabel : "", + birthPlaceType: typeof birthPlaceType === "string" ? birthPlaceType : "", + birthPlaceProvider: typeof birthPlaceProvider === "string" ? birthPlaceProvider : "", + birthPlaceProviderId: typeof birthPlaceProviderId === "string" ? birthPlaceProviderId : "", + timezoneId: typeof timezoneId === "string" ? timezoneId : "", + timezoneSource: typeof timezoneSource === "string" ? timezoneSource : "", + latitude, + longitude, + timezoneOffset, }; } @@ -624,25 +734,46 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null } function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) { - const province = findProvince(value.provinceCode); - const cities = province?.cities ?? []; - const city = findCity(province, value.cityCode); - const districts = city?.districts ?? []; - return (
出生地点 -
- - - - -
+ onChange({ + ...value, + countryCode: location?.countryCode || "CN", + provinceCode: "", + cityCode: "", + districtCode: "", + birthPlaceLabel: location?.label || "", + birthPlaceType: location?.placeType || "", + birthPlaceProvider: location?.provider || "", + birthPlaceProviderId: location?.providerPlaceId || "", + timezoneId: location?.timezoneId || "", + timezoneSource: location?.timezoneSource || "", + latitude: location?.latitude ?? null, + longitude: location?.longitude ?? null, + timezoneOffset: location?.timezoneOffset ?? null, + })} + />
); } -const birthLocationKeys = ["countryCode", "provinceCode", "cityCode", "districtCode"] as const; +const birthLocationKeys = [ + "countryCode", + "provinceCode", + "cityCode", + "districtCode", + "birthPlaceLabel", + "birthPlaceProviderId", + "timezoneId", + "latitude", + "longitude", + "timezoneOffset", +] as const; function birthProfileDeclarationChanged(current: Profile, next: Profile) { return declaredBirthInputChanged(current, next) @@ -752,32 +883,6 @@ async function fetchModelCatalog(signal?: AbortSignal) { return parsePublicModelCatalog(payload); } -async function fetchSessions(signal?: AbortSignal) { - const response = await fetch("/api/sessions", { signal, cache: "no-store" }); - if (response.status === 401) { - window.location.assign("/login"); - throw new Error("请先登录"); - } - const payload = await response.json().catch(() => null) as { sessions?: unknown; error?: string } | null; - if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); - return payload?.sessions ?? []; -} - -async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) { - const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - signal, - body: JSON.stringify({ model_id: modelId }), - }); - const payload = await response.json().catch(() => null); - return { - found: response.ok, - error: response.ok ? null : payloadMessage(payload, "模型选择暂时无法同步"), - }; -} - export default function Home() { const [profile, setProfile] = useState(emptyProfile); const [profileDraft, setProfileDraft] = useState(emptyProfile); @@ -821,8 +926,7 @@ export default function Home() { ); const [rectificationSessionId, setRectificationSessionId] = useState(null); const [rectificationReturnSessionId, setRectificationReturnSessionId] = useState(null); - const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); - const [rectificationOpeningAssistantText, setRectificationOpeningAssistantText] = useState(""); + const [rectificationInitialTurn, setRectificationInitialTurn] = useState(null); const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState(null); const [rectificationLoading, setRectificationLoading] = useState(false); const [rectificationMutationPending, setRectificationMutationPending] = useState(false); @@ -856,6 +960,7 @@ export default function Home() { const stoppedRequestAwaitingSettlement = useRef(null); const stoppedSessionPersistence = useRef(new Map>()); const modelPersistence = useRef(new SessionModelPersistenceQueue()); + const rectificationPersistence = useRef(new SessionModelPersistenceQueue()); const modelSyncFailures = useRef(new Set()); const modelSelectionVersions = useRef(new Map()); const activeSessionIdRef = useRef(""); @@ -1071,6 +1176,7 @@ export default function Home() { const previewProfile: Profile = previewMode === "onboarding" ? emptyProfile : { + ...emptyProfile, name: "林遥", date: "1990-06-15", time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30", @@ -1114,8 +1220,6 @@ export default function Home() { setAccount({ user: { id: "preview-user", email: "preview@local.test" }, credits: 8, - profile: previewProfile, - authProvider: "self-hosted", isAdmin: false, rectificationPriceCredits: 1, hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", @@ -1142,7 +1246,16 @@ export default function Home() { return; } - const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([ + const supabase = createBrowserSupabaseClient(); + const { data: authData, error: authError } = await supabase.auth.getSession(); + if (authError) throw authError; + if (controller.signal.aborted) return; + if (!authData.session) { + window.location.assign("/login"); + return; + } + + const [nextAccount, modelCatalogResult] = await Promise.all([ fetchAccount(controller.signal), fetchModelCatalog(controller.signal) .then((catalog) => ({ catalog, unavailable: false })) @@ -1150,30 +1263,50 @@ export default function Home() { if (caught instanceof Error && caught.name === "AbortError") throw caught; return { catalog: null, unavailable: true }; }), - fetchSessions(controller.signal), ]); const nextModelCatalog = modelCatalogResult.catalog; - const parsedSessions = readSessions(sessionsPayload, nextModelCatalog); + const [profileResult, sessionsResult] = await Promise.all([ + supabase + .from("profiles") + .select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,latitude,longitude,timezone_id,timezone_offset,timezone_source") + .eq("id", nextAccount.user.id) + .abortSignal(controller.signal) + .maybeSingle(), + supabase + .from("chat_sessions") + .select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at") + .abortSignal(controller.signal) + .order("updated_at", { ascending: false }), + ]); + + if (profileResult.error) throw profileResult.error; + if (sessionsResult.error) throw sessionsResult.error; + + const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog); let nextSessions = parsedSessions.sessions; if (nextSessions.length === 0) { if (controller.signal.aborted) return; const initialSession = createSession(nextModelCatalog?.defaultModelId ?? ""); - if (initialSession.modelId) { - await writeChatSession(initialSession.id, { + const { error } = await supabase + .from("chat_sessions") + .insert({ + id: initialSession.id, + user_id: nextAccount.user.id, title: initialSession.title, theme: initialSession.theme, - model_id: initialSession.modelId, + model_id: initialSession.modelId || null, messages: initialSession.messages, session_type: initialSession.sessionType, rectification_case_id: initialSession.rectificationCaseId, updated_at: new Date(initialSession.updatedAt).toISOString(), - }, "create"); - } + }) + .abortSignal(controller.signal); + if (error) throw error; nextSessions = [initialSession]; } if (controller.signal.aborted) return; - const nextProfile = readProfile(nextAccount.profile); + const nextProfile = readProfile(profileResult.data); setAccount(nextAccount); setModelCatalog(nextModelCatalog); setProfile(nextProfile); @@ -1190,9 +1323,13 @@ export default function Home() { setAccountError(""); if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) { - const results = await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) => - patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal))); - if (results.some((result) => result.error) && !controller.signal.aborted) { + const { error } = await supabase + .from("chat_sessions") + .update({ model_id: nextModelCatalog.defaultModelId }) + .eq("user_id", nextAccount.user.id) + .in("id", parsedSessions.fallbackSessionIds) + .abortSignal(controller.signal); + if (error && !controller.signal.aborted) { setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); } } @@ -1499,8 +1636,14 @@ export default function Home() { if (process.env.NODE_ENV === "development" && uiPreview.current) { return { found: true, error: null }; } - void ownerId; - return patchSessionModel(sessionId, values.model_id); + const { data, error } = await createBrowserSupabaseClient() + .from("chat_sessions") + .update(values) + .eq("id", sessionId) + .eq("user_id", ownerId) + .select("id") + .maybeSingle(); + return { found: Boolean(data), error: error?.message ?? null }; }, userId, nextSession.id, @@ -1569,9 +1712,15 @@ export default function Home() { province_code: nextProfile.provinceCode || null, city_code: nextProfile.cityCode || null, district_code: nextProfile.districtCode || null, + birth_place_label: nextProfile.birthPlaceLabel || null, + birth_place_type: nextProfile.birthPlaceType || null, + birth_place_provider: nextProfile.birthPlaceProvider || null, + birth_place_provider_id: nextProfile.birthPlaceProviderId || null, latitude: birthPlace?.lat ?? null, longitude: birthPlace?.lon ?? null, + timezone_id: nextProfile.timezoneId || null, timezone_offset: birthPlace?.tz ?? null, + timezone_source: nextProfile.timezoneSource || null, }), }); if (!response.ok) { @@ -1836,12 +1985,8 @@ export default function Home() { setSigningOut(true); setAccountError(""); try { - if (account?.authProvider === "self-hosted") { - await selfHostedOtpActions.signOut(); - } else { - const { error } = await createBrowserSupabaseClient().auth.signOut(); - if (error) throw error; - } + const { error } = await createBrowserSupabaseClient().auth.signOut(); + if (error) throw error; window.location.assign("/login"); } catch (caught) { const message = caught instanceof Error ? caught.message : "退出失败"; @@ -1910,32 +2055,22 @@ export default function Home() { || 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 + const resumableSession = action === "resume" && account.rectificationCase ? sessions.find((session) => session.sessionType === "birth_time_rectification" - && session.rectificationCaseId === accountResumeCase.caseId) + && session.rectificationCaseId === account.rectificationCase?.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 canReuseSourceRectificationSession = action === "resume" + && sourceSession.sessionType === "birth_time_rectification" + && account.rectificationCase !== null + && (sourceSession.rectificationCaseId === account.rectificationCase.caseId + || sourceSession.rectificationCaseId === null); const rectificationSession = canReuseSourceRectificationSession ? sourceSession : resumableSession ?? createSession(modelCatalog.defaultModelId, "birth_time_rectification"); @@ -1952,7 +2087,6 @@ export default function Home() { setDraftEntrypoint(null); setRectificationPendingQuestion(requestedQuestion); setRectificationInitialTurn(null); - setRectificationOpeningAssistantText(""); setRectificationError(""); setRectificationLoading(true); if (!reusingRectificationSession) { @@ -1961,32 +2095,46 @@ export default function Home() { ...current.filter((session) => session.id !== rectificationSession.id), ]); } - if (sourceSession.id !== rectificationSession.id) { + if (sourceSession && sourceSession.id !== rectificationSession.id) { setRectificationReturnSessionId(sourceSession.id); } setRectificationSessionId(rectificationSession.id); activeSessionIdRef.current = rectificationSession.id; setActiveSessionId(rectificationSession.id); try { - let turn: ConversationalRectificationResponse; - if (!resumeTarget) { + let turn: ConversationalRectificationTurn; + if (action !== "resume" || !account.rectificationCase) { const durable = await durableRectificationQuestionHandoff.current.load(); - turn = durable && durable.status !== "consumed" - ? durable.turn - : await sendConversationalRectificationCommand({ + 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, + }); + } + } } else { - let current = resumeTarget; - const canAttachQuestion = pendingConsultationQuestion - && accountResumeCase?.caseId === current.caseId; - if (canAttachQuestion) { + let current = account.rectificationCase; + if (pendingConsultationQuestion) { try { turn = await durableRectificationQuestionHandoff.current.attach({ caseId: current.caseId, @@ -2008,22 +2156,46 @@ export default function Home() { }); } } else { - turn = await sendConversationalRectificationCommand({ - type: "resume", - caseId: current.caseId, - actionId: globalThis.crypto.randomUUID(), - turnVersion: current.turnVersion, - }, { - onNarrativeDelta(text) { - setRectificationOpeningAssistantText((value) => value + text); - }, - }); + try { + turn = await sendConversationalRectificationCommand({ + type: "resume", + caseId: current.caseId, + actionId: globalThis.crypto.randomUUID(), + turnVersion: current.turnVersion, + }); + } 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, + }); + } } } - const boundSession = rectificationSession.rectificationCaseId === turn.caseId - ? rectificationSession - : { ...rectificationSession, rectificationCaseId: turn.caseId, updatedAt: timestamp() }; + 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(), + }; let sessionSyncFailed = false; if (!reusingRectificationSession) { try { @@ -2041,7 +2213,6 @@ export default function Home() { } } setRectificationInitialTurn(turn); - setRectificationOpeningAssistantText(""); synchronizeRectificationQuestion(turn, sourceSession); setComposerNotice(sessionSyncFailed ? "校正已经开始,但会话关联暂时未同步到云端。" : ""); } catch (caught) { @@ -2049,13 +2220,14 @@ export default function Home() { ? caught.message : "生时校正暂时无法继续,请稍后重试。"; setRectificationError(message); - setRectificationOpeningAssistantText(""); setComposerNotice(message); if (!reusingRectificationSession) { setSessions((current) => current.filter((session) => session.id !== rectificationSession.id)); setRectificationSessionId(null); - activeSessionIdRef.current = sourceSession.id; - setActiveSessionId(sourceSession.id); + if (sourceSession) { + activeSessionIdRef.current = sourceSession.id; + setActiveSessionId(sourceSession.id); + } } } finally { rectificationOpenInFlight.current = false; @@ -2067,19 +2239,25 @@ export default function Home() { void openBirthTimeRectification(null, session); }; - function handleConversationalRectificationTurn(turn: ConversationalRectificationResponse) { + function handleConversationalRectificationTurn( + turn: ConversationalRectificationTurn, + messages: readonly ConversationalRectificationMessage[], + ) { const requestIdentity = accountRefreshGuard.current.begin(); setRectificationInitialTurn(turn); synchronizeRectificationQuestion(turn); - if (activeSession?.sessionType === "birth_time_rectification" - && activeSession.rectificationCaseId !== turn.caseId) { + if (activeSession?.sessionType === "birth_time_rectification") { const boundSession = { ...activeSession, + messages: durableRectificationMessages(messages), rectificationCaseId: turn.caseId, updatedAt: timestamp(), }; updateSession(activeSession.id, () => boundSession); - void persistSession(boundSession).catch(() => { + void rectificationPersistence.current.enqueue( + boundSession.id, + () => persistSession(boundSession), + ).catch(() => { setComposerNotice("校正进度已经保留,但会话关联暂时未同步到云端。"); }); } @@ -2087,22 +2265,16 @@ export default function Home() { ...current, hasConfirmedBirthTime: current.hasConfirmedBirthTime || (turn.status === "completed" && turn.candidate.status === "confirmed"), - rectificationCase: ["active", "paused", "confirming"].includes(turn.status) - ? { - caseId: turn.caseId, - journeyProtocol: "conversational-evidence-v3", - status: turn.status, - turnVersion: turn.turnVersion, - isRevision: current.rectificationCase?.caseId === turn.caseId - ? current.rectificationCase.isRevision - : current.hasConfirmedBirthTime, - preservesActiveTime: current.rectificationCase?.caseId === turn.caseId - ? current.rectificationCase.preservesActiveTime - : current.hasConfirmedBirthTime, - } - : current.rectificationCase?.caseId === turn.caseId - ? null - : current.rectificationCase, + rectificationCase: { + caseId: turn.caseId, + journeyProtocol: "conversational-evidence-v3", + status: turn.status, + turnVersion: turn.turnVersion, + isRevision: current.rectificationCase?.isRevision + ?? current.hasConfirmedBirthTime, + preservesActiveTime: current.rectificationCase?.preservesActiveTime + ?? current.hasConfirmedBirthTime, + }, } : current); if (turn.status === "completed" && turn.candidate.status === "confirmed" @@ -2124,7 +2296,6 @@ export default function Home() { .then((latest) => { if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; setAccount((current) => { - if (turn.status === "completed" || turn.status === "abandoned") return latest; if (latest.rectificationCase?.caseId !== turn.caseId || latest.rectificationCase.turnVersion < turn.turnVersion) return current; return latest; @@ -2790,14 +2961,7 @@ export default function Home() { if (!hydrated || (!account && !accountError)) { return (
-
- - 正在和星星对口供 - 顺便同步你的账户与对话记录 -
+
); } @@ -2932,11 +3096,18 @@ export default function Home() {
-
出生时间按你实际知道的程度填写,不需要猜测
+
+
+ 先完成出生资料 + 出生日期与时间 +
+
setProfileDraft((current) => applyBirthTimeDraftPatch(current, patch))} /> {accountError &&

{accountError}

} -
+
+ +
@@ -2948,7 +3119,7 @@ export default function Home() {
-
出生地点目前先支持中国大陆地区
+
出生地点搜索全球城市或区县,系统会匹配出生当日的时区。
{accountError &&

{accountError}

} @@ -2983,7 +3154,9 @@ export default function Home() { {!profileComplete && onboardingStep === "name" && accountError &&

{accountError}

} {profileComplete && presetMessageFinished && !rectificationSurfaceOpen && (onboardingPending ? ( -
正在准备三个入门问题…
+
+ +
) : (
@@ -3096,7 +3269,10 @@ export default function Home() { ) : ( void selectSessionModel(modelId)} pendingConsultationQuestion={rectificationPendingQuestion} continuationPending={rectificationContinuationPending} onPendingChange={setRectificationMutationPending} @@ -3163,11 +3339,11 @@ export default function Home() { disabled={!activeSession || isLoading || cancellationPending || creatingSession} onSelect={(modelId) => void selectSessionModel(modelId)} /> -

{composerNotice || (consultationPhase === "undo" - ? "已加入发送队列,2.5 秒内可免费撤回。" - : !profileComplete - ? onboardingStep === "name" ? "Enter 确认称呼" : onboardingStep === "rectification" ? "生时校正为可选增强" : "请先完成上方资料" - : "Enter 发送 · Shift + Enter 换行")}

+ {(composerNotice || consultationPhase === "undo" || (!profileComplete && onboardingStep !== "name")) && ( +

{composerNotice || (consultationPhase === "undo" + ? "已加入发送队列,2.5 秒内可免费撤回。" + : onboardingStep === "rectification" ? "生时校正为可选增强" : "请先完成上方资料")}

+ )}
} diff --git a/frontend/src/components/app-loading-indicator.tsx b/frontend/src/components/app-loading-indicator.tsx new file mode 100644 index 00000000..53036cba --- /dev/null +++ b/frontend/src/components/app-loading-indicator.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; + +type AppLoadingIndicatorProps = Readonly<{ + title: string; + detail: ReactNode; + className?: string; +}>; + +export function AppLoadingIndicator({ title, detail, className = "" }: AppLoadingIndicatorProps) { + return ( +
+ + {title} + {detail} +
+ ); +} diff --git a/frontend/src/components/birth-time-assessment-overlay.tsx b/frontend/src/components/birth-time-assessment-overlay.tsx index 4496f734..3d60f66a 100644 --- a/frontend/src/components/birth-time-assessment-overlay.tsx +++ b/frontend/src/components/birth-time-assessment-overlay.tsx @@ -1,3 +1,5 @@ +import { AppLoadingIndicator } from "./app-loading-indicator.tsx"; + export type BirthTimeAssessmentPhase = "saving_profile" | "assessing" | "entering_home"; const progressCopy = { @@ -26,14 +28,11 @@ export function BirthTimeAssessmentOverlay({ phase }: { readonly phase: BirthTim role="status" aria-live="polite" > -
- - {copy.title} - {copy.detail} -
+
); } diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx index 52da3b2b..7ee33272 100644 --- a/frontend/src/components/birth-time-intake.tsx +++ b/frontend/src/components/birth-time-intake.tsx @@ -1,7 +1,8 @@ "use client"; -import { useId } from "react"; +import { useId, useState } from "react"; import { BirthDatePicker } from "@/components/birth-date-picker"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent"; import { birthTimeDisplayState, @@ -17,6 +18,54 @@ type BirthTimeIntakeProps = { readonly onPatch: (patch: BirthTimeDraftPatch) => void; }; + +const hours = Array.from({ length: 24 }, (_, index) => String(index).padStart(2, "0")); +const minutes = Array.from({ length: 60 }, (_, index) => String(index).padStart(2, "0")); + +function splitBirthTime(value: string) { + const [hour = "", minute = ""] = value.split(":"); + return { hour, minute }; +} + +type BirthClockSelectProps = { + readonly value: string; + readonly onChange: (value: string) => void; +}; + +function BirthClockSelect({ value, onChange }: BirthClockSelectProps) { + const currentTime = value || ""; + const [timeParts, setTimeParts] = useState(() => splitBirthTime(currentTime)); + + const patchTime = (part: "hour" | "minute", nextValue: string | null) => { + if (nextValue === null) return; + const nextParts = { ...timeParts, [part]: nextValue }; + setTimeParts(nextParts); + if (nextParts.hour && nextParts.minute) onChange(`${nextParts.hour}:${nextParts.minute}`); + }; + + return ( +
+ + + +
+ ); +} + const sourceDefaults = { hospital_record: { birthTimePeriod: "", @@ -99,7 +148,8 @@ export function BirthTimeIntakeFields({ value, onPatch }: BirthTimeIntakeProps) )} {!isConfirmed &&
- 你是否知道准确出生时间? + 你对出生时间了解多少? +

不用猜具体分钟,先选一个最符合你的情况的选项。

{birthTimeSourceOptions.map((option) => (
)} {source === "period_only" && ( -
+
+ + + +
+ )} +
))} {submission && turn.turnVersion === submission.turnVersion && ( )} - {controller.pending && canAnswer && ( - controller.streamingAssistantText - ? - : + {controller.pending && canAnswer && regeneratingMessageKey === null && ( + )} - {turn.status === "paused" && } - {turn.status === "abandoned" && } - {turn.status === "completed" && } {controller.error &&

{controller.error}

} +
{(canAnswer || canConfirm || canContinue) &&
@@ -262,9 +283,12 @@ export function ConversationalRectificationSurface({ )}
-

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

+
}
} @@ -273,11 +297,17 @@ export function ConversationalRectificationSurface({ } type ConversationalBirthTimeRectificationProps = Readonly<{ - initialTurn?: ConversationalRectificationResponse | null; - openingAssistantText?: string; + initialTurn?: ConversationalRectificationTurn | null; + initialMessages?: readonly ConversationalRectificationStoredMessage[]; + models: readonly PublicLanguageModel[]; + selectedModelId: string; + onSelectModel: (modelId: string) => void; pendingConsultationQuestion?: string | null; continuationPending?: boolean; - onTurn?: (turn: ConversationalRectificationResponse) => void; + onTurn?: ( + turn: ConversationalRectificationTurn, + messages: readonly ConversationalRectificationMessage[], + ) => void; onPendingChange?: (pending: boolean) => void; onContinueOriginalQuestion?: (question: string) => void; }>; @@ -290,13 +320,17 @@ export function ConversationalBirthTimeRectification(props: ConversationalBirthT useEffect(() => () => pendingChange.current?.(false), []); const controller = useConversationalRectification({ initialTurn: props.initialTurn, + initialMessages: props.initialMessages, + modelId: props.selectedModelId, onTurn: props.onTurn, onPendingChange: props.onPendingChange, }); return ( void; +}; + +type SearchState = "idle" | "loading" | "ready" | "empty" | "error"; + +function cleanString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +export function parseLocationSearchResults(payload: unknown): ResolvedBirthLocation[] { + const rawResults = Array.isArray(payload) + ? payload + : payload && typeof payload === "object" && Array.isArray((payload as { results?: unknown }).results) + ? (payload as { results: unknown[] }).results + : payload && typeof payload === "object" && Array.isArray((payload as { locations?: unknown }).locations) + ? (payload as { locations: unknown[] }).locations + : []; + + return rawResults.flatMap((candidate): ResolvedBirthLocation[] => { + if (!candidate || typeof candidate !== "object") return []; + const item = candidate as Record; + const latitude = typeof item.latitude === "number" ? item.latitude : Number.NaN; + const longitude = typeof item.longitude === "number" ? item.longitude : Number.NaN; + const timezoneOffset = item.timezoneOffset === null + ? null + : typeof item.timezoneOffset === "number" + ? item.timezoneOffset + : Number.NaN; + const provider = cleanString(item.provider); + const providerPlaceId = cleanString(item.providerPlaceId); + const id = cleanString(item.id) || (provider && providerPlaceId ? `${provider}:${providerPlaceId}` : ""); + const label = cleanString(item.label); + const countryCode = cleanString(item.countryCode).toUpperCase(); + const timezoneId = cleanString(item.timezoneId); + if (!id || !label || !countryCode || !timezoneId + || !Number.isFinite(latitude) || latitude < -90 || latitude > 90 + || !Number.isFinite(longitude) || longitude < -180 || longitude > 180 + || (timezoneOffset !== null + && (!Number.isFinite(timezoneOffset) || timezoneOffset < -12 || timezoneOffset > 14))) return []; + + return [{ + id, + label, + placeType: cleanString(item.placeType), + provider, + providerPlaceId, + countryCode, + countryName: cleanString(item.countryName), + admin1: cleanString(item.admin1) || cleanString(item.regionName), + admin2: cleanString(item.admin2) || cleanString(item.districtName), + locality: cleanString(item.locality) || cleanString(item.localityName), + latitude, + longitude, + timezoneId, + timezoneOffset, + timezoneSource: cleanString(item.timezoneSource), + }]; + }); +} + +export function LocationSearchCombobox({ + value, + birthDate = "", + birthTime = "", + disabled = false, + onChange, +}: LocationSearchComboboxProps) { + const inputId = useId(); + const listboxId = `${inputId}-listbox`; + const statusId = `${inputId}-status`; + const requestSequence = useRef(0); + const [query, setQuery] = useState(value?.label ?? ""); + const [results, setResults] = useState([]); + const [state, setState] = useState("idle"); + const [expanded, setExpanded] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + + useEffect(() => { + const normalized = query.trim(); + if (disabled || value || normalized.length < 2) return; + + const sequence = requestSequence.current + 1; + requestSequence.current = sequence; + const controller = new AbortController(); + const timer = window.setTimeout(async () => { + setState("loading"); + setExpanded(true); + try { + const search = new URLSearchParams({ q: normalized, locale: "zh-CN" }); + if (birthDate) search.set("birthDate", birthDate); + if (birthDate && birthTime) search.set("birthTime", birthTime); + const response = await fetch(`/api/locations/search?${search.toString()}`, { + credentials: "same-origin", + signal: controller.signal, + }); + if (!response.ok) throw new Error("location_search_failed"); + const nextResults = parseLocationSearchResults(await response.json()); + if (requestSequence.current !== sequence) return; + setResults(nextResults); + setState(nextResults.length > 0 ? "ready" : "empty"); + setActiveIndex(nextResults.length > 0 ? 0 : -1); + } catch { + if (controller.signal.aborted || requestSequence.current !== sequence) return; + setResults([]); + setState("error"); + setActiveIndex(-1); + } + }, 260); + + return () => { + window.clearTimeout(timer); + controller.abort(); + }; + }, [birthDate, birthTime, disabled, query, value]); + + function choose(location: ResolvedBirthLocation) { + requestSequence.current += 1; + setQuery(location.label); + setExpanded(false); + setResults([]); + setState("idle"); + setActiveIndex(-1); + onChange(location); + } + + function clear() { + requestSequence.current += 1; + setQuery(""); + setExpanded(false); + setResults([]); + setState("idle"); + setActiveIndex(-1); + onChange(null); + } + + function handleKeyDown(event: KeyboardEvent) { + if (value && (event.key === "Backspace" || event.key === "Delete")) { + event.preventDefault(); + clear(); + return; + } + if (!expanded || results.length === 0) { + if (event.key === "ArrowDown" && results.length > 0) setExpanded(true); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setActiveIndex((current) => (current + 1) % results.length); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActiveIndex((current) => (current <= 0 ? results.length - 1 : current - 1)); + } else if (event.key === "Enter" && activeIndex >= 0) { + event.preventDefault(); + choose(results[activeIndex]); + } else if (event.key === "Escape") { + event.preventDefault(); + setExpanded(false); + setActiveIndex(-1); + } + } + + const isExpanded = expanded && !disabled && !value && query.trim().length >= 2; + const activeDescendant = isExpanded && activeIndex >= 0 + ? `${listboxId}-option-${activeIndex}` + : undefined; + const statusText = state === "loading" + ? "正在搜索地点" + : state === "empty" + ? "没有找到匹配地点,请尝试城市、区县或英文地名" + : state === "error" + ? "地点搜索暂时不可用,请稍后重试" + : state === "ready" + ? `找到 ${results.length} 个地点` + : value + ? `已选择 ${value.label}` + : "输入至少两个字开始搜索"; + + return ( +
+ +
+ {state === "loading" ?
+ +

+ {value ?

+ + {isExpanded && ( +
    + {state === "loading" &&
  • 正在查找与出生日期对应的地点和时区…
  • } + {state === "empty" &&
  • 没有匹配结果。可以尝试更完整的城市名或英文拼写。
  • } + {state === "error" &&
  • 搜索暂时失败,请检查网络后修改关键词重试。
  • } + {state === "ready" && results.map((location, index) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx new file mode 100644 index 00000000..50db1665 --- /dev/null +++ b/frontend/src/components/ui/select.tsx @@ -0,0 +1,79 @@ +"use client"; + +import * as React from "react"; +import { Check, ChevronDown } from "lucide-react"; +import { Select as SelectPrimitive } from "@base-ui/react/select"; + +import { cn } from "@/lib/utils"; + +const Select = SelectPrimitive.Root; + +const SelectGroup = SelectPrimitive.Group; + +const SelectValue = SelectPrimitive.Value; + +const SelectTrigger = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + {children} + + + ), +); +SelectTrigger.displayName = "SelectTrigger"; + +const SelectContent = React.forwardRef>( + ({ className, children, sideOffset = 6, align = "start", alignItemWithTrigger = false, ...props }, ref) => ( + + + + {children} + + + + ), +); +SelectContent.displayName = "SelectContent"; + +const SelectItem = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + {children} + + + + ), +); +SelectItem.displayName = "SelectItem"; + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +}; diff --git a/frontend/src/hooks/use-conversational-rectification.ts b/frontend/src/hooks/use-conversational-rectification.ts index 13571384..ca77b40f 100644 --- a/frontend/src/hooks/use-conversational-rectification.ts +++ b/frontend/src/hooks/use-conversational-rectification.ts @@ -5,13 +5,12 @@ import { CONVERSATIONAL_RECTIFICATION_UNAVAILABLE, ConversationalRectificationRequestError, createConversationalRectificationActionRegistry, + conversationalRectificationHistoryForTurn, sendConversationalRectificationCommand, type ConversationalRectificationActionIdentity, - type ConversationalRectificationStreamOptions, } from "../lib/conversational-rectification/client.ts"; import type { ConversationalRectificationCommand, - ConversationalRectificationResponse, ConversationalRectificationTurn, } from "../lib/conversational-rectification/contracts.ts"; @@ -26,46 +25,66 @@ export type ConversationalRectificationMessage = Readonly<{ renderKey: string; }>; +export type ConversationalRectificationStoredMessage = Readonly<{ + role: "assistant" | "user"; + text: string; +}>; + function assistantText(turn: ConversationalRectificationTurn): string { return turn.narrative.trim(); } -function initialMessages(turn: ConversationalRectificationResponse | null): ConversationalRectificationMessage[] { +function initialMessages(turn: ConversationalRectificationTurn | null): ConversationalRectificationMessage[] { if (!turn) return []; - if (turn.messageHistory?.length) { - return turn.messageHistory.flatMap((entry) => [ - ...(entry.userMessage ? [{ - role: "user" as const, - text: entry.userMessage, - renderKey: `user-turn-${entry.turnVersion}`, - }] : []), - { - role: "assistant" as const, - text: entry.narrative.trim(), - renderKey: `assistant-turn-${entry.turnVersion}`, - }, - ]); + if (turn.evidenceRecap.length === 0) { + return [{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }]; } - return [{ role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }]; + const latestEvidence = turn.evidenceRecap.at(-1); + return [ + ...(latestEvidence ? [{ + role: "user" as const, + text: `${latestEvidence.dateLabel} · ${latestEvidence.summary}${latestEvidence.isCorrection ? "(已修订)" : ""}`, + renderKey: `user-${latestEvidence.id}`, + }] : []), + { role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }, + ]; +} + +function restoredMessages( + turn: ConversationalRectificationTurn | null, + messages?: readonly ConversationalRectificationStoredMessage[], +): ConversationalRectificationMessage[] { + const durableMessages = messages?.length + ? messages + : turn + ? conversationalRectificationHistoryForTurn(turn) + : []; + if (!durableMessages.length) return initialMessages(turn); + return durableMessages.map((message, index) => ({ + ...message, + renderKey: `persisted-${turn?.caseId ?? "unbound"}-${index}`, + })); } export type ConversationalRectificationControllerSnapshot = Readonly<{ - turn: ConversationalRectificationResponse | null; + turn: ConversationalRectificationTurn | null; messages?: readonly ConversationalRectificationMessage[]; draft: string; selectedDomain: EvidenceDomain | null; correctionTarget: EvidenceRecapEntry | null; pending: boolean; - streamingAssistantText?: string; error: string; }>; -type MutationResult = Promise; +type MutationResult = Promise; export type ConversationalRectificationController = ConversationalRectificationControllerSnapshot & Readonly<{ getSnapshot(): ConversationalRectificationControllerSnapshot; subscribe(listener: () => void): () => void; - synchronizeInitialTurn(turn: ConversationalRectificationResponse | null): void; + synchronizeInitialTurn( + turn: ConversationalRectificationTurn | null, + messages?: readonly ConversationalRectificationStoredMessage[], + ): void; setDraft(value: string): void; selectDomain(domain: EvidenceDomain | null): void; beginEvidenceCorrection(evidenceId: string): void; @@ -73,19 +92,23 @@ export type ConversationalRectificationController = ConversationalRectificationC start(pendingConsultationQuestion?: string | null): MutationResult; resume(): MutationResult; answer(domain?: EvidenceDomain, answerOverride?: string): MutationResult; + regenerate(): MutationResult; pause(): MutationResult; abandon(): MutationResult; confirm(time?: string): MutationResult; }>; type ControllerInput = Readonly<{ - initialTurn?: ConversationalRectificationResponse | null; - send?: ( - command: ConversationalRectificationCommand, - options?: ConversationalRectificationStreamOptions, - ) => Promise; + initialTurn?: ConversationalRectificationTurn | null; + initialMessages?: readonly ConversationalRectificationStoredMessage[]; + modelId?: string; + getModelId?: () => string | undefined; + send?: (command: ConversationalRectificationCommand) => Promise; createActionId?: () => string; - onTurn?: (turn: ConversationalRectificationResponse) => void; + onTurn?: ( + turn: ConversationalRectificationTurn, + messages: readonly ConversationalRectificationMessage[], + ) => void; onPendingChange?: (pending: boolean) => void; }>; @@ -94,6 +117,7 @@ type Mutation = Readonly<{ command(actionId: string): ConversationalRectificationCommand; clearDraftOnSuccess?: boolean; userMessage?: string; + replaceLatestAssistant?: boolean; }>; type ActiveMutation = Readonly<{ @@ -108,11 +132,17 @@ function createLatestControllerInput(initial: ControllerInput) { update(next: ControllerInput) { current = next; }, - send(command: ConversationalRectificationCommand, options?: ConversationalRectificationStreamOptions) { - return (current.send ?? sendConversationalRectificationCommand)(command, options); + modelId() { + return current.modelId; }, - onTurn(turn: ConversationalRectificationResponse) { - current.onTurn?.(turn); + send(command: ConversationalRectificationCommand) { + return (current.send ?? sendConversationalRectificationCommand)(command); + }, + onTurn( + turn: ConversationalRectificationTurn, + messages: readonly ConversationalRectificationMessage[], + ) { + current.onTurn?.(turn, messages); }, onPendingChange(pending: boolean) { current.onPendingChange?.(pending); @@ -136,16 +166,16 @@ export function createConversationalRectificationController( input: ControllerInput = {}, ): ConversationalRectificationController { const send = input.send ?? sendConversationalRectificationCommand; + const selectedModelId = () => (input.getModelId?.() ?? input.modelId)?.trim() || undefined; const registry = createConversationalRectificationActionRegistry(input.createActionId); const listeners = new Set<() => void>(); let snapshot: ConversationalRectificationControllerSnapshot = { turn: input.initialTurn ?? null, - messages: initialMessages(input.initialTurn ?? null), + messages: restoredMessages(input.initialTurn ?? null, input.initialMessages), draft: "", selectedDomain: null, correctionTarget: null, pending: false, - streamingAssistantText: "", error: "", }; let activeMutation: ActiveMutation | null = null; @@ -168,14 +198,36 @@ export function createConversationalRectificationController( } }; const acceptTurn = ( - turn: ConversationalRectificationResponse, + turn: ConversationalRectificationTurn, clearDraft: boolean, expectedCaseContext: number, userMessage?: string, + replaceLatestAssistant = false, ) => { const current = snapshot.turn; if (caseContext !== expectedCaseContext) return turn; - if (current?.caseId === turn.caseId && current.turnVersion >= turn.turnVersion) return turn; + const durableMessages = conversationalRectificationHistoryForTurn(turn); + const restoredDurableMessages = durableMessages.length > 0 + ? restoredMessages(turn, durableMessages) + : []; + if (current?.caseId === turn.caseId && current.turnVersion > turn.turnVersion) return turn; + if (current?.caseId === turn.caseId && current.turnVersion === turn.turnVersion) { + const latestDurableAssistant = durableMessages.findLast((message) => message.role === "assistant"); + const repairsCurrentTranscript = restoredDurableMessages.length > (snapshot.messages?.length ?? 0) + && latestDurableAssistant?.text.trim() === assistantText(current); + if (!repairsCurrentTranscript) return turn; + patch({ + messages: restoredDurableMessages, + error: "", + ...(clearDraft ? { draft: "" } : {}), + }); + try { + input.onTurn?.(current, restoredDurableMessages); + } catch { + // A consumer callback is observational. It must never turn a durable success into a failure. + } + return turn; + } const selectedDomain = clearDraft ? null : snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain) @@ -186,29 +238,46 @@ export function createConversationalRectificationController( : snapshot.correctionTarget ? turn.evidenceRecap.find((entry) => entry.id === snapshot.correctionTarget?.id) ?? null : null; + const nextMessages = restoredDurableMessages.length > 0 + ? restoredDurableMessages + : replaceLatestAssistant + ? (() => { + const messages = [...(snapshot.messages ?? [])]; + const lastAssistantIndex = messages.findLastIndex((message) => message.role === "assistant"); + const replacement = { + role: "assistant" as const, + text: assistantText(turn), + renderKey: `assistant-${turn.turnVersion}`, + }; + if (lastAssistantIndex < 0) return [...messages, replacement]; + messages[lastAssistantIndex] = replacement; + return messages; + })() + : userMessage + ? [ + ...(snapshot.messages ?? []), + { role: "user" as const, text: userMessage, renderKey: `user-${turn.turnVersion}` }, + { role: "assistant" as const, text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }, + ] + : current === null || current.caseId !== turn.caseId + ? initialMessages(turn) + : snapshot.messages ?? []; patch({ turn, - messages: userMessage - ? [ - ...(snapshot.messages ?? []), - { role: "user", text: userMessage, renderKey: `user-${turn.turnVersion}` }, - { role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }, - ] - : snapshot.messages, + messages: nextMessages, error: "", - streamingAssistantText: "", selectedDomain, correctionTarget, ...(clearDraft ? { draft: "" } : {}), }); try { - input.onTurn?.(turn); + input.onTurn?.(turn, nextMessages); } catch { // A consumer callback is observational. It must never turn a durable success into a failure. } return turn; }; - const recoverLatest = async (turn: ConversationalRectificationResponse) => registry.run({ + const recoverLatest = async (turn: ConversationalRectificationTurn) => registry.run({ caseId: turn.caseId, turnVersion: turn.turnVersion, operation: "resume", @@ -221,7 +290,7 @@ export function createConversationalRectificationController( })); const run = (mutation: Mutation): MutationResult => { if (activeMutation?.caseContext === caseContext) return activeMutation.promise; - patch({ error: "", streamingAssistantText: "" }); + patch({ error: "" }); setPending(true); const turnAtStart = snapshot.turn; const caseContextAtStart = caseContext; @@ -230,17 +299,13 @@ export function createConversationalRectificationController( && activeMutation?.token === mutationToken; const operation = registry.run( mutation.identity, - (actionId) => send(mutation.command(actionId), { - onNarrativeDelta(text) { - if (!ownsCurrentContext()) return; - patch({ streamingAssistantText: (snapshot.streamingAssistantText ?? "") + text }); - }, - }), + (actionId) => send(mutation.command(actionId)), ).then((turn) => acceptTurn( turn, mutation.clearDraftOnSuccess === true, caseContextAtStart, mutation.userMessage, + mutation.replaceLatestAssistant === true, )) .catch(async (error: unknown) => { if (turnAtStart && staleTurn(error) && caseContext === caseContextAtStart) { @@ -248,20 +313,17 @@ export function createConversationalRectificationController( const recovered = await recoverLatest(turnAtStart); return acceptTurn(recovered, false, caseContextAtStart); } catch (recoveryError) { - if (ownsCurrentContext()) patch({ error: displayError(recoveryError), streamingAssistantText: "" }); + if (ownsCurrentContext()) patch({ error: displayError(recoveryError) }); throw recoveryError; } } - if (ownsCurrentContext()) patch({ error: displayError(error), streamingAssistantText: "" }); + if (ownsCurrentContext()) patch({ error: displayError(error) }); throw error; }) .finally(() => { if (activeMutation?.token !== mutationToken) return; activeMutation = null; - if (caseContext === caseContextAtStart) { - patch({ streamingAssistantText: "" }); - setPending(false); - } + if (caseContext === caseContextAtStart) setPending(false); }); activeMutation = { caseContext: caseContextAtStart, @@ -277,9 +339,10 @@ export function createConversationalRectificationController( const currentMutation = ( operation: Exclude, payload: unknown, - command: (turn: ConversationalRectificationResponse, actionId: string) => ConversationalRectificationCommand, + command: (turn: ConversationalRectificationTurn, actionId: string) => ConversationalRectificationCommand, clearDraftOnSuccess = false, userMessage?: string, + replaceLatestAssistant = false, ): MutationResult => { const turn = currentTurn(); if (!turn) return Promise.resolve(null); @@ -293,6 +356,7 @@ export function createConversationalRectificationController( command: (actionId) => command(turn, actionId), clearDraftOnSuccess, userMessage, + replaceLatestAssistant, }); }; @@ -303,14 +367,16 @@ export function createConversationalRectificationController( get selectedDomain() { return snapshot.selectedDomain; }, get correctionTarget() { return snapshot.correctionTarget; }, get pending() { return snapshot.pending; }, - get streamingAssistantText() { return snapshot.streamingAssistantText; }, get error() { return snapshot.error; }, getSnapshot: () => snapshot, subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener); }, - synchronizeInitialTurn(turn: ConversationalRectificationResponse | null) { + synchronizeInitialTurn( + turn: ConversationalRectificationTurn | null, + messages?: readonly ConversationalRectificationStoredMessage[], + ) { const current = snapshot.turn; if (turn === null) { if (current === null) return; @@ -328,7 +394,6 @@ export function createConversationalRectificationController( selectedDomain: null, correctionTarget: null, pending: false, - streamingAssistantText: "", error: "", }); return; @@ -343,27 +408,30 @@ export function createConversationalRectificationController( } patch({ turn, - messages: initialMessages(turn), + messages: restoredMessages(turn, messages), draft: "", selectedDomain: null, correctionTarget: null, pending: false, - streamingAssistantText: "", error: "", }); return; } - if (turn.turnVersion <= current.turnVersion) return; + if (turn.turnVersion <= current.turnVersion) { + const restored = messages?.length ? restoredMessages(turn, messages) : []; + if (turn.turnVersion === current.turnVersion + && restored.length > (snapshot.messages?.length ?? 0)) { + patch({ messages: restored }); + } + return; + } patch({ turn, - messages: turn.messageHistory?.length - ? initialMessages(turn) - : [ - ...(snapshot.messages ?? []), - { role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }, - ], + messages: [ + ...(snapshot.messages ?? []), + { role: "assistant", text: assistantText(turn), renderKey: `assistant-${turn.turnVersion}` }, + ], error: "", - streamingAssistantText: "", selectedDomain: snapshot.selectedDomain && turn.evidenceRequest?.domains.includes(snapshot.selectedDomain) ? snapshot.selectedDomain @@ -393,14 +461,20 @@ export function createConversationalRectificationController( patch({ correctionTarget: null, draft: "" }); }, start(pendingConsultationQuestion: string | null = null) { + const modelId = selectedModelId(); return run({ identity: { caseId: "new-case", turnVersion: 0, operation: "start", - payload: { pendingConsultationQuestion }, + payload: { pendingConsultationQuestion, ...(modelId ? { modelId } : {}) }, }, - command: (actionId) => ({ type: "start", actionId, pendingConsultationQuestion }), + command: (actionId) => ({ + type: "start", + actionId, + pendingConsultationQuestion, + ...(modelId ? { modelId } : {}), + }), }); }, resume() { @@ -419,8 +493,10 @@ export function createConversationalRectificationController( const answer = (answerOverride ?? snapshot.draft).trim(); if (!turn || !answer || !turn.actions.includes("answer")) return Promise.resolve(turn); const correctsEvidenceId = snapshot.correctionTarget?.id; + const modelId = selectedModelId(); const payload = { answer, + ...(modelId ? { modelId } : {}), ...(domain ? { domain } : {}), ...(correctsEvidenceId ? { correctsEvidenceId } : {}), }; @@ -430,10 +506,25 @@ export function createConversationalRectificationController( actionId, turnVersion: current.turnVersion, answer, + ...(modelId ? { modelId } : {}), ...(domain ? { domain } : {}), ...(correctsEvidenceId ? { correctsEvidenceId } : {}), }), true, answer); }, + regenerate() { + const turn = currentTurn(); + if (!turn?.actions.includes("answer")) { + return activeMutationForCurrentContext() ?? Promise.resolve(turn); + } + const modelId = selectedModelId(); + return currentMutation("regenerate", modelId ? { modelId } : {}, (current, actionId) => ({ + type: "regenerate", + caseId: current.caseId, + actionId, + turnVersion: current.turnVersion, + ...(modelId ? { modelId } : {}), + }), false, undefined, true); + }, pause() { const turn = currentTurn(); if (!turn?.actions.includes("pause")) { @@ -484,7 +575,9 @@ export function useConversationalRectification( const [latestInput] = useState(() => createLatestControllerInput(input)); const [controller] = useState(() => createConversationalRectificationController({ initialTurn: input.initialTurn, + initialMessages: input.initialMessages, createActionId: input.createActionId, + getModelId: () => latestInput.modelId(), send: latestInput.send, onTurn: latestInput.onTurn, onPendingChange: latestInput.onPendingChange, @@ -498,7 +591,7 @@ export function useConversationalRectification( latestInput.update(input); }, [input, latestInput]); useEffect(() => { - controller.synchronizeInitialTurn(input.initialTurn ?? null); - }, [controller, input.initialTurn]); + controller.synchronizeInitialTurn(input.initialTurn ?? null, input.initialMessages); + }, [controller, input.initialMessages, input.initialTurn]); return { ...controller, ...snapshot }; } diff --git a/frontend/src/lib/account-profile-patch.ts b/frontend/src/lib/account-profile-patch.ts index 4d9440df..7db463ac 100644 --- a/frontend/src/lib/account-profile-patch.ts +++ b/frontend/src/lib/account-profile-patch.ts @@ -44,6 +44,12 @@ export const accountProfilePatchSchema = z.object({ latitude: z.number().finite().min(-90).max(90).nullable().optional(), longitude: z.number().finite().min(-180).max(180).nullable().optional(), timezone_offset: z.number().finite().min(-12).max(14).nullable().optional(), + birth_place_label: nullableTrimmedString(240).optional(), + birth_place_type: nullableTrimmedString(40).optional(), + birth_place_provider: z.enum(["geoapify", "china_locations", "mapbox", "geonames"]).nullable().optional(), + birth_place_provider_id: nullableTrimmedString(160).optional(), + timezone_id: nullableTrimmedString(80).optional(), + timezone_source: z.literal("iana_historical").nullable().optional(), }).strict().superRefine((value, context) => { const source = value.birth_time_source; const time = value.reported_birth_time; @@ -65,10 +71,21 @@ export const accountProfilePatchSchema = z.object({ "uncertainty_after_minutes", ] as const; const mutatesDeclaration = declarationKeys.some((key) => value[key] !== undefined); - const coordinates = [value.latitude, value.longitude, value.timezone_offset]; - const concreteCoordinateCount = coordinates.filter((coordinate) => coordinate != null).length; - if (concreteCoordinateCount > 0 && concreteCoordinateCount < coordinates.length) { - addIssue("latitude", "出生地点坐标与时区必须完整提交"); + const coordinateCount = [value.latitude, value.longitude].filter((coordinate) => coordinate != null).length; + if (coordinateCount === 1) { + addIssue("latitude", "出生地点经纬度必须完整提交"); + } + const globalLocationFields = [ + value.birth_place_label, + value.birth_place_type, + value.birth_place_provider, + value.birth_place_provider_id, + value.timezone_id, + value.timezone_source, + ]; + const hasGlobalLocation = globalLocationFields.some((field) => field != null); + if (hasGlobalLocation && (coordinateCount !== 2 || !value.timezone_id)) { + addIssue("timezone_id", "全球出生地点必须包含完整坐标与 IANA 时区"); } if (source === undefined) { if (mutatesDeclaration) addIssue("birth_time_source", "修改出生资料时必须同时说明时间来源"); @@ -151,6 +168,12 @@ type AccountBirthTimeState = Readonly<{ latitude?: number | null; longitude?: number | null; timezone_offset?: number | null; + birth_place_label?: string | null; + birth_place_type?: string | null; + birth_place_provider?: string | null; + birth_place_provider_id?: string | null; + timezone_id?: string | null; + timezone_source?: string | null; }>; const declarationFields = [ @@ -168,6 +191,12 @@ const declarationFields = [ "latitude", "longitude", "timezone_offset", + "birth_place_label", + "birth_place_type", + "birth_place_provider", + "birth_place_provider_id", + "timezone_id", + "timezone_source", ] as const; const concurrencyFields = [ diff --git a/frontend/src/lib/account-rectification-case.ts b/frontend/src/lib/account-rectification-case.ts index a6a8e513..d2f1d2bd 100644 --- a/frontend/src/lib/account-rectification-case.ts +++ b/frontend/src/lib/account-rectification-case.ts @@ -4,6 +4,7 @@ import { declaredBirthInputSchema, type DeclaredBirthInput, } from "./conversational-rectification/persistence-contracts.ts"; +import { durableBirthCoordinate } from "./birth-profile-timezone.ts"; type RecordValue = Record; @@ -39,25 +40,34 @@ function integer(value: unknown): number | null { return typeof value === "number" && Number.isInteger(value) ? value : null; } -function currentDeclaration(value: unknown): DeclaredBirthInput | null { +function currentDeclaration(value: unknown, fallbackTimezoneOffset?: number): DeclaredBirthInput | null { const profile = record(value); if (!profile) return null; const birthDate = text(profile.birth_date); const source = text(profile.birth_time_source); const cityCode = text(profile.city_code); - const latitude = finiteNumber(profile.latitude); - const longitude = finiteNumber(profile.longitude); - const timezoneOffset = finiteNumber(profile.timezone_offset); - if (!birthDate || !source || !cityCode || latitude === null || longitude === null + const city = text(profile.birth_place_label); + const placeId = text(profile.birth_place_provider_id); + const latitude = durableBirthCoordinate(profile.latitude); + const longitude = durableBirthCoordinate(profile.longitude); + const timezoneOffset = finiteNumber(profile.timezone_offset) ?? fallbackTimezoneOffset ?? null; + if (!birthDate || !source || (!city && !cityCode && !placeId) + || latitude === null || longitude === null || timezoneOffset === null) return null; const birthplace = { + ...(city ? { city } : {}), + ...(placeId ? { placeId } : {}), + ...(text(profile.birth_place_type) ? { placeType: text(profile.birth_place_type) } : {}), + ...(text(profile.birth_place_provider) ? { provider: text(profile.birth_place_provider) } : {}), ...(text(profile.country_code) ? { countryCode: text(profile.country_code) } : {}), ...(text(profile.province_code) ? { provinceCode: text(profile.province_code) } : {}), - cityCode, + ...(cityCode ? { cityCode } : {}), ...(text(profile.district_code) ? { districtCode: text(profile.district_code) } : {}), latitude, longitude, + ...(text(profile.timezone_id) ? { timezoneId: text(profile.timezone_id) } : {}), + ...(text(profile.timezone_source) ? { timezoneSource: text(profile.timezone_source) } : {}), timezoneOffset, }; const common = { @@ -107,6 +117,7 @@ function currentDeclaration(value: unknown): DeclaredBirthInput | null { function canonicalPlaceLabel(input: DeclaredBirthInput): string | null { const place = input.birthplace; + if (place.city) return place.city; const country = chinaLocations.country; if (place.countryCode !== country.code || !place.provinceCode || !place.cityCode) return null; const province = country.provinces.find((candidate) => candidate.code === place.provinceCode); @@ -120,10 +131,10 @@ function canonicalPlaceLabel(input: DeclaredBirthInput): string | null { .join(" · "); } -function withoutOptionalPlaceLabel(input: DeclaredBirthInput): unknown { - const birthplace: Record = { ...input.birthplace }; - delete birthplace.city; - return { ...input, birthplace }; +function withoutBirthplace(input: DeclaredBirthInput): unknown { + const declaration: Record = { ...input }; + delete declaration.birthplace; + return declaration; } function sameJson(left: unknown, right: unknown): boolean { @@ -144,9 +155,15 @@ function sameJson(left: unknown, right: unknown): boolean { } function declarationMatches(current: DeclaredBirthInput, stored: DeclaredBirthInput) { - if (!sameJson(withoutOptionalPlaceLabel(current), withoutOptionalPlaceLabel(stored))) { + if (!sameJson(withoutBirthplace(current), withoutBirthplace(stored))) { return false; } + const currentPlace = current.birthplace as Record; + const storedPlace = stored.birthplace as Record; + for (const [key, value] of Object.entries(storedPlace)) { + if (key === "city") continue; + if (!sameJson(currentPlace[key], value)) return false; + } if (!stored.birthplace.city) return true; return stored.birthplace.city === canonicalPlaceLabel(current); } @@ -177,15 +194,15 @@ export function resolveAccountRectificationCase( profile: unknown, rows: readonly unknown[], ): AccountRectificationCaseState | null { - const current = currentDeclaration(profile); - if (!current) return null; for (const value of rows) { const row = record(value); if (!row) continue; const projected = project(row); if (!projected) continue; const declared = declaredBirthInputSchema.safeParse(row.declared_birth_input); - if (declared.success && declarationMatches(current, declared.data)) return projected; + if (!declared.success) continue; + const current = currentDeclaration(profile, declared.data.birthplace.timezoneOffset); + if (current && declarationMatches(current, declared.data)) return projected; } return null; } diff --git a/frontend/src/lib/birth-profile-timezone.ts b/frontend/src/lib/birth-profile-timezone.ts new file mode 100644 index 00000000..c7f328a8 --- /dev/null +++ b/frontend/src/lib/birth-profile-timezone.ts @@ -0,0 +1,102 @@ +type RecordValue = Record; + +export class BirthProfileTimezoneError extends Error { + constructor() { + super("Unable to resolve historical birth timezone offset"); + this.name = "BirthProfileTimezoneError"; + } +} + +function record(value: unknown): RecordValue | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as RecordValue + : null; +} + +function valueFor(profile: RecordValue, snakeKey: string, camelKey: string): unknown { + return profile[snakeKey] ?? profile[camelKey]; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function finiteBirthNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +export function durableBirthCoordinate(value: unknown): number | null { + const coordinate = finiteBirthNumber(value); + if (coordinate === null) return null; + const rounded = Math.round(coordinate * 1_000_000) / 1_000_000; + return Object.is(rounded, -0) ? 0 : rounded; +} + +export function birthProfileReferenceTime(value: unknown, preferredTime?: string | null): string { + const preferred = text(preferredTime)?.slice(0, 5); + if (preferred && /^([01]\d|2[0-3]):[0-5]\d$/.test(preferred)) return preferred; + const profile = record(value); + const reported = text(profile ? valueFor(profile, "reported_birth_time", "reportedTime") : null)?.slice(0, 5); + if (reported && /^([01]\d|2[0-3]):[0-5]\d$/.test(reported)) return reported; + const direct = text(profile ? valueFor(profile, "birth_time", "time") : null)?.slice(0, 5); + if (direct && /^([01]\d|2[0-3]):[0-5]\d$/.test(direct)) return direct; + const period = text(profile ? valueFor(profile, "birth_time_period", "birthTimePeriod") : null); + return { + early_morning: "06:00", + morning: "10:00", + afternoon: "15:00", + evening: "20:30", + late_night: "23:30", + }[period ?? ""] ?? "12:00"; +} + +type ResolveBirthTimezoneOptions = Readonly<{ + fetchImpl?: typeof fetch; + apiBase?: string; + preferredTime?: string | null; +}>; + +export async function resolveMissingBirthTimezoneOffset( + value: unknown, + options: ResolveBirthTimezoneOptions = {}, +): Promise { + const profile = record(value); + if (!profile) return value; + const existingOffset = finiteBirthNumber(valueFor(profile, "timezone_offset", "timezoneOffset")); + if (existingOffset !== null) return value; + const latitude = finiteBirthNumber(profile.latitude); + const longitude = finiteBirthNumber(profile.longitude); + const birthDate = text(valueFor(profile, "birth_date", "date")); + const timezoneId = text(valueFor(profile, "timezone_id", "timezoneId")); + if (latitude === null || longitude === null || !birthDate || !timezoneId) return value; + + const fetchImpl = options.fetchImpl ?? fetch; + const apiBase = options.apiBase ?? process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; + let response: Response; + try { + response = await fetchImpl(`${apiBase}/api/location/timezone`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + latitude, + longitude, + birthDate, + birthTime: birthProfileReferenceTime(profile, options.preferredTime), + }), + cache: "no-store", + }); + } catch { + throw new BirthProfileTimezoneError(); + } + if (!response.ok) throw new BirthProfileTimezoneError(); + const payload = record(await response.json().catch(() => null)); + const timezoneOffset = finiteBirthNumber(payload?.timezoneOffset); + if (payload?.available !== true || timezoneOffset === null) { + throw new BirthProfileTimezoneError(); + } + return { + ...profile, + timezone_offset: timezoneOffset, + timezoneOffset, + }; +} diff --git a/frontend/src/lib/birth-time-evidence.ts b/frontend/src/lib/birth-time-evidence.ts index b64fe28d..e4f631dd 100644 --- a/frontend/src/lib/birth-time-evidence.ts +++ b/frontend/src/lib/birth-time-evidence.ts @@ -93,6 +93,11 @@ const rectificationTechniqueReceiptSchema = z.object({ missingLayers: z.array(z.string()), auxiliaryLayers: z.array(z.string()).default([]), hardBlockers: z.array(z.string()), + externalEngines: z.object({ + status: z.string(), + providers: z.array(z.string()), + validation: z.record(z.string(), z.unknown()).optional(), + }).strict().optional(), canonicalInputHash: z.string().optional(), confirmationAllowed: z.boolean().optional(), decision: z.enum(["continue_rectification", "confirm_minute"]).optional(), @@ -112,7 +117,7 @@ export const candidateResultSchema = z.object({ representativeTime: timeSchema, widthMinutes: z.number().int().min(1).max(1_440), }).strict().readonly().nullable(), - eventCount: z.number().int().min(0).max(10), + eventCount: z.number().int().min(0), domainCount: z.number().int().min(0).max(6), topScore: z.number(), secondScore: z.number(), diff --git a/frontend/src/lib/birth-time-intake-model.ts b/frontend/src/lib/birth-time-intake-model.ts index d63eca78..1badf878 100644 --- a/frontend/src/lib/birth-time-intake-model.ts +++ b/frontend/src/lib/birth-time-intake-model.ts @@ -49,7 +49,8 @@ export type DeclaredBirthPlace = Readonly<{ label: string; lat: number; lon: number; - tz: number; + tz: number | null; + timezoneId?: string; }>; export function parseBirthDate(value: string): Date | undefined { @@ -193,9 +194,10 @@ export function isDeclaredBirthProfileComplete( && Number.isFinite(place.lon) && place.lon >= -180 && place.lon <= 180 - && Number.isFinite(place.tz) - && place.tz >= -12 - && place.tz <= 14); + && ((Number.isFinite(place.tz) + && (place.tz as number) >= -12 + && (place.tz as number) <= 14) + || Boolean(place.timezoneId?.trim()))); } export function isBirthTimeReadyForConsultation(draft: BirthTimeDraft) { diff --git a/frontend/src/lib/birth-time-journey-adapters.ts b/frontend/src/lib/birth-time-journey-adapters.ts index 72d097fc..252f1d8e 100644 --- a/frontend/src/lib/birth-time-journey-adapters.ts +++ b/frontend/src/lib/birth-time-journey-adapters.ts @@ -34,7 +34,7 @@ const profileSchema = z.object({ uncertainty_after_minutes: z.number().int().nullable().optional(), latitude: z.number(), longitude: z.number(), - timezone_offset: z.number(), + timezone_offset: z.number().nullable(), }); const optionSchema = z.object({ @@ -132,6 +132,11 @@ const candidateResultApiSchema = z.object({ missing_layers: z.array(z.string()), auxiliary_layers: z.array(z.string()).default([]), hard_blockers: z.array(z.string()), + external_engines: z.object({ + status: z.string(), + providers: z.array(z.string()), + validation: z.record(z.string(), z.unknown()).optional(), + }).strict().optional(), canonical_input_hash: z.string().optional(), confirmation_allowed: z.boolean().optional(), decision: z.enum(["continue_rectification", "confirm_minute"]).optional(), @@ -152,6 +157,13 @@ class UnexpectedProfileSourceError extends Error { export function parseBirthTimeProfile(value: unknown): BirthTimeAssessment { const profile = profileSchema.parse(value); + if (profile.timezone_offset === null) { + throw new z.ZodError([{ + code: "custom", + path: ["timezone_offset"], + message: "Historical timezone offset must be resolved before parsing", + }]); + } const location = { lat: profile.latitude, lon: profile.longitude, @@ -279,6 +291,11 @@ function adaptCandidateResult(parsed: z.infer): missingLayers: parsed.technique_contract.missing_layers, auxiliaryLayers: parsed.technique_contract.auxiliary_layers, hardBlockers: parsed.technique_contract.hard_blockers, + externalEngines: parsed.technique_contract.external_engines ? { + status: parsed.technique_contract.external_engines.status, + providers: parsed.technique_contract.external_engines.providers, + validation: parsed.technique_contract.external_engines.validation, + } : undefined, canonicalInputHash: parsed.technique_contract.canonical_input_hash, confirmationAllowed: parsed.technique_contract.confirmation_allowed, decision: parsed.technique_contract.decision, diff --git a/frontend/src/lib/birth-time-journey-case-loader.ts b/frontend/src/lib/birth-time-journey-case-loader.ts index 5bd31f63..7770ad17 100644 --- a/frontend/src/lib/birth-time-journey-case-loader.ts +++ b/frontend/src/lib/birth-time-journey-case-loader.ts @@ -22,6 +22,7 @@ import { parseDynamicPrivateRow, } from "./birth-time-journey-dynamic-state.ts"; import { BirthTimeJourneyStoreError } from "./birth-time-journey-store-errors.ts"; +import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts"; type JourneyLoadResult = { readonly data: unknown; readonly error: unknown }; type JourneyLoadQuery = { @@ -140,11 +141,17 @@ export async function loadStoredRectificationCase( const parsed = storedCaseSchema.parse(data); const { data: profile, error: profileError } = await client .from("profiles") - .select("latitude,longitude,timezone_offset") + .select("birth_date,reported_birth_time,birth_time_period,latitude,longitude,timezone_id,timezone_offset") .eq("id", userId) .maybeSingle(); if (profileError || !profile) throw new BirthTimeJourneyStoreError("load_case"); - const location = eventLocationSchema.parse(profile); + let resolvedProfile: unknown; + try { + resolvedProfile = await resolveMissingBirthTimezoneOffset(profile); + } catch { + throw new BirthTimeJourneyStoreError("load_case"); + } + const location = eventLocationSchema.parse(resolvedProfile); const scoring = Object.keys(parsed.scoring_result).length > 0 ? parseRectificationScoring(parsed.scoring_result) : undefined; diff --git a/frontend/src/lib/birth-time-journey-dynamic-adapters.ts b/frontend/src/lib/birth-time-journey-dynamic-adapters.ts index e1db9cdb..c9bbaaae 100644 --- a/frontend/src/lib/birth-time-journey-dynamic-adapters.ts +++ b/frontend/src/lib/birth-time-journey-dynamic-adapters.ts @@ -196,7 +196,7 @@ export function parseDynamicChoiceScoring(value: unknown): DynamicChoiceScoringR topScore: parsed.top_score, secondScore: parsed.second_score, marginPercent: parsed.margin_percent, - reasons: [...new Set([...parsed.reasons, "minute_holdout_not_ready"])], + reasons: [...new Set([...parsed.reasons, "vedastro_validation_required"])], evidence: [], algorithmVersion: parsed.algorithm_version, }); diff --git a/frontend/src/lib/birth-time-journey-engine-model.ts b/frontend/src/lib/birth-time-journey-engine-model.ts index 3966ff86..4cc83afd 100644 --- a/frontend/src/lib/birth-time-journey-engine-model.ts +++ b/frontend/src/lib/birth-time-journey-engine-model.ts @@ -87,6 +87,7 @@ export function eventScorePayload(input: JourneyEventScoreInput) { lat: input.lat, lon: input.lon, tz: input.tz, + high_rigor: true, events: (input.events ?? []).map((event) => ({ id: event.id, domain: event.domain, diff --git a/frontend/src/lib/birth-time-journey-telemetry.ts b/frontend/src/lib/birth-time-journey-telemetry.ts index a5b23514..7c1061a6 100644 --- a/frontend/src/lib/birth-time-journey-telemetry.ts +++ b/frontend/src/lib/birth-time-journey-telemetry.ts @@ -16,6 +16,7 @@ export const conversationalRectificationTelemetryActionKinds = [ "start", "resume", "answer", + "regenerate", "pause", "abandon", "confirm", diff --git a/frontend/src/lib/consultation-route-service.ts b/frontend/src/lib/consultation-route-service.ts index 98f62357..38d8c175 100644 --- a/frontend/src/lib/consultation-route-service.ts +++ b/frontend/src/lib/consultation-route-service.ts @@ -1,5 +1,6 @@ import { chinaLocations } from "../data/china-locations.ts"; import { isBirthClockTime, parseBirthDate } from "./birth-time-intake-model.ts"; +import { resolveMissingBirthTimezoneOffset } from "./birth-profile-timezone.ts"; import type { ConsultationBirthTimeMode } from "./consultation-birth-time-mode.ts"; export type ConsultationProfileTruthErrorCode = @@ -42,11 +43,16 @@ export type ServerChartConsultation = Readonly<{ birthTimeStatus: string; placeLabel: string; placeCodes: Readonly<{ - countryCode: string; - provinceCode: string; - cityCode: string; + countryCode: string | null; + provinceCode: string | null; + cityCode: string | null; districtCode: string | null; }>; + placeId: string | null; + placeType: string | null; + placeProvider: string | null; + timezoneId: string | null; + timezoneSource: string | null; latitude: number; longitude: number; timezoneOffset: number; @@ -57,6 +63,7 @@ type PrepareConsultationRouteInput = Readonly<{ userId: string; mode: ConsultationBirthTimeMode; loadProfile: (userId: string) => Promise; + resolveTimezoneOffset?: (profile: unknown, selectedTime?: string) => Promise; reserve: () => Promise; }>; @@ -110,8 +117,27 @@ function requiredFiniteNumber(profile: RecordValue, key: string, minimum: number return value; } -function sameCoordinate(left: number, right: number) { - return Math.abs(left - right) <= 0.000001; +function optionalText(profile: RecordValue, key: string): string | null { + const value = profile[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function legacyChinaPlaceLabel(profile: RecordValue): string | null { + const countryCode = optionalText(profile, "country_code"); + const provinceCode = optionalText(profile, "province_code"); + const cityCode = optionalText(profile, "city_code"); + const districtCode = optionalText(profile, "district_code"); + const country = chinaLocations.country; + if (countryCode !== country.code || !provinceCode || !cityCode) return null; + const province = country.provinces.find((candidate) => candidate.code === provinceCode); + const city = province?.cities.find((candidate) => candidate.code === cityCode); + const district = districtCode + ? city?.districts.find((candidate) => candidate.code === districtCode) + : undefined; + if (!province || !city || (districtCode && !district)) return null; + return [country.name, province.name, city.name, district?.name] + .filter((label, index, labels) => Boolean(label) && labels.indexOf(label) === index) + .join(" · "); } function serverChartFromProfile( @@ -137,37 +163,22 @@ function serverChartFromProfile( throw new ConsultationProfileTruthError("profile_inconsistent"); } - const countryCode = requiredText(profile, "country_code"); - const provinceCode = requiredText(profile, "province_code"); - const cityCode = requiredText(profile, "city_code"); - const districtValue = profile.district_code; - const districtCode = typeof districtValue === "string" && districtValue.trim() - ? districtValue.trim() - : null; + const countryCode = optionalText(profile, "country_code"); + const provinceCode = optionalText(profile, "province_code"); + const cityCode = optionalText(profile, "city_code"); + const districtCode = optionalText(profile, "district_code"); + const placeId = optionalText(profile, "birth_place_provider_id"); + const placeType = optionalText(profile, "birth_place_type"); + const placeProvider = optionalText(profile, "birth_place_provider"); + const timezoneId = optionalText(profile, "timezone_id"); + const timezoneSource = optionalText(profile, "timezone_source"); const latitude = requiredFiniteNumber(profile, "latitude", -90, 90); const longitude = requiredFiniteNumber(profile, "longitude", -180, 180); const timezoneOffset = requiredFiniteNumber(profile, "timezone_offset", -12, 14); - - const country = chinaLocations.country; - const province = country.provinces.find((candidate) => candidate.code === provinceCode); - const city = province?.cities.find((candidate) => candidate.code === cityCode); - const district = districtCode - ? city?.districts.find((candidate) => candidate.code === districtCode) - : undefined; - if (countryCode !== country.code || !province || !city - || (city.districts.length > 0 && !district) - || (districtCode !== null && !district)) { - throw new ConsultationProfileTruthError("profile_inconsistent"); - } - const location = district ?? city; - if (!sameCoordinate(latitude, location.center[1]) - || !sameCoordinate(longitude, location.center[0]) - || !sameCoordinate(timezoneOffset, country.timezone)) { - throw new ConsultationProfileTruthError("profile_inconsistent"); - } - const placeLabel = [country.name, province.name, city.name, district?.name] - .filter((label, index, labels) => Boolean(label) && labels.indexOf(label) === index) - .join(" · "); + const placeLabel = optionalText(profile, "birth_place_label") + ?? legacyChinaPlaceLabel(profile) + ?? placeId; + if (!placeLabel) throw new ConsultationProfileTruthError("profile_incomplete"); let selectedTime: string; let selectedTimeKind: "reported" | "active"; @@ -215,6 +226,11 @@ function serverChartFromProfile( cityCode, districtCode, }), + placeId, + placeType, + placeProvider, + timezoneId, + timezoneSource, latitude, longitude, timezoneOffset, @@ -238,6 +254,17 @@ export async function prepareConsultationRoute( if (error instanceof ConsultationProfileTruthError) throw error; throw new ConsultationProfileTruthError("profile_unavailable"); } + const profileValue = record(profile); + const selectedTime = input.mode === "verified_chart" + ? nullableClock(profileValue ?? {}, "active_birth_time") + : nullableClock(profileValue ?? {}, "reported_birth_time"); + try { + profile = await (input.resolveTimezoneOffset ?? ((value, time) => ( + resolveMissingBirthTimezoneOffset(value, { preferredTime: time }) + )))(profile, selectedTime ?? undefined); + } catch { + throw new ConsultationProfileTruthError("profile_unavailable"); + } serverChart = serverChartFromProfile(profile, input.mode); } const reservation = await input.reserve(); diff --git a/frontend/src/lib/conversational-rectification/client.ts b/frontend/src/lib/conversational-rectification/client.ts index bd5a7db0..ba245e19 100644 --- a/frontend/src/lib/conversational-rectification/client.ts +++ b/frontend/src/lib/conversational-rectification/client.ts @@ -1,30 +1,38 @@ import { z } from "zod"; +import { postJson } from "../birth-time-client-transport.ts"; import { conversationalRectificationCommandSchema, - conversationalRectificationResponseSchema, + conversationalRectificationTurnSchema, type ConversationalRectificationCommand, - type ConversationalRectificationResponse, + type ConversationalRectificationTurn, } from "./contracts.ts"; export const CONVERSATIONAL_RECTIFICATION_UNAVAILABLE = "生时校正暂时无法继续,请稍后重试。"; +export type ConversationalRectificationHistoryMessage = Readonly<{ + role: "assistant" | "user"; + text: string; +}>; + +const conversationHistoryMessageSchema = z.object({ + role: z.enum(["assistant", "user"]), + text: z.string().trim().min(1).max(12_000), +}).strict(); + +const conversationHistorySchema = z.array(conversationHistoryMessageSchema).max(500); +const conversationHistoryByTurn = new WeakMap(); + +export function conversationalRectificationHistoryForTurn( + turn: ConversationalRectificationTurn, +): readonly ConversationalRectificationHistoryMessage[] { + return conversationHistoryByTurn.get(turn) ?? []; +} + const publicErrorSchema = z.object({ code: z.string(), message: z.string(), }).passthrough(); -const streamEventSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal("delta"), text: z.string() }).strict(), - z.object({ - type: z.literal("turn"), - turn: conversationalRectificationResponseSchema, - }).strict(), -]); - -export type ConversationalRectificationStreamOptions = Readonly<{ - onNarrativeDelta?: (text: string) => void; -}>; - export class ConversationalRectificationRequestError extends Error { readonly name = "ConversationalRectificationRequestError"; readonly status: number; @@ -106,75 +114,21 @@ function isRetryableTransportError(error: unknown): boolean { ); } -async function readJsonPayload(response: Response): Promise { - return response.json().catch(() => null); -} - -async function readStreamedTurn( - response: Response, - options: ConversationalRectificationStreamOptions, -): Promise { - if (!response.body) throw new SyntaxError("missing rectification response stream"); - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffered = ""; - let turn: ConversationalRectificationResponse | null = null; - const consumeLine = (line: string) => { - if (!line.trim()) return; - const event = streamEventSchema.parse(JSON.parse(line)); - if (event.type === "delta") options.onNarrativeDelta?.(event.text); - else turn = event.turn; - }; - while (true) { - const { done, value } = await reader.read(); - buffered += decoder.decode(value, { stream: !done }); - let newline = buffered.indexOf("\n"); - while (newline >= 0) { - consumeLine(buffered.slice(0, newline)); - buffered = buffered.slice(newline + 1); - newline = buffered.indexOf("\n"); - } - if (done) break; - } - consumeLine(buffered); - if (!turn) throw new SyntaxError("missing rectification turn event"); - return turn; -} - -async function postCommandWithOneReplay( - body: string, - options: ConversationalRectificationStreamOptions, -) { +async function postCommandWithOneReplay(body: string) { for (let attempt = 0; attempt < 2; attempt += 1) { - let emittedNarrative = false; try { - const response = await fetch("/api/birth-time-conversation", { - method: "POST", - credentials: "same-origin", - headers: { - Accept: "application/x-ndjson, application/json", - "Content-Type": "application/json", - }, + const result = await postJson({ + url: "/api/birth-time-conversation", body, + retryLostResponse: false, }); - if (!response.ok) { - const payload = await readJsonPayload(response); - const nonJsonFailure = payload === null; - if (attempt === 0 && (response.status === 502 || nonJsonFailure)) continue; - return { response, payload, turn: null }; - } - if (response.headers.get("content-type")?.includes("application/x-ndjson")) { - const turn = await readStreamedTurn(response, { - onNarrativeDelta(text) { - emittedNarrative = true; - options.onNarrativeDelta?.(text); - }, - }); - return { response, payload: null, turn }; - } - return { response, payload: await readJsonPayload(response), turn: null }; + // postJson deliberately projects an unparseable non-ok body to null. Treating all null + // error payloads as replayable also covers proxies that mislabel HTML as application/json. + const nonJsonFailure = !result.response.ok && result.payload === null; + if (attempt === 0 && (result.response.status === 502 || nonJsonFailure)) continue; + return result; } catch (error) { - if (attempt === 0 && !emittedNarrative && isRetryableTransportError(error)) continue; + if (attempt === 0 && isRetryableTransportError(error)) continue; throw error; } } @@ -183,12 +137,11 @@ async function postCommandWithOneReplay( export async function sendConversationalRectificationCommand( command: ConversationalRectificationCommand, - options: ConversationalRectificationStreamOptions = {}, -): Promise { +): Promise { const request = conversationalRectificationCommandSchema.parse(command); const body = JSON.stringify(request); try { - const { response, payload, turn } = await postCommandWithOneReplay(body, options); + const { response, payload } = await postCommandWithOneReplay(body); if (!response.ok) { const parsed = publicErrorSchema.safeParse(payload); const safeServerMessage = response.status < 500 && parsed.success @@ -200,7 +153,20 @@ export async function sendConversationalRectificationCommand( safeServerMessage, ); } - return turn ?? conversationalRectificationResponseSchema.parse(payload); + const payloadRecord = payload !== null && typeof payload === "object" && !Array.isArray(payload) + ? payload as Readonly> + : null; + const history = conversationHistorySchema.safeParse(payloadRecord?.conversationMessages); + const turnPayload = payloadRecord && "conversationMessages" in payloadRecord + ? Object.fromEntries( + Object.entries(payloadRecord).filter(([key]) => key !== "conversationMessages"), + ) + : payload; + const turn = conversationalRectificationTurnSchema.parse(turnPayload); + if (history.success && history.data.length > 0) { + conversationHistoryByTurn.set(turn, history.data); + } + return turn; } catch (error) { if (error instanceof ConversationalRectificationRequestError) throw error; throw new ConversationalRectificationRequestError( diff --git a/frontend/src/lib/conversational-rectification/contracts.ts b/frontend/src/lib/conversational-rectification/contracts.ts index 5a84b459..8afbea41 100644 --- a/frontend/src/lib/conversational-rectification/contracts.ts +++ b/frontend/src/lib/conversational-rectification/contracts.ts @@ -3,6 +3,7 @@ import { boundedJson } from "./json-bounds.ts"; const actionIdSchema = z.string().uuid(); const caseIdSchema = z.string().uuid(); +const modelIdSchema = z.string().trim().min(1).max(64); const turnVersionSchema = z.number().int().nonnegative(); const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); const evidenceDomainSchema = z.enum([ @@ -35,6 +36,7 @@ export const conversationalRectificationCommandSchema = z.discriminatedUnion("ty z.object({ type: z.literal("start"), actionId: actionIdSchema, + modelId: modelIdSchema.optional(), pendingConsultationQuestion: z.string().trim().min(1).max(500).nullable().optional(), }).strict(), actionCommandSchema.extend({ @@ -42,10 +44,15 @@ export const conversationalRectificationCommandSchema = z.discriminatedUnion("ty }).strict(), actionCommandSchema.extend({ type: z.literal("answer"), + modelId: modelIdSchema.optional(), domain: evidenceDomainSchema.optional(), answer: z.string().trim().min(1).max(4_000), correctsEvidenceId: z.string().uuid().optional(), }).strict(), + actionCommandSchema.extend({ + type: z.literal("regenerate"), + modelId: modelIdSchema.optional(), + }).strict(), actionCommandSchema.extend({ type: z.literal("pause"), }).strict(), @@ -78,6 +85,11 @@ const evidenceRequestSchema = boundedJson(z.object({ domains: z.array(evidenceDomainSchema).min(1).max(4), datePrecision: z.enum(["month_preferred", "year_accepted"]), freeTextAllowed: z.literal(true), + // Optional for turns written before follow-up state was persisted. + followUp: z.object({ + kind: z.enum(["new_event", "event_date", "event_detail"]), + evidenceId: z.string().uuid().nullable(), + }).strict().optional(), }).strict(), 2_048); const evidenceRecapEntrySchema = boundedJson(z.object({ diff --git a/frontend/src/lib/conversational-rectification/convergence.ts b/frontend/src/lib/conversational-rectification/convergence.ts index 586bedfb..c508ee3a 100644 --- a/frontend/src/lib/conversational-rectification/convergence.ts +++ b/frontend/src/lib/conversational-rectification/convergence.ts @@ -1,13 +1,6 @@ import type { RectificationTechnicalPacket } from "./technical-packet.ts"; export const MINIMUM_SCOREABLE_EVENTS = 3; -export const MAXIMUM_SCOREABLE_EVENTS = 8; -export const MAXIMUM_PLATEAU_ROUNDS = 2; - -export type RangeCompletionReason = - | "evidence_limit" - | "no_discriminating_question" - | "range_plateau"; const plateauNotePrefix = "range_plateau_count:"; @@ -43,30 +36,3 @@ export function convergenceNotes(candidate: CandidateProgress, plateauCount: num `${plateauNotePrefix}${plateauCount}`, ]; } - -export function rangeCompletionReason(input: Readonly<{ - packet: RectificationTechnicalPacket; - scoreableEventCount: number; - plateauCount: number; - unansweredSuggestedDomainCount: number; -}>): RangeCompletionReason | null { - if (input.packet.candidate.status === "ready_for_confirmation") return null; - if (input.scoreableEventCount < MINIMUM_SCOREABLE_EVENTS) return null; - if (input.scoreableEventCount >= MAXIMUM_SCOREABLE_EVENTS - && input.unansweredSuggestedDomainCount === 0) return "evidence_limit"; - if (input.packet.suggestedDomains.length === 0) return "no_discriminating_question"; - if (input.plateauCount >= MAXIMUM_PLATEAU_ROUNDS - && input.unansweredSuggestedDomainCount === 0) return "range_plateau"; - return null; -} - -export function rangeCompletionCopy(reason: RangeCompletionReason): string { - switch (reason) { - case "evidence_limit": - return "已核对足够数量的真实经历,但现有证据仍不足以可靠确认某一分钟。"; - case "no_discriminating_question": - return "当前候选之间已经没有可由真实经历继续区分的问题。"; - case "range_plateau": - return "连续两轮补充经历后,候选范围没有继续稳定缩小。"; - } -} diff --git a/frontend/src/lib/conversational-rectification/errors.ts b/frontend/src/lib/conversational-rectification/errors.ts index 9d1c2efa..5d6536ea 100644 --- a/frontend/src/lib/conversational-rectification/errors.ts +++ b/frontend/src/lib/conversational-rectification/errors.ts @@ -47,6 +47,12 @@ const errorDefinitions = { message: "请先补全出生日期、时间和地点。", retryable: false, }, + model_unavailable: { + status: 409, + error: "模型暂不可用", + message: "请选择其他模型后重新发送,本次不会扣除点数。", + retryable: false, + }, insufficient_credits: { status: 409, error: "校正点数不足", diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index e3e0374f..55596b55 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -25,18 +25,24 @@ type ParsedDate = { readonly precision: "day" | "month" | "year"; }; -const chineseDatePattern = /(?:1\d{3}|20\d{2})\s*年(?:\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*(?:日|号))?)?/g; +const chineseDatePattern = /(?:1\d{3}|20\d{2}|\d{2})\s*年(?:\s*\d{1,2}\s*月(?:\s*\d{1,2}\s*(?:日|号))?)?/g; const isoDatePattern = /(?:1\d{3}|20\d{2})-(?:0[1-9]|1[0-2])(?:-(?:0[1-9]|[12]\d|3[01]))?/g; -const unresolvedRelativeTimePattern = /(?:次年|第二年|后来|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)/; -const leadingRelativeTimePattern = /^\s*(?:(?:次年|第二年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/; +const unresolvedRelativeTimePattern = /(?:来年|次年|第二年|翌年|后来|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)/; +const leadingRelativeTimePattern = /^\s*(?:(?:来年|次年|第二年|翌年|后来(?:又)?|此前|同年|当年|那年|随后|先前|然后|之前|之后|今年|去年|前年|明年)\s*)+/; const missingEventSummary = "事件内容待补充"; -function normalizedDate(value: string): ParsedDate | null { - const chinese = value.match(/^((?:1\d{3}|20\d{2}))\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/); +function normalizedDate(value: string, asOfDate: string): ParsedDate | null { + const chinese = value.match(/^((?:1\d{3}|20\d{2}|\d{2}))\s*年(?:\s*(\d{1,2})\s*月(?:\s*(\d{1,2})\s*(?:日|号))?)?$/); const iso = value.match(/^((?:1\d{3}|20\d{2}))-(\d{2})(?:-(\d{2}))?$/); const match = chinese ?? iso; if (!match) return null; - const year = Number(match[1]); + const rawYear = match[1] ?? ""; + const asOfYear = Number(asOfDate.slice(0, 4)); + const currentCentury = Math.floor(asOfYear / 100) * 100; + const expandedYear = currentCentury + Number(rawYear); + const year = rawYear.length === 2 + ? expandedYear <= asOfYear ? expandedYear : expandedYear - 100 + : Number(rawYear); const rawMonth = match[2]; if (!rawMonth) return { value: String(year), precision: "year" }; const month = Number(rawMonth); @@ -56,11 +62,11 @@ function normalizedDate(value: string): ParsedDate | null { }; } -function datesIn(value: string): ParsedDate[] { +function datesIn(value: string, asOfDate: string): ParsedDate[] { const matches = [...value.matchAll(chineseDatePattern), ...value.matchAll(isoDatePattern)] .sort((left, right) => (left.index ?? 0) - (right.index ?? 0)); return matches.flatMap((match) => { - const parsed = normalizedDate(match[0]); + const parsed = normalizedDate(match[0], asOfDate); return parsed ? [parsed] : []; }); } @@ -177,10 +183,10 @@ export function extractLifeEventEvidence( const events: ExtractedLifeEventEvidence[] = []; for (const fragments of splitSentences(input.rawText.normalize("NFKC"))) { - const sentenceDates = datesIn(fragments.join("并")); + const sentenceDates = datesIn(fragments.join("并"), input.asOfDate); const sharedDate = sentenceDates.length === 1 ? sentenceDates[0] ?? null : null; for (const fragment of fragments) { - const ownDates = datesIn(fragment); + const ownDates = datesIn(fragment, input.asOfDate); const unresolvedRelativeTime = ownDates.length === 0 && unresolvedRelativeTimePattern.test(fragment); const date = ownDates.length === 1 ? ownDates[0] ?? null diff --git a/frontend/src/lib/conversational-rectification/narrative-agent.ts b/frontend/src/lib/conversational-rectification/narrative-agent.ts index 51a2450d..08bf860b 100644 --- a/frontend/src/lib/conversational-rectification/narrative-agent.ts +++ b/frontend/src/lib/conversational-rectification/narrative-agent.ts @@ -10,6 +10,7 @@ export type RectificationNarrativePhase = "first" | "intermediate" | "final"; export type RectificationNarrativeContext = Readonly<{ latestUserText?: string; latestEvidence?: ReadonlyArray<{ + id?: string; dateLabel: string; summary: string; domain: RectificationEvidenceDomain; @@ -36,7 +37,14 @@ export type RectificationNarrativeContext = Readonly<{ const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); const modelIdSchema = z.string().trim().min(1).max(120); const validatorVersion = "rectification-narrative-grounding-v2"; +// Keep the complete route within its 60 second platform budget. The first +// attempt gets enough time for the richer model, while the second is a short, +// independent recovery attempt that production routes to the Flash model. +const narrativeAttemptTimeoutMs = [38_000, 14_000] as const; const domainSchema = z.enum(["career", "education", "finance", "health_pressure", "relocation", "relationship", "family", "other"]); +export const rectificationEvidenceDomainOutputSchema = z.object({ + domain: domainSchema, +}).strict(); const broadYearRangePattern = /(?:19|20)\d{2}\s*年?\s*(?:[-–—~~至到\/]|\.\.)\s*(?:19|20)\d{2}\s*年?/i; const proposedYearAlternativesPattern = /(?:19|20)\d{2}\s*年?\s*(?:还是|或者|或是|或|、|,|,)\s*(?:19|20)\d{2}\s*年?/i; const choiceQuestionPattern = /(?:哪(?:一|个)?(?:年|年份|年代|时间段|区间|时期)|哪个时间段|还是|选择|选项|更符合|更匹配|A\s*[.、::)]|B\s*[.、::)]|which\s+(?:year|period|range)|options?)/i; @@ -70,10 +78,31 @@ export const rectificationNarrativeOutputSchema = z.object({ domains: z.array(domainSchema).min(1).max(4), datePrecision: z.enum(["month_preferred", "year_accepted"]), prompt: z.string().trim().min(1).max(1_000), + followUp: z.object({ + kind: z.enum(["new_event", "event_date", "event_detail"]), + evidenceId: z.string().uuid().nullable(), + }).strict().default({ kind: "new_event", evidenceId: null }), }).strict().nullable(), }).strict(); -export type RectificationNarrativeModelOutput = z.infer; +export const rectificationNarrativeAuthoredOutputSchema = z.object({ + narrative: z.string().trim().min(1).max(12_000), + evidenceRequest: z.object({ + // Domain routing is private scoring metadata. Older providers may still + // return it, but the server always replaces it from the technical packet. + domains: z.array(domainSchema).min(1).max(4).optional(), + datePrecision: z.enum(["month_preferred", "year_accepted"]), + prompt: z.string().trim().min(1).max(1_000), + followUp: z.object({ + kind: z.enum(["new_event", "event_date", "event_detail"]), + evidenceId: z.string().uuid().nullable(), + }).strict().default({ kind: "new_event", evidenceId: null }), + }).strict().nullable(), +}).strict(); + +// Callers may construct a pre-parse model payload without the defaulted +// follow-up field; parsed runtime output always receives the schema default. +export type RectificationNarrativeModelOutput = z.input; export type NarrativeValidation = { readonly valid: boolean; @@ -82,7 +111,20 @@ export type NarrativeValidation = { export interface RectificationNarrativeGenerator { readonly modelId: string; - generate(prompt: string): Promise<{ readonly text: string }>; + classifyEvidenceDomain?( + input: Readonly<{ + text: string; + recentEvidence: readonly Readonly<{ + summary: string; + domain: RectificationEvidenceDomain; + }>[]; + }>, + options?: Readonly<{ signal?: AbortSignal }>, + ): Promise; + generate( + prompt: string, + options?: Readonly<{ signal?: AbortSignal; attempt?: 1 | 2 }>, + ): Promise<{ readonly text: string; readonly modelId?: string }>; } export type RectificationNarrativeResult = { @@ -101,16 +143,70 @@ export type RectificationNarrativeResult = { }; }; -function unique(values: readonly string[]): string[] { +function unique(values: readonly T[]): T[] { return [...new Set(values)]; } -function parseModelOutput(text: string): RectificationNarrativeModelOutput { +function groundedEvidenceDomains( + requested: readonly RectificationEvidenceDomain[] | undefined, + packet: RectificationTechnicalPacket, +): RectificationEvidenceDomain[] { + const suggested = packet.suggestedDomains.slice(0, 4).map((item) => item.domain); + if (!requested?.length) return suggested; + const allowed = new Set(suggested); + const grounded = unique(requested.filter((domain) => allowed.has(domain))); + return grounded.length > 0 ? grounded : suggested; +} + +function completeAuthoredOutput( + output: z.infer, + packet: RectificationTechnicalPacket, +): RectificationNarrativeModelOutput { + const evidenceRequest = output.evidenceRequest === null ? null : { + datePrecision: output.evidenceRequest.datePrecision, + prompt: output.evidenceRequest.prompt, + followUp: output.evidenceRequest.followUp, + domains: groundedEvidenceDomains(output.evidenceRequest.domains, packet), + }; + return { + ...output, + evidenceRequest, + candidateStatus: packet.candidate.status, + representativeTime: packet.candidate.representativeTime, + rangeStart: packet.candidate.range.startTime, + rangeEnd: packet.candidate.range.endTime, + useBoundary: packet.useBoundary, + stableLayers: packet.stableLayers.map((item) => item.layer), + sensitiveLayers: packet.sensitiveLayers.map((item) => item.layer), + referenceIds: [], + domainReasons: packet.suggestedDomains.map((item) => ({ ...item })), + }; +} + +function parseModelOutput( + text: string, + packet: RectificationTechnicalPacket, +): RectificationNarrativeModelOutput { const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); const start = normalized.indexOf("{"); const end = normalized.lastIndexOf("}"); if (start < 0 || end <= start) throw new TypeError("narrative output is not JSON"); - return rectificationNarrativeOutputSchema.parse(JSON.parse(normalized.slice(start, end + 1))); + const parsed: unknown = JSON.parse(normalized.slice(start, end + 1)); + const legacy = rectificationNarrativeOutputSchema.safeParse(parsed); + if (legacy.success) { + return { + ...legacy.data, + evidenceRequest: legacy.data.evidenceRequest === null ? null : { + ...legacy.data.evidenceRequest, + // The next conversational topic is authored by the model, but the + // scoring-domain allowlist remains server-owned. Do not let a useful + // answer fail merely because the model described that topic with a + // different internal domain label. + domains: groundedEvidenceDomains(legacy.data.evidenceRequest.domains, packet), + }, + }; + } + return completeAuthoredOutput(rectificationNarrativeAuthoredOutputSchema.parse(parsed), packet); } function narrativeTimes(value: string): string[] { @@ -122,11 +218,9 @@ function narrativeLayers(value: string): string[] { } function narrativeReferences(value: string): string[] { - const bracketed = [...value.matchAll(/【([^】]+)】/g)] + return [...value.matchAll(/【([^】]+)】/g)] .map((match) => match[1] ?? "") .filter(Boolean); - const plainTechnicalIds = value.match(/\b[A-Za-z][A-Za-z0-9]*(?:[-_][A-Za-z0-9]+)+\b/g) ?? []; - return unique([...bracketed, ...plainTechnicalIds]); } function isGenericBroadYearChoiceQuestionnaire(value: string): boolean { @@ -151,15 +245,12 @@ function proseFields(output: RectificationNarrativeModelOutput): readonly { ]; } -function pairKey(value: { readonly domain: RectificationEvidenceDomain; readonly layer: string }): string { - return `${value.domain}\0${value.layer}`; -} - export function validateNarrativeAgainstPacket( output: RectificationNarrativeModelOutput, packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase = "first", ): NarrativeValidation { + void phase; const issues: string[] = []; const candidate = packet.candidate; if (output.candidateStatus !== candidate.status) { @@ -186,24 +277,15 @@ export function validateNarrativeAgainstPacket( if (!packet.referenceIds.includes(reference)) issues.push(`reference ${reference} is not packet-grounded`); } const allowedDomains = new Map(packet.suggestedDomains.map((item) => [item.domain, item.layer])); - const packetReasons = new Map(packet.suggestedDomains.map((item) => [pairKey(item), item.reason])); - for (const [index, reason] of output.domainReasons.entries()) { - const expectedReason = packetReasons.get(pairKey(reason)); - if (!expectedReason) { + for (const reason of output.domainReasons) { + if (allowedDomains.get(reason.domain) !== reason.layer) { issues.push(`domain reason ${reason.domain}/${reason.layer} is not packet-grounded`); - } else if (reason.reason !== expectedReason) { - issues.push(`domainReasons[${index}].reason must use the packet discrimination explanation`); } } if (output.evidenceRequest) { for (const domain of output.evidenceRequest.domains) { if (!allowedDomains.has(domain)) issues.push(`evidence domain ${domain} is not packet-grounded`); } - if (!requestsPastDatedEvent(output.evidenceRequest.prompt)) { - issues.push("evidence request must ask for a real past event by year and month"); - } - } else if (phase !== "final") { - issues.push("non-final turns require an evidence request"); } const allowedTimes = [candidate.representativeTime, candidate.range.startTime, candidate.range.endTime]; @@ -225,40 +307,81 @@ export function validateNarrativeAgainstPacket( issues.push(`${field.path} is a forbidden generic broad-year choice questionnaire`); } } - if (phase === "first") { - if (!output.narrative.includes(candidate.range.startTime) - || !output.narrative.includes(candidate.range.endTime) - || !/(?:待验证|候选|核对)/.test(output.narrative)) { - issues.push("first narrative must state the pending candidate range"); - } - if (narrativeLayers(output.narrative).length > 0) { - issues.push("visible evidence narrative must not expose technical layer tokens"); - } - if (!requestsPastDatedEvent(output.narrative)) { - issues.push("first narrative must request real past events by year and month"); - } - if (!/(?:不是[\s\S]*确认|不能[\s\S]*(?:确定|确认)|仅[\s\S]*候选|必须[\s\S]*确认)/.test(output.narrative)) { - issues.push("first narrative must state the candidate use boundary"); - } + const authoredEventTable = markdownSection(output.narrative, "### 事件验证表"); + if (authoredEventTable && /(?:\|\s*(?:得分|score)(?:\s*\/\s*状态)?\s*\||内部(?:分数|权重))/i.test(authoredEventTable)) { + issues.push("event validation table exposes a private score or weight"); } const uniqueIssues = unique(issues); return { valid: uniqueIssues.length === 0, issues: uniqueIssues }; } -function grounding(packet: RectificationTechnicalPacket) { +function grounding(packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase) { const projected = projectRectificationTechnicalPacket(packet); - return { + const base = { calculationVersion: packet.calculationVersion, candidate: projected.candidate, useBoundary: packet.useBoundary, - sensitivityScope: projected.technicalReceipt.sensitivityScope, - stableLayers: packet.stableLayers, - sensitiveLayers: packet.sensitiveLayers, - scoredHistoricalEvidence: packet.scoredHistoricalEvidence, - suggestedDomains: packet.suggestedDomains, - referenceIds: packet.referenceIds, - futureWindows: projected.futureWindows, - expertWorkflow: packet.expertWorkflow, + stableLayers: packet.stableLayers.map(({ layer }) => layer), + sensitiveLayers: packet.sensitiveLayers.map(({ layer }) => layer), + }; + if (phase === "first") return base; + return { + ...base, + sensitivityScope: { + rangeStart: projected.technicalReceipt.sensitivityScope.rangeStart, + rangeEnd: projected.technicalReceipt.sensitivityScope.rangeEnd, + }, + layerValues: { + stable: packet.stableLayers.map(({ layer, values }) => ({ layer, values })), + sensitive: packet.sensitiveLayers.map(({ layer, values }) => ({ layer, values })), + }, + expertWorkflow: packet.expertWorkflow ? { + boundary: packet.expertWorkflow.boundary, + candidateWindows: packet.expertWorkflow.candidateWindows, + techniqueStates: packet.expertWorkflow.techniqueAuditTable + .filter((row) => phase === "final" || row.status === "used" || row.status === "partial") + .map((row) => ({ + technique: row.technique, + status: row.status, + boundary: row.boundary, + })), + confirmationAllowed: phase === "final" ? packet.expertWorkflow.confirmationAllowed : undefined, + hardBlockers: phase === "final" ? packet.expertWorkflow.hardBlockers : undefined, + } : undefined, + }; +} + +function narrativeConversationContext(context: RectificationNarrativeContext) { + return { + latestUserText: context.latestUserText, + latestEvidence: context.latestEvidence?.map(({ id, dateLabel, summary }) => ({ + id, + dateLabel, + summary, + })), + eventLedger: context.eventLedger?.map(({ + id, + rawText, + dateLabel, + summary, + extractionStatus, + active, + correctsEvidenceIds, + }) => ({ + id, + rawText, + dateLabel, + summary, + extractionStatus, + active, + correctsEvidenceIds, + })), + unresolvedEvidence: context.unresolvedEvidence?.map(({ id, rawText, summary, dateLabel }) => ({ + id, + rawText, + summary, + dateLabel, + })), }; } @@ -273,67 +396,33 @@ function ensureSentence(value: string, sentence: string): string { return trimmed ? `${trimmed}\n${sentence}` : sentence; } -function hasVisibleEvidenceQuestion(value: string): boolean { - const withoutRhetoricalPrompts = value.replace(/(?:好吗|可以吗|行吗)[??]/g, ""); - return /[??]/.test(withoutRhetoricalPrompts) - || /请(?:先|再|补充|告诉|提供|确认|回忆)/.test(value) - || /(?:先说一件|说说|告诉我)/.test(value); +function endsWithIncompletePrompt(value: string): boolean { + return /(?:[-—–::,,、]|\.\.\.|…|我需要(?:确认|了解|知道)|关键信息)\s*$/.test(value.trim()); } -function requestsPastDatedEvent(value: string): boolean { - const asksForDate = /(?:年|月|日期|时间|什么时候)/.test(value); - const refersToPastEvent = /(?:已经发生|已发生|过去|当时|后来|经历|发生|开始|毕业|入职|离职|结束|分手|事故|手术)/.test(value); - const asksOnlyAboutFuture = /(?:未来|预计|计划|打算)/.test(value) && !refersToPastEvent; - return asksForDate && refersToPastEvent && !asksOnlyAboutFuture; +function hasExplicitQuestion(value: string): boolean { + return /[??]/.test(value); } function repairRequiredSafetyLanguage( output: RectificationNarrativeModelOutput, - packet: RectificationTechnicalPacket, phase: RectificationNarrativePhase, ): RectificationNarrativeModelOutput { let narrative = output.narrative; - let evidenceRequest = output.evidenceRequest; + const evidenceRequest = output.evidenceRequest; const modelEvidencePrompt = output.evidenceRequest?.prompt.trim() ?? ""; - if (phase !== "final" && evidenceRequest) { - if (!requestsPastDatedEvent(evidenceRequest.prompt)) { - evidenceRequest = { - ...evidenceRequest, - prompt: `请以已经发生的真实事件为准,并尽量说明年份和月份。${evidenceRequest.prompt}`, - }; - } - } - - if (phase === "first") { - const candidate = packet.candidate; - const statesCandidateRange = narrative.includes(candidate.range.startTime) - && narrative.includes(candidate.range.endTime) - && /(?:待验证|候选|核对)/.test(narrative); - const statesUseBoundary = /(?:不是[\s\S]*确认|不能[\s\S]*(?:确定|确认)|仅[\s\S]*候选|必须[\s\S]*确认)/.test(narrative); - - if (!statesCandidateRange || !statesUseBoundary) { - narrative = [ - `我们先在 ${candidate.range.startTime}–${candidate.range.endTime} 内核对候选;这个范围不能直接当作已经确认的出生时间。`, - narrative.trim(), - ].filter(Boolean).join("\n"); - } - } - - // evidenceRequest.prompt is an internal planning field and is intentionally not - // projected to the public turn. Keep the model-authored acknowledgement, but make - // sure the one concrete follow-up question is also visible in the chat bubble. - if (phase !== "final" && evidenceRequest && !hasVisibleEvidenceQuestion(narrative)) { + // Every collecting turn must end with one visible, model-authored question. + // evidenceRequest.prompt is generated in the same model call, so appending it + // repairs truncated or analysis-only prose without introducing a business + // template or changing the Agent's chosen conversational direction. + if (phase !== "final" && evidenceRequest && ( + endsWithIncompletePrompt(narrative) + || !hasExplicitQuestion(narrative) + )) { narrative = ensureSentence(narrative, modelEvidencePrompt || evidenceRequest.prompt); } - if (phase === "first" && !requestsPastDatedEvent(narrative)) { - narrative = ensureSentence( - narrative, - "请从已经发生的真实经历开始,尽量写明哪一年、哪一月。", - ); - } - return { ...output, narrative, @@ -341,38 +430,119 @@ function repairRequiredSafetyLanguage( }; } +type NarrativeDiagnosticIssueCode = + | "candidate_status_mismatch" + | "representative_time_mismatch" + | "candidate_range_mismatch" + | "use_boundary_mismatch" + | "ungrounded_domain_reason" + | "ungrounded_evidence_domain" + | "broad_year_questionnaire" + | "ungrounded_layer" + | "ungrounded_reference" + | "timeout" + | "schema_invalid" + | "generation_error" + | "narrative_validation_failed"; + +function diagnosticIssueCodes(issues: readonly string[]): NarrativeDiagnosticIssueCode[] { + const codes = issues.map((issue): NarrativeDiagnosticIssueCode => { + if (issue.startsWith("candidateStatus ")) return "candidate_status_mismatch"; + if (issue.startsWith("representativeTime ")) return "representative_time_mismatch"; + if (issue === "candidate range is not packet-grounded") return "candidate_range_mismatch"; + if (issue === "useBoundary is not packet-grounded") return "use_boundary_mismatch"; + if (issue.startsWith("domain reason ")) return "ungrounded_domain_reason"; + if (issue.startsWith("evidence domain ")) return "ungrounded_evidence_domain"; + if (issue.includes("broad-year choice questionnaire")) return "broad_year_questionnaire"; + if (issue.includes(" layer ") || issue.startsWith("stable layer ") || issue.startsWith("sensitive layer ")) { + return "ungrounded_layer"; + } + if (issue.includes(" reference ") || issue.startsWith("reference ")) return "ungrounded_reference"; + if (issue === "TimeoutError" || issue === "AbortError") return "timeout"; + if (/^(?:root|[\w.]+):/.test(issue)) return "schema_invalid"; + if (/Error$/.test(issue) || issue === "NarrativeOutputError") return "generation_error"; + return "narrative_validation_failed"; + }); + return [...new Set(codes)]; +} + +function logNarrativeGeneration(input: Readonly<{ + phase: RectificationNarrativePhase; + retryCount: 0 | 1; + fallbackUsed: boolean; + source: "model" | "model_retry" | "fallback" | "failed"; + issues: readonly string[]; + startedAt: number; +}>): void { + const payload = { + phase: input.phase, + retryCount: input.retryCount, + fallbackUsed: input.fallbackUsed, + source: input.source, + issueCodes: diagnosticIssueCodes(input.issues), + elapsedMs: Math.max(0, Date.now() - input.startedAt), + }; + if (input.fallbackUsed || input.source === "failed") { + console.warn("[rectification-narrative]", JSON.stringify(payload)); + return; + } + console.info("[rectification-narrative]", JSON.stringify(payload)); +} + function promptFor( phase: RectificationNarrativePhase, packet: RectificationTechnicalPacket, context: RectificationNarrativeContext, retryIssues: readonly string[] = [], ): string { + if (phase === "first") { + return JSON.stringify({ + task: "用自然、有人味的中文开启生时校正;简短回应当前范围,再只问一个最有信息量的问题。不要使用固定模板。", + phase, + conversationContext: narrativeConversationContext(context), + packet: grounding(packet, phase), + output: "只返回 narrative,以及 evidenceRequest。evidenceRequest 可用 domains 作为不可见路由元数据,并包含 datePrecision、prompt、followUp;narrative 不得输出或讨论事件分类、领域标签,也不要重复输出候选状态、时间、分盘或引用字段。", + safety: "技术事实只能来自 packet;不能确认未经验证的分钟;不得展示内部权重、分数、事件分类或内部路由元数据。", + retryIssues: boundedReceiptIssues(retryIssues), + }); + } return JSON.stringify({ task: "write_grounded_rectification_narrative", phase, - conversationContext: context, - packet: grounding(packet), + conversationContext: narrativeConversationContext(context), + packet: grounding(packet, phase), outputContract: { - candidateFactsMustMatch: true, + returnOnlyNarrativeAndEvidenceRequest: true, + candidateFactsAreInjectedByServerAndMustNotBeRepeatedAsJsonFields: true, onlyListedLayersAndReferences: true, - everyAuthoredStringMustBeGrounded: true, - keepTechnicalLayerValuesOutOfVisibleNarrative: phase !== "final", - askExactlyOneHighInformationQuestion: phase !== "final", - acknowledgeLatestEvidenceSpecificallyBeforeAsking: phase === "intermediate", - doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked: phase === "intermediate", - finishCurrentEventBeforeSwitchingDomains: phase === "intermediate", - resolveDateContradictionsBeforeScoring: phase === "intermediate", - mergeSameEventDetailsWithoutDoubleCounting: phase === "intermediate", - askForTheSingleMostInformativeMissingDetail: phase === "intermediate", - treatCauseResultAgencyAndNextTransitionAsPartsOfTheCurrentEvent: phase === "intermediate", - useEventLedgerToAvoidRepeatingAnsweredQuestions: phase === "intermediate", - usePacketDomainReasonTextExactly: true, - requestRealPastEventsByYearAndMonth: phase !== "final", + everyAuthoredTechnicalClaimMustBeGrounded: true, futureWindowsAreContextOnly: true, genericBroadYearRangeQuestionnaireForbidden: true, useExpertWorkflowAsTechniqueTruth: true, blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed: true, - finalNarrativeIncludesAConciseTechniqueAuditTable: phase === "final", + technicalTablesMayAppearWhenRelevant: true, + completeTechnicalTableSummaryRequiredBeforeConfirmation: phase === "final", + unchangedTechnicalTablesShouldNotBeRepeated: true, + privateScoresAndCandidateWeightsMustNeverBeShown: true, + internalEventDomainsAndRoutingMustNeverBeShown: true, + }, + conversationGuidance: { + preferOneHighInformationQuestion: phase !== "final", + everyNonFinalReplyMustEndWithExactlyOneVisibleQuestion: phase !== "final", + respondToLatestEvidenceBeforeAsking: phase === "intermediate", + neverMentionHowTheEventWasClassifiedOrLabeledInternally: phase === "intermediate", + doNotVolunteerNotEvaluatedOrBlockedTechniqueInventory: phase === "intermediate", + doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked: phase === "intermediate", + continueCurrentEventWhenItRemainsInformative: phase === "intermediate", + resolveDateContradictionsBeforeScoring: phase === "intermediate", + mergeSameEventDetailsWithoutDoubleCounting: phase === "intermediate", + treatCauseResultAgencyAndNextTransitionAsPartsOfTheCurrentEvent: phase === "intermediate", + useEventLedgerToAvoidRepeatingAnsweredQuestions: phase === "intermediate", + askForDatesOnlyWhenNeededToIdentifyOrScoreTheEvent: phase !== "final", + persistFollowUpState: phase !== "final" + ? "Treat followUp as advisory metadata: use event_detail or event_date with an existing evidenceId when clear; otherwise omit assumptions and use new_event with null evidenceId." + : false, + domainReasonsMayBeNaturallyParaphrased: true, }, retryIssues: boundedReceiptIssues(retryIssues), }); @@ -407,39 +577,177 @@ function fallbackOutput( referenceIds: [], domainReasons: packet.suggestedDomains.map((item) => ({ ...item })), evidenceRequest: phase === "final" ? null : { - domains: packet.suggestedDomains.slice(0, 1).map((item) => item.domain), + domains: packet.suggestedDomains.slice(0, 4).map((item) => item.domain), datePrecision: "month_preferred", prompt: "请提供已经发生的真实事件,并尽量写明哪一年、哪一月以及发生了什么。", + followUp: { kind: "new_event", evidenceId: null }, }, }; } +function markdownCell(value: unknown): string { + return String(value ?? "") + .replace(/\|/g, "\\|") + .replace(/\r?\n/g, " ") + .trim() || "—"; +} + +function analysisTableSections( + packet: RectificationTechnicalPacket, + context: RectificationNarrativeContext, +): ReadonlyArray<{ + readonly heading: string; + readonly header: string; + readonly markdown: string; +}> { + const workflow = packet.expertWorkflow; + const techniqueRows = workflow?.techniqueAuditTable ?? []; + const techniqueTable = [ + "### Technique Audit Table", + "| 技法 | 状态 | 证据 | 使用边界 |", + "|---|---|---|---|", + ...(techniqueRows.length > 0 + ? techniqueRows.map((row) => ( + `| ${markdownCell(row.technique)} | ${markdownCell(row.status)} | ${markdownCell(row.evidence.join("、"))} | ${markdownCell(row.boundary)} |` + )) + : ["| — | 待评估 | 尚无可展示证据 | 不得声称已运行 |"]), + ].join("\n"); + + const scoreByEvidenceId = new Map(packet.scoredHistoricalEvidence.map((item) => [item.evidenceId, item])); + const activeEvents = (context.eventLedger ?? []).filter((item) => item.active); + const eventTable = [ + "### 事件验证表", + "| 时间 | 事件 | 领域 | 验证状态 | 结论 |", + "|---|---|---|---|---|", + ...(activeEvents.length > 0 + ? activeEvents.map((event) => { + const score = scoreByEvidenceId.get(event.id); + const status = score ? "已纳入验证" : "待验证"; + const conclusion = score ? "已纳入当前候选比较" : "尚未完成候选比较"; + return `| ${markdownCell(event.dateLabel)} | ${markdownCell(event.summary)} | ${markdownCell(domainLabels[event.domain])} | ${status} | ${conclusion} |`; + }) + : ["| — | 尚无可评分事件 | — | 待验证 | 尚未完成候选比较 |"]), + ].join("\n"); + + const candidateRows = [ + ...(workflow?.candidateWindows ?? []).map((window) => ({ + range: `${window.startTime}–${window.endTime}`, + layer: "候选窗口", + status: window.status, + evidence: packet.useBoundary, + })), + ...packet.stableLayers.map((layer) => ({ + range: `${packet.candidate.range.startTime}–${packet.candidate.range.endTime}`, + layer: layer.layer, + status: "stable", + evidence: layer.values.join(" / "), + })), + ...packet.sensitiveLayers.map((layer) => ({ + range: `${packet.candidate.range.startTime}–${packet.candidate.range.endTime}`, + layer: layer.layer, + status: "minute_sensitive", + evidence: layer.values.join(" / "), + })), + ]; + const candidateTable = [ + "### 候选时间差异表", + "| 候选范围 | 层 | 状态 | 差异 / 证据 |", + "|---|---|---|---|", + ...(candidateRows.length > 0 + ? candidateRows.map((row) => `| ${markdownCell(row.range)} | ${markdownCell(row.layer)} | ${markdownCell(row.status)} | ${markdownCell(row.evidence)} |`) + : ["| — | — | 待计算 | 尚无可展示差异 |"]), + ].join("\n"); + + return [ + { + heading: "### Technique Audit Table", + header: "| 技法 | 状态 | 证据 | 使用边界 |", + markdown: techniqueTable, + }, + { + heading: "### 事件验证表", + header: "| 时间 | 事件 | 领域 | 验证状态 | 结论 |", + markdown: eventTable, + }, + { + heading: "### 候选时间差异表", + header: "| 候选范围 | 层 | 状态 | 差异 / 证据 |", + markdown: candidateTable, + }, + ]; +} + +function markdownSection(narrative: string, heading: string): string | null { + const start = narrative.indexOf(heading); + if (start < 0) return null; + const nextHeading = narrative.indexOf("\n### ", start + heading.length); + return narrative.slice(start, nextHeading < 0 ? undefined : nextHeading); +} + +function hasCompleteAnalysisTable( + narrative: string, + section: Readonly<{ readonly heading: string; readonly header: string }>, +): boolean { + const authoredSection = markdownSection(narrative, section.heading); + if (!authoredSection || !authoredSection.includes(section.header)) return false; + const tableLines = authoredSection + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("|") && line.endsWith("|")); + return tableLines.length >= 3; +} + +function appendFinalAnalysisTables( + narrative: string, + packet: RectificationTechnicalPacket, + context: RectificationNarrativeContext, +): string { + const missing = analysisTableSections(packet, context) + .filter((section) => !hasCompleteAnalysisTable(narrative, section)) + .map((section) => section.markdown); + return missing.length > 0 ? [narrative.trim(), ...missing].filter(Boolean).join("\n\n") : narrative; +} + export async function generateRectificationNarrative(input: { readonly phase: RectificationNarrativePhase; readonly packet: RectificationTechnicalPacket; readonly generator: RectificationNarrativeGenerator; readonly context?: RectificationNarrativeContext; }): Promise { - const modelId = modelIdSchema.parse(input.generator.modelId); + const startedAt = Date.now(); + const defaultModelId = modelIdSchema.parse(input.generator.modelId); let issues: readonly string[] = []; for (const attempt of [1, 2] as const) { try { + const signal = AbortSignal.timeout(narrativeAttemptTimeoutMs[attempt - 1]); const generated = await input.generator.generate(promptFor( input.phase, input.packet, input.context ?? {}, issues, - )); + ), { signal, attempt }); + const modelId = modelIdSchema.parse(generated.modelId ?? defaultModelId); const output = repairRequiredSafetyLanguage( - parseModelOutput(generated.text), - input.packet, + parseModelOutput(generated.text, input.packet), input.phase, ); const validation = validateNarrativeAgainstPacket(output, input.packet, input.phase); if (validation.valid) { + const narrative = input.phase === "final" + ? appendFinalAnalysisTables(output.narrative, input.packet, input.context ?? {}) + : output.narrative; + const finalOutput = narrative === output.narrative ? output : { ...output, narrative }; + logNarrativeGeneration({ + phase: input.phase, + retryCount: attempt === 1 ? 0 : 1, + fallbackUsed: false, + source: attempt === 1 ? "model" : "model_retry", + issues, + startedAt, + }); return { - narrative: output.narrative, - output, + narrative, + output: finalOutput, attempts: attempt, fallbackUsed: false, allowEvidenceScoringAdvance: true, @@ -453,30 +761,48 @@ export async function generateRectificationNarrative(input: { }, }; } - issues = validation.issues; + issues = [...issues, ...validation.issues]; } catch (error) { - issues = error instanceof z.ZodError + const attemptIssues = error instanceof z.ZodError ? error.issues.map((issue) => `${issue.path.join(".") || "root"}:${issue.code}`) : [error instanceof Error ? error.name : "NarrativeOutputError"]; + issues = [...issues, ...attemptIssues]; } } + if (input.phase !== "final") { + logNarrativeGeneration({ + phase: input.phase, + retryCount: 1, + fallbackUsed: false, + source: "failed", + issues: boundedReceiptIssues(issues), + startedAt, + }); + throw new Error("RectificationNarrativeUnavailable"); + } const output = fallbackOutput(input.packet, input.phase); - console.warn("[rectification-narrative-fallback]", JSON.stringify({ + const narrative = input.phase === "final" + ? appendFinalAnalysisTables(output.narrative, input.packet, input.context ?? {}) + : output.narrative; + const finalOutput = narrative === output.narrative ? output : { ...output, narrative }; + logNarrativeGeneration({ phase: input.phase, - modelId, - attempts: 2, + retryCount: 1, + fallbackUsed: true, + source: "fallback", issues: boundedReceiptIssues(issues), - })); + startedAt, + }); return { - narrative: output.narrative, - output, + narrative, + output: finalOutput, attempts: 2, fallbackUsed: true, // The fallback is rendered entirely from the validated deterministic packet. // A prose-model failure must not discard scoreable evidence or block narrowing. allowEvidenceScoringAdvance: true, validationReceipt: { - modelId, + modelId: defaultModelId, schemaValidated: false, validatorVersion, retryCount: 1, diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 9e9b6949..cad44bb5 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -3,7 +3,6 @@ import { conversationalRectificationCommandSchema, conversationalRectificationTurnSchema, type ConversationalRectificationCommand, - type ConversationalRectificationResponse, type ConversationalRectificationTurn, } from "./contracts.ts"; import { ConversationalRectificationError } from "./errors.ts"; @@ -24,16 +23,12 @@ import { } from "./narrative-agent.ts"; import { projectRectificationTechnicalPacket, - type RectificationEvidenceDomain, type RectificationTechnicalPacket, } from "./technical-packet.ts"; import { convergenceNotes, MINIMUM_SCOREABLE_EVENTS, nextPlateauCount, - rangeCompletionCopy, - rangeCompletionReason, - type RangeCompletionReason, } from "./convergence.ts"; import type { ConversationalRectificationBilling } from "./billing.ts"; import { @@ -103,6 +98,7 @@ export type ConversationalRectificationService = Readonly<{ start(userId: string, command: CommandOf<"start">): Promise; resume(userId: string, command: CommandOf<"resume">): Promise; answer(userId: string, command: CommandOf<"answer">): Promise; + regenerate(userId: string, command: CommandOf<"regenerate">): Promise; pause(userId: string, command: CommandOf<"pause">): Promise; abandon(userId: string, command: CommandOf<"abandon">): Promise; confirm(userId: string, command: CommandOf<"confirm">): Promise; @@ -127,6 +123,9 @@ export function conversationalRectificationTelemetryOutcome( const transitionValidatorVersion = "conversational-rectification-orchestrator-v1"; const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/; const genericUncertaintyPattern = /(?:不知道|不确定)/; +const contextualRelativeMonthPattern = /(?:来年|次年|第二年|翌年|同年|当年|那年)\s*(\d{1,2})\s*月份?/; +const contextualBareMonthDayPattern = /^\s*(\d{1,2})\s*月\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/; +const contextualBareDayPattern = /^\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/; export function evidencePredatesBirthDate( evidence: Pick, @@ -143,6 +142,21 @@ export function evidencePredatesBirthDate( return boundary !== null && evidence.dateValue < boundary; } +function evidencePostdatesAsOfDate( + evidence: Pick, + asOfDate: string, +): boolean { + if (!evidence.dateValue) return false; + const boundary = evidence.datePrecision === "year" + ? asOfDate.slice(0, 4) + : evidence.datePrecision === "month" + ? asOfDate.slice(0, 7) + : evidence.datePrecision === "day" + ? asOfDate + : null; + return boundary !== null && evidence.dateValue > boundary; +} + function evidenceForDeclaredBirthDate( evidence: readonly LifeEventEvidence[], birthDate: string, @@ -171,7 +185,7 @@ function parseCommand( } type MutableCommand = Extract; function commandFingerprint(command: MutableCommand): string { @@ -184,12 +198,34 @@ function commandFingerprint(command: MutableCommand): string { return createHash("sha256").update(JSON.stringify(identity), "utf8").digest("hex"); } +function sameDeclaredBirthInput( + left: unknown, + right: unknown, +): boolean { + const leftParsed = declaredBirthInputSchema.safeParse(left); + const rightParsed = declaredBirthInputSchema.safeParse(right); + return leftParsed.success + && rightParsed.success + && stableJson(leftParsed.data) === stableJson(rightParsed.data); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)) + .map(([key, nested]) => `${JSON.stringify(key)}:${stableJson(nested)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + function visibleEvidenceSummary(value: string): string { const cleaned = value.replace(/(?:发生时间|事件详情)\s*[::]\s*/g, "").trim(); return cleaned || value; } -function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationResponse { +function publicTurn(value: StoredConversationalRectificationCase): ConversationalRectificationTurn { const parsed = conversationalRectificationTurnSchema.safeParse(value.latestTurn); if (!parsed.success) throw new ConversationalRectificationError("store_unavailable"); const evidenceDomains = new Map( @@ -197,7 +233,6 @@ function publicTurn(value: StoredConversationalRectificationCase): Conversationa ); return { ...parsed.data, - ...(value.messageHistory ? { messageHistory: [...value.messageHistory] } : {}), evidenceRecap: parsed.data.evidenceRecap.map((item) => ({ ...item, summary: visibleEvidenceSummary(item.summary), @@ -239,6 +274,35 @@ export function effectiveLifeEventEvidence< return evidence.filter((item) => !correctedIds.has(item.id)); } +function normalizedEventSemantics(value: string): string { + return value + .normalize("NFKC") + .toLocaleLowerCase("zh-CN") + .replace(/^(?:我|本人|其实|准确(?:的是)?|更正(?:为|成|:|:)*)+/u, "") + .replace(/[\p{P}\p{S}\s]+/gu, ""); +} + +function uniqueScoreableLifeEventEvidence( + evidence: ReadonlyArray, + birthDate: string, +): ReadonlyArray { + const seen = new Set(); + return effectiveLifeEventEvidence(evidence) + .filter((item) => item.scoreable === true + && item.extractionStatus !== "needs_clarification" + && !evidencePredatesBirthDate(item, birthDate)) + .filter((item) => { + const identity = [ + item.dateValue ?? "", + item.domain, + normalizedEventSemantics(item.eventSummary), + ].join("|"); + if (seen.has(identity)) return false; + seen.add(identity); + return true; + }); +} + function evidenceRecap(evidence: ReadonlyArray) { return effectiveLifeEventEvidence(evidence).slice(-20).map((item) => ({ id: item.id, @@ -263,6 +327,7 @@ function narrativeConversationContext(input: Readonly<{ return { latestUserText: input.latestUserText.trim().slice(0, 4_000), latestEvidence: evidenceRecap(input.newEvidence).map((item) => ({ + id: item.id, dateLabel: item.dateLabel, summary: item.summary, domain: item.domain, @@ -290,92 +355,6 @@ function narrativeConversationContext(input: Readonly<{ }; } -const progressDomainLabels = { - career: "事业", - education: "学业", - finance: "财务", - health_pressure: "健康与重大压力", - relocation: "搬迁", - relationship: "重要关系", - family: "家庭", - other: "其他关键经历", -} as const satisfies Readonly>; - -function evidenceProgressNarrative(input: Readonly<{ - previousCandidate: PrivateCandidateInput; - packet: RectificationTechnicalPacket; - newEvidence: ReadonlyArray; - allEvidence: ReadonlyArray; - scoreableEventCount: number; - willContinue: boolean; - authoredNarrative?: string | null; -}>): string { - const recorded = evidenceRecap(input.newEvidence); - const acknowledgement = recorded.length === 0 - ? "这段经历已经保存。" - : `已记录:${recorded.map((item) => `${item.dateLabel} · ${item.summary}`).join(";")}。`; - const previousStart = input.previousCandidate.rangeStart; - const previousEnd = input.previousCandidate.rangeEnd; - const nextStart = input.packet.candidate.range.startTime; - const nextEnd = input.packet.candidate.range.endTime; - const rangeChanged = previousStart !== nextStart || previousEnd !== nextEnd; - const progress = input.scoreableEventCount < MINIMUM_SCOREABLE_EVENTS - ? `当前累计 ${input.scoreableEventCount} 条可评分经历;系统至少需要 ${MINIMUM_SCOREABLE_EVENTS} 条时间明确的经历才开始事件排序,所以本轮候选范围暂时保持 ${nextStart}–${nextEnd}。` - : rangeChanged - ? `候选范围已从 ${previousStart ?? "原范围"}–${previousEnd ?? "原范围"} 更新为 ${nextStart}–${nextEnd}。` - : `本轮已纳入 ${input.scoreableEventCount} 条可评分经历,但候选范围暂未稳定缩小;这不是提交失败。`; - const suggested = nextEvidenceDomains(input.packet, input.allEvidence) - .map((item) => progressDomainLabels[item.domain]); - const nextStep = input.willContinue && suggested.length > 0 - ? `下一步:请优先补充一件${suggested.join("或")}领域已经发生的事件,并选择大致年月。` - : ""; - const differenceBasis = input.packet.suggestedDomains.length > 0 - ? `本轮区分重点:${input.packet.suggestedDomains.slice(0, 2) - .map((item) => `${progressDomainLabels[item.domain]}事件用于比较 ${item.layer}`) - .join(";")}。` - : ""; - const authored = input.authoredNarrative?.trim(); - if (authored) return authored.slice(0, 12_000); - return [acknowledgement, authored, progress, nextStep, differenceBasis] - .filter(Boolean) - .join("\n") - .slice(0, 12_000); -} - -function unansweredEvidenceDomains( - packet: RectificationTechnicalPacket, - evidence: ReadonlyArray, -) { - const answeredDomains = new Set(effectiveLifeEventEvidence(evidence) - .filter((item) => item.extractionStatus !== "needs_clarification") - .map((item) => item.domain)); - return packet.suggestedDomains.filter((item) => !answeredDomains.has(item.domain)); -} - -function nextEvidenceDomains( - packet: RectificationTechnicalPacket, - evidence: ReadonlyArray, -) { - const unanswered = unansweredEvidenceDomains(packet, evidence); - return (unanswered.length > 0 ? unanswered : packet.suggestedDomains).slice(0, 2); -} - -function evidenceRequestForProgress(input: Readonly<{ - packet: RectificationTechnicalPacket; - evidence: ReadonlyArray; - willContinue: boolean; -}>): RectificationNarrativeResult["output"]["evidenceRequest"] { - if (!input.willContinue) return null; - const suggested = nextEvidenceDomains(input.packet, input.evidence); - if (suggested.length === 0) return null; - const labels = suggested.map((item) => progressDomainLabels[item.domain]); - return { - domains: suggested.map((item) => item.domain), - datePrecision: "month_preferred", - prompt: `请说一件${labels.join("或")}方面已经发生的事,尽量写明哪一年、哪一月以及发生了什么。`, - }; -} - function exactTechnicalReceipt(packet: RectificationTechnicalPacket) { const projected = projectRectificationTechnicalPacket(packet); return { @@ -421,6 +400,7 @@ function turnFromNarrative(input: { domains: input.narrative.output.evidenceRequest.domains, datePrecision: input.narrative.output.evidenceRequest.datePrecision, freeTextAllowed: true as const, + followUp: input.narrative.output.evidenceRequest.followUp, } : null; const candidate = { @@ -482,23 +462,6 @@ function privateCandidateFromPacket(input: { return parsed.data; } -function completedRangeTurn( - turn: ConversationalRectificationTurn, - reason: RangeCompletionReason, -): ConversationalRectificationTurn { - const suffix = `${rangeCompletionCopy(reason)} 当前证据没有收敛到可确认分钟,因此本次不计费,已退回暂扣点数。候选范围仅作记录,代表时间不会替换当前排盘时间。`; - const parsed = conversationalRectificationTurnSchema.safeParse({ - ...turn, - status: "completed", - narrative: boundedNarrative(turn.narrative, suffix), - candidate: { ...turn.candidate, status: "pending_validation" }, - evidenceRequest: null, - actions: turn.pendingConsultationQuestion ? ["continue_original_question"] : [], - }); - if (!parsed.success) throw new ConversationalRectificationError("service_unavailable"); - return parsed.data; -} - function changedTurn(input: { readonly current: LoadedConversationalRectificationCase; readonly status: "paused" | "abandoned" | "completed"; @@ -527,11 +490,6 @@ function changedTurn(input: { return { turn: parsed.data, receipt: input.receipt ?? transitionReceipt() }; } -function boundedNarrative(previous: string, suffix: string): string { - const room = Math.max(1, 12_000 - suffix.length - 2); - return `${previous.slice(0, room)}\n\n${suffix}`.slice(0, 12_000); -} - function midpointOfRange(range: Readonly<{ startTime: string; endTime: string }>): string { const minute = (value: string) => { const [hour = 0, part = 0] = value.split(":").map(Number); @@ -547,76 +505,60 @@ function midpointOfRange(range: Readonly<{ startTime: string; endTime: string }> return clock(Math.round((start + end) / 2)); } -function domainsForClarification( - current: ConversationalRectificationTurn, - hint: RectificationEvidenceDomain | undefined, -): readonly RectificationEvidenceDomain[] { - const values = [ - hint, - ...(current.evidenceRequest?.domains ?? []), - "career" as const, - "relationship" as const, - ].filter((value): value is RectificationEvidenceDomain => Boolean(value)); - return [...new Set(values)].slice(0, 4).length >= 2 - ? [...new Set(values)].slice(0, 4) - : ["career", "relationship"]; -} - type CorrectionResetReason = | "needs_clarification" | "non_scoreable" - | "direction_change" - | "validation_fallback"; + | "direction_change"; function nonScoringTurn(input: { readonly current: LoadedConversationalRectificationCase; readonly newEvidence: ReadonlyArray; - readonly domain?: RectificationEvidenceDomain; - readonly directionChange: boolean; + readonly latestUserText: string; + readonly authoredNarrative?: RectificationNarrativeResult | null; readonly correctionReset?: Readonly<{ packet: RectificationTechnicalPacket; reason: CorrectionResetReason; }>; }): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } { const allEvidence = [...input.current.eventEvidence, ...input.newEvidence]; - const hasFuture = input.newEvidence.some((item) => item.extractionStatus !== "needs_clarification" - && item.scoreable === false && item.dateValue !== null); const latestIncomplete = input.newEvidence .filter((item) => item.extractionStatus === "needs_clarification") .at(-1); - const clarificationNarrative = latestIncomplete?.dateValue === null - && latestIncomplete.eventSummary !== "事件内容待补充" - ? `你提到“${visibleEvidenceSummary(latestIncomplete.eventSummary)}”,具体内容我已经记下了。它大致是什么年月?只记得年份也可以。` - : latestIncomplete?.dateValue - && latestIncomplete.eventSummary === "事件内容待补充" - ? `我已经记下 ${latestIncomplete.dateValue} 这个时间。那时具体发生了什么重要事情?` - : null; - const correctionNarrative = input.correctionReset?.reason === "validation_fallback" - ? "这条更正已保存,原记录已经停止参与候选评分,候选范围也已重新计算。为避免只凭一次修订直接确认出生分钟,本轮先保持待验证;请继续补充另一件已经发生的真实经历。" - : input.correctionReset?.reason === "direction_change" - ? "这条更正已保存,原记录已经停止参与候选评分。我们会从声明范围重新开始核对,你可以换一个真实事件方向并尽量写明年月;本轮不会沿用旧候选推进确认。" - : input.correctionReset?.reason === "non_scoreable" - ? "这条更正已保存,原记录已经停止参与候选评分。更正后的内容目前不能作为已经发生的评分证据,候选已从声明范围重新计算;请再补充一件已发生并带有年月的事件。" - : "这条更正已保存,原记录已经停止参与候选评分。更正后的事件时间还不够清楚,候选已从声明范围重新计算;请补充大约年份、月份和发生了什么。"; - const narrative = input.correctionReset - ? correctionNarrative - : input.directionChange - ? "好的,我们不沿用不符合你的方向。你可以自由描述另一件已经发生的生活变化,尽量写明年月;我会根据事实继续,而不是让你选择宽泛年份。" - : hasFuture - ? "已保存这段描述。未来事件只能作为背景,不能用于校正评分;请再说一件已经发生的事件,并尽量写明年月。" - : clarificationNarrative - ?? "我已保存你的原话,但还缺少可用于区分候选的明确时间。请用自己的话补充这件已经发生的事大约是哪一年、哪一月;不需要选择固定答案。"; + const authoredNarrative = input.authoredNarrative; + const latestSummary = input.newEvidence.at(-1)?.eventSummary; + const fallbackSubject = latestSummary && latestSummary !== "事件内容待补充" + ? latestSummary + : input.latestUserText.trim().slice(0, 80); + const narrative = authoredNarrative?.narrative + ?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,但这次分析暂时没有完成。内容会保留,你可以继续补充它的时间和经过,或直接说下一件已经发生的经历。`; const status = input.correctionReset ? "active" as const : input.current.status === "confirming" ? "confirming" as const : "active" as const; const actions = actionsFor(status); - const evidenceRequest = status === "confirming" && input.current.latestTurn.evidenceRequest === null + const clarificationFollowUp = latestIncomplete?.dateValue === null + && latestIncomplete.eventSummary !== "事件内容待补充" + ? { kind: "event_date" as const, evidenceId: latestIncomplete.id } + : latestIncomplete?.dateValue + && latestIncomplete.eventSummary === "事件内容待补充" + ? { kind: "event_detail" as const, evidenceId: latestIncomplete.id } + : null; + const authoredRequest = authoredNarrative?.output.evidenceRequest; + const priorRequest = input.current.latestTurn.evidenceRequest; + const evidenceRequest = status === "confirming" && priorRequest === null ? null - : { - domains: domainsForClarification(input.current.latestTurn, input.domain), - datePrecision: "month_preferred" as const, - freeTextAllowed: true as const, - }; + : authoredRequest + ? { + domains: authoredRequest.domains, + datePrecision: authoredRequest.datePrecision, + freeTextAllowed: true as const, + followUp: clarificationFollowUp ?? authoredRequest.followUp, + } + : priorRequest + ? { + ...priorRequest, + followUp: clarificationFollowUp ?? priorRequest.followUp, + } + : null; const parsed = conversationalRectificationTurnSchema.safeParse({ ...input.current.latestTurn, status, @@ -636,7 +578,16 @@ function nonScoringTurn(input: { if (!parsed.success) throw new ConversationalRectificationError("service_unavailable"); return { turn: parsed.data, - receipt: transitionReceipt("deterministic-evidence-clarification"), + receipt: authoredNarrative + ? validationReceiptSchema.parse(authoredNarrative.validationReceipt) + : validationReceiptSchema.parse({ + modelId: "deterministic-evidence-failure-continuation", + schemaValidated: false, + validatorVersion: transitionValidatorVersion, + retryCount: 0, + fallbackUsed: true, + issues: ["technical_or_narrative_generation_failed"], + }), }; } @@ -827,16 +778,61 @@ export function createConversationalRectificationService( } } - function extractedEvidence(command: CommandOf<"answer">): readonly LifeEventEvidence[] { + function contextualizedAnswer( + command: CommandOf<"answer">, + current: LoadedConversationalRectificationCase, + ): string { + const followUp = current.latestTurn.evidenceRequest?.followUp; + if (followUp?.kind !== "event_date" && followUp?.kind !== "event_detail") { + return command.answer; + } + const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence); + const target = followUp.evidenceId + ? activeEvidence.find((item) => item.id === followUp.evidenceId) + : null; + const anchor = target?.dateValue + ? target + : activeEvidence.filter((item) => item.dateValue !== null).at(-1); + const anchorYear = Number(anchor?.dateValue?.slice(0, 4)); + if (!Number.isInteger(anchorYear)) return command.answer; + + const bareMonthDay = followUp.kind === "event_date" + ? command.answer.match(contextualBareMonthDayPattern) + : null; + if (bareMonthDay) { + return `${anchorYear}年${Number(bareMonthDay[1])}月${Number(bareMonthDay[2])}号`; + } + const bareDay = followUp.kind === "event_date" + ? command.answer.match(contextualBareDayPattern) + : null; + const anchorMonth = Number(anchor?.dateValue?.slice(5, 7)); + if (bareDay && Number.isInteger(anchorMonth)) { + return `${anchorYear}年${anchorMonth}月${Number(bareDay[1])}号`; + } + + const match = command.answer.match(contextualRelativeMonthPattern); + if (!match) return command.answer; + const month = Number(match[1]); + if (month < 1 || month > 12) return command.answer; + const sameYear = /(?:同年|当年|那年)/.test(match[0]); + return command.answer.replace(match[0], `${sameYear ? anchorYear : anchorYear + 1}年${month}月`); + } + + async function extractedEvidence( + command: CommandOf<"answer">, + current: LoadedConversationalRectificationCase, + ): Promise { let extracted: readonly LifeEventEvidence[]; try { + const answerForExtraction = contextualizedAnswer(command, current); extracted = extractLifeEventEvidence({ - rawText: command.answer, + rawText: answerForExtraction, sourceTurnId: command.actionId, asOfDate: ports.asOfDate(), correctsEvidenceId: command.correctsEvidenceId, }).map((item) => ({ ...item, + rawText: command.answer, correctsEvidenceIds: [...item.correctsEvidenceIds], domain: item.domain === "other" && command.domain && command.domain !== "other" ? command.domain @@ -849,6 +845,26 @@ export function createConversationalRectificationService( if (command.correctsEvidenceId && extracted.length !== 1) { throw new ConversationalRectificationError("invalid_command"); } + const ambiguous = extracted.length === 1 && extracted[0]?.domain === "other" + ? extracted[0] + : null; + if (ambiguous && ports.narrativeGenerator.classifyEvidenceDomain) { + try { + const domain = await ports.narrativeGenerator.classifyEvidenceDomain({ + text: command.answer, + recentEvidence: effectiveLifeEventEvidence(current.eventEvidence).slice(-6).map((item) => ({ + summary: item.eventSummary, + domain: item.domain, + })), + }, { signal: AbortSignal.timeout(8_000) }); + if (domain && domain !== "other") { + return [{ ...ambiguous, domain }]; + } + } catch { + // Semantic classification is advisory. Keep the deterministic fallback + // instead of blocking the user's event when the model is unavailable. + } + } return extracted; } @@ -858,29 +874,59 @@ export function createConversationalRectificationService( extracted: readonly LifeEventEvidence[]; }>): readonly LifeEventEvidence[] { if (input.command.correctsEvidenceId || input.extracted.length !== 1) return input.extracted; - const pending = effectiveLifeEventEvidence(input.current.eventEvidence) - .filter((item) => item.extractionStatus === "needs_clarification") - .at(-1); + const activeEvidence = effectiveLifeEventEvidence(input.current.eventEvidence); + const declaredFollowUp = input.current.latestTurn.evidenceRequest?.followUp; const supplied = input.extracted[0]; + if (declaredFollowUp?.kind === "new_event") return input.extracted; + const followUp = declaredFollowUp; + const pending = followUp?.evidenceId + ? activeEvidence.find((item) => item.id === followUp.evidenceId) + : activeEvidence.filter((item) => item.extractionStatus === "needs_clarification").at(-1); if (!pending || !supplied) return input.extracted; const pendingHasSummary = pending.eventSummary !== "事件内容待补充"; const suppliedHasSummary = supplied.eventSummary !== "事件内容待补充"; - const fillsMissingDate = pending.dateValue === null + const suppliedMatchesPending = !suppliedHasSummary + || supplied.domain === "other" + || supplied.domain === pending.domain; + const fillsMissingDate = followUp?.kind !== "event_detail" + && pending.dateValue === null && supplied.dateValue !== null + && !evidencePostdatesAsOfDate(supplied, ports.asOfDate()) && pendingHasSummary - && !suppliedHasSummary; - const fillsMissingSummary = pending.dateValue !== null + && suppliedMatchesPending; + const refinesKnownDate = followUp?.kind === "event_date" + && pending.dateValue !== null + && supplied.dateValue !== null + && supplied.dateValue.startsWith(`${pending.dateValue}-`) + && !evidencePostdatesAsOfDate(supplied, ports.asOfDate()) + && pendingHasSummary + && suppliedMatchesPending; + const fillsMissingSummary = followUp?.kind !== "event_date" + && pending.dateValue !== null && supplied.dateValue === null && !pendingHasSummary && suppliedHasSummary; - if (!fillsMissingDate && !fillsMissingSummary) return input.extracted; + const addsEventDetail = followUp?.kind === "event_detail" + && pending.dateValue !== null + && suppliedHasSummary + && (supplied.dateValue === null || supplied.dateValue === pending.dateValue); + if (!fillsMissingDate && !refinesKnownDate && !fillsMissingSummary && !addsEventDetail) { + return input.extracted; + } const dateValue = supplied.dateValue ?? pending.dateValue; - const summary = suppliedHasSummary ? supplied.eventSummary : pending.eventSummary; + const summary = fillsMissingDate || refinesKnownDate + ? pending.eventSummary + : addsEventDetail + ? [...new Set([pending.eventSummary, supplied.eventSummary])].join(";") + : suppliedHasSummary ? supplied.eventSummary : pending.eventSummary; if (!dateValue || summary === "事件内容待补充") return input.extracted; + // Re-parse the original event as one dated sentence. Detail replies may + // contain punctuation that the extractor treats as separate boundaries; + // apply the composed summary after deriving the single event metadata. const merged = extractLifeEventEvidence({ - rawText: `${dateValue} ${summary}`, + rawText: `${dateValue} ${pending.eventSummary}`, sourceTurnId: input.command.actionId, asOfDate: ports.asOfDate(), correctsEvidenceId: pending.id, @@ -889,6 +935,7 @@ export function createConversationalRectificationService( return merged.map((item) => ({ ...item, rawText: `${pending.rawText}\n补充:${input.command.answer}`, + eventSummary: summary, domain: pending.domain === "other" ? item.domain : pending.domain, correctsEvidenceIds: [...item.correctsEvidenceIds], })); @@ -969,6 +1016,25 @@ export function createConversationalRectificationService( } return publicTurn(existing); } + + // A new action id is not a new rectification session. The durable + // reserve RPC rejects a second unfinished case for the same account, + // so resolve that invariant before spending time or credits. This is + // intentionally limited to the same declared birth input; a changed + // profile must not silently continue an old calculation. + let existingForUser: LoadedConversationalRectificationCase | null; + try { + existingForUser = await ports.store.loadCase({ userId }); + } catch (error) { + throw safeFailure(error); + } + if (existingForUser && existingForUser.caseId !== caseId) { + observeCase(existingForUser); + if (sameDeclaredBirthInput(existingForUser.declaredBirthInput, declared.data)) { + return publicTurn(existingForUser); + } + throw new ConversationalRectificationError("action_conflict"); + } if (ports.allowNewCaseCreation === false) { throw new ConversationalRectificationError("service_unavailable"); } @@ -1034,6 +1100,7 @@ export function createConversationalRectificationService( observeCase(created, "charged"); return publicTurn(created); } catch (error) { + const failure = safeFailure(error); if (reserved) { try { await ports.billing.release({ @@ -1051,7 +1118,21 @@ export function createConversationalRectificationService( throw new ConversationalRectificationError("billing_failed"); } } - throw safeFailure(error); + // A concurrent start can win between the account-level read above + // and the reserve/create RPC. Re-read the account after releasing our + // reservation and return the winner when it uses the same profile. + if (failure.code === "action_conflict") { + try { + const winner = await ports.store.loadCase({ userId }); + if (winner && sameDeclaredBirthInput(winner.declaredBirthInput, declared.data)) { + observeCase(winner); + return publicTurn(winner); + } + } catch { + // Preserve the original stable conflict below. + } + } + throw failure; } }, @@ -1074,7 +1155,7 @@ export function createConversationalRectificationService( completeLatestClarification({ command, current, - extracted: extractedEvidence(command), + extracted: await extractedEvidence(command, current), }), current.declaredBirthInput.birthDate, ); @@ -1087,7 +1168,6 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, - userMessage: command.answer, turn: current.latestTurn, evidence, validationReceipt: latestReceipt(current), @@ -1111,10 +1191,10 @@ export function createConversationalRectificationService( const explicitDirectionChange = explicitDirectionChangePattern.test(command.answer); const directionChange = explicitDirectionChange || (scoreableEvidence.length === 0 && genericUncertaintyPattern.test(command.answer)); - const allScoreable = effectiveLifeEventEvidence([...current.eventEvidence, ...evidence]) - .filter((item) => item.scoreable === true - && item.extractionStatus !== "needs_clarification" - && !evidencePredatesBirthDate(item, current.declaredBirthInput.birthDate)); + const allScoreable = uniqueScoreableLifeEventEvidence( + [...current.eventEvidence, ...evidence], + current.declaredBirthInput.birthDate, + ); if (command.correctsEvidenceId) { try { @@ -1141,11 +1221,21 @@ export function createConversationalRectificationService( ? "needs_clarification" : replacement.scoreable !== true ? "non_scoreable" : null; if (resetReason) { + const authoredNarrative = await generateRectificationNarrative({ + phase: "intermediate", + packet: gatedPacket, + generator: ports.narrativeGenerator, + context: narrativeConversationContext({ + latestUserText: command.answer, + allEvidence: [...current.eventEvidence, ...evidence], + newEvidence: evidence, + }), + }); const next = nonScoringTurn({ current, newEvidence: evidence, - domain: command.domain, - directionChange: false, + latestUserText: command.answer, + authoredNarrative, correctionReset: { packet: computed.packet, reason: resetReason }, }); const privateCandidate = privateCandidateFromPacket({ @@ -1160,7 +1250,6 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, - userMessage: command.answer, turn: next.turn, evidence, validationReceipt: next.receipt, @@ -1182,37 +1271,6 @@ export function createConversationalRectificationService( newEvidence: evidence, }), }); - if (narrative.fallbackUsed) { - const next = nonScoringTurn({ - current, - newEvidence: evidence, - domain: command.domain, - directionChange: false, - correctionReset: { - packet: gatedPacket, - reason: "validation_fallback", - }, - }); - const privateCandidate = privateCandidateFromPacket({ - packet: gatedPacket, - resultId: null, - iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1, - forceCollecting: true, - }); - const saved = await ports.store.saveTurn({ - userId, - caseId: command.caseId, - expectedVersion: command.turnVersion, - actionId: command.actionId, - commandFingerprint: fingerprint, - userMessage: command.answer, - turn: next.turn, - evidence, - validationReceipt: narrative.validationReceipt, - privateCandidate, - }); - return publicTurn(saved); - } const privateCandidate = privateCandidateFromPacket({ packet: gatedPacket, resultId: gatedPacket.candidate.status === "ready_for_confirmation" @@ -1235,7 +1293,6 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, - userMessage: command.answer, turn, evidence, validationReceipt: narrative.validationReceipt, @@ -1247,11 +1304,40 @@ export function createConversationalRectificationService( } } if (directionChange || scoreableEvidence.length === 0) { + let authoredNarrative: RectificationNarrativeResult | null = null; + try { + const computed = await ports.buildTechnicalPacket({ + userId, + caseId: command.caseId, + asOfDate: ports.asOfDate(), + declaredBirthInput: current.declaredBirthInput, + privateCandidate: current.privateCandidate, + evidence: allScoreable, + }); + const gatedPacket = confirmationGatedPacket( + computed.packet, + allScoreable.length, + ); + authoredNarrative = await generateRectificationNarrative({ + phase: "intermediate", + packet: gatedPacket, + generator: ports.narrativeGenerator, + context: narrativeConversationContext({ + latestUserText: command.answer, + allEvidence: [...current.eventEvidence, ...evidence], + newEvidence: evidence, + }), + }); + } catch (error) { + console.warn("[rectification-non-scoring-narrative-fallback]", JSON.stringify({ + error: error instanceof Error ? error.name : "UnknownError", + })); + } const next = nonScoringTurn({ current, newEvidence: evidence, - domain: command.domain, - directionChange, + latestUserText: command.answer, + authoredNarrative, }); try { const saved = await ports.store.saveTurn({ @@ -1260,7 +1346,6 @@ export function createConversationalRectificationService( expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, - userMessage: command.answer, turn: next.turn, evidence, validationReceipt: next.receipt, @@ -1299,37 +1384,6 @@ export function createConversationalRectificationService( }), }); const plateauCount = nextPlateauCount(current.privateCandidate, gatedPacket); - const completionReason = rangeCompletionReason({ - packet: gatedPacket, - scoreableEventCount: allScoreable.length, - plateauCount, - unansweredSuggestedDomainCount: unansweredEvidenceDomains( - gatedPacket, - [...current.eventEvidence, ...evidence], - ).length, - }); - const narrativeWithProgress = { - ...narrative, - narrative: evidenceProgressNarrative({ - previousCandidate: current.privateCandidate, - packet: gatedPacket, - newEvidence: evidence, - allEvidence: [...current.eventEvidence, ...evidence], - scoreableEventCount: allScoreable.length, - willContinue: completionReason === null - && gatedPacket.candidate.status !== "ready_for_confirmation", - authoredNarrative: narrative.fallbackUsed ? null : narrative.narrative, - }), - output: { - ...narrative.output, - evidenceRequest: evidenceRequestForProgress({ - packet: gatedPacket, - evidence: [...current.eventEvidence, ...evidence], - willContinue: completionReason === null - && gatedPacket.candidate.status !== "ready_for_confirmation", - }), - }, - } satisfies RectificationNarrativeResult; const privateCandidate = privateCandidateFromPacket({ packet: gatedPacket, resultId: gatedPacket.candidate.status === "ready_for_confirmation" @@ -1344,20 +1398,16 @@ export function createConversationalRectificationService( turnVersion: command.turnVersion + 1, pendingConsultationQuestion: current.pendingConsultationQuestion, packet: gatedPacket, - narrative: narrativeWithProgress, + narrative, evidence: [...current.eventEvidence, ...evidence], }); - const turn = completionReason - ? completedRangeTurn(narratedTurn, completionReason) - : narratedTurn; const saved = await ports.store.saveTurn({ userId, caseId: command.caseId, expectedVersion: command.turnVersion, actionId: command.actionId, commandFingerprint: fingerprint, - userMessage: command.answer, - turn, + turn: narratedTurn, evidence, validationReceipt: narrative.validationReceipt, privateCandidate, @@ -1368,6 +1418,74 @@ export function createConversationalRectificationService( } }, + async regenerate(userId, rawCommand) { + resetTelemetryOutcome(); + const command = parseCommand("regenerate", rawCommand); + const fingerprint = commandFingerprint(command); + const receipt = await replayMutation(userId, command, "save_turn", fingerprint); + if (receipt) return receipt; + const current = await load(userId, command.caseId); + requireMutable(current); + requireExactVersion(current, command.turnVersion); + if (!current.latestTurn.actions.includes("answer")) { + throw new ConversationalRectificationError("invalid_transition"); + } + + try { + const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence); + const latestEvidence = activeEvidence.at(-1); + const scoreableEvidence = uniqueScoreableLifeEventEvidence( + current.eventEvidence, + current.declaredBirthInput.birthDate, + ); + const computed = await ports.buildTechnicalPacket({ + userId, + caseId: command.caseId, + asOfDate: ports.asOfDate(), + declaredBirthInput: current.declaredBirthInput, + privateCandidate: current.privateCandidate, + evidence: scoreableEvidence, + preserveCandidateRange: true, + }); + const gatedPacket = confirmationGatedPacket(computed.packet, scoreableEvidence.length); + const phase = gatedPacket.candidate.status === "ready_for_confirmation" + ? "final" as const + : activeEvidence.length === 0 ? "first" as const : "intermediate" as const; + const narrative = await generateRectificationNarrative({ + phase, + packet: gatedPacket, + generator: ports.narrativeGenerator, + context: latestEvidence ? narrativeConversationContext({ + latestUserText: latestEvidence.rawText, + allEvidence: current.eventEvidence, + newEvidence: [latestEvidence], + }) : undefined, + }); + const turn = turnFromNarrative({ + caseId: command.caseId, + turnVersion: command.turnVersion + 1, + pendingConsultationQuestion: current.pendingConsultationQuestion, + packet: gatedPacket, + narrative, + evidence: current.eventEvidence, + }); + const saved = await ports.store.saveTurn({ + userId, + caseId: command.caseId, + expectedVersion: command.turnVersion, + actionId: command.actionId, + commandFingerprint: fingerprint, + turn, + evidence: [], + validationReceipt: narrative.validationReceipt, + privateCandidate: current.privateCandidate, + }); + return publicTurn(saved); + } catch (error) { + throw safeFailure(error); + } + }, + async pause(userId, rawCommand) { resetTelemetryOutcome(); const command = parseCommand("pause", rawCommand); @@ -1398,7 +1516,7 @@ export function createConversationalRectificationService( const next = changedTurn({ current, status: "paused", - narrative: boundedNarrative(current.latestTurn.narrative, "校正已暂停,现有证据和候选已保存;继续时不会重复扣点。"), + narrative: current.latestTurn.narrative, }); try { return publicTurn(await ports.store.pause({ @@ -1442,7 +1560,7 @@ export function createConversationalRectificationService( const next = changedTurn({ current, status: "abandoned", - narrative: boundedNarrative(current.latestTurn.narrative, "本次校正已放弃;再次校正期间原有确认时间始终没有被替换。"), + narrative: current.latestTurn.narrative, }); try { return publicTurn(await ports.store.abandon({ @@ -1498,12 +1616,7 @@ export function createConversationalRectificationService( const next = changedTurn({ current, status: "completed", - narrative: boundedNarrative( - current.latestTurn.narrative, - current.pendingConsultationQuestion - ? "你已明确确认这个候选时间。现在可以使用新确认时间继续回答原问题。" - : "你已明确确认这个候选时间,账户当前排盘时间已原子更新。", - ), + narrative: current.latestTurn.narrative, }); try { return publicTurn(await ports.store.confirm({ diff --git a/frontend/src/lib/conversational-rectification/persistence-contracts.ts b/frontend/src/lib/conversational-rectification/persistence-contracts.ts index 59bd3a94..996102b5 100644 --- a/frontend/src/lib/conversational-rectification/persistence-contracts.ts +++ b/frontend/src/lib/conversational-rectification/persistence-contracts.ts @@ -24,16 +24,21 @@ const birthDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) = const locationCodeSchema = boundedText(80); const birthplaceSchema = boundedJson(z.object({ city: boundedText(120).optional(), + placeId: boundedText(240).optional(), + placeType: boundedText(80).optional(), + provider: boundedText(80).optional(), countryCode: z.string().regex(/^[A-Z0-9-]{1,8}$/).optional(), provinceCode: locationCodeSchema.optional(), cityCode: locationCodeSchema.optional(), districtCode: locationCodeSchema.optional(), latitude: z.number().finite().min(-90).max(90).optional(), longitude: z.number().finite().min(-180).max(180).optional(), + timezoneId: boundedText(120).optional(), + timezoneSource: boundedText(80).optional(), timezoneOffset: z.number().finite().min(-12).max(14), }).strict().superRefine((value, context) => { - if (!value.city && !value.cityCode) { - context.addIssue({ code: "custom", message: "city or cityCode is required" }); + if (!value.city && !value.cityCode && !value.placeId) { + context.addIssue({ code: "custom", message: "city, cityCode, or placeId is required" }); } if ((value.latitude === undefined) !== (value.longitude === undefined)) { context.addIssue({ code: "custom", message: "coordinates must be supplied as a pair" }); diff --git a/frontend/src/lib/conversational-rectification/store.ts b/frontend/src/lib/conversational-rectification/store.ts index 9b3c8246..6c36037e 100644 --- a/frontend/src/lib/conversational-rectification/store.ts +++ b/frontend/src/lib/conversational-rectification/store.ts @@ -1,6 +1,5 @@ import { z } from "zod"; import { - type ConversationalRectificationMessageHistoryEntry, conversationalRectificationTurnSchema, type ConversationalRectificationTurn, } from "./contracts.ts"; @@ -58,7 +57,6 @@ export type StoredConversationalRectificationCase = Readonly<{ pendingConsultationQuestion: string | null; billingState: "reserved" | "charged" | "released" | "migration_waived" | null; latestTurn: ConversationalRectificationTurn; - messageHistory?: ReadonlyArray; declaredBirthInput?: DeepReadonly; privateCandidate?: DeepReadonly; eventEvidence?: ReadonlyArray; @@ -106,7 +104,6 @@ export type CreateConversationalRectificationCaseInput = MutationIdentity & Read export type LifeEventEvidenceInput = DeepReadonly; export type SaveConversationalRectificationTurnInput = CommandMutationIdentity & Readonly<{ - userMessage: string; turn: ConversationalRectificationTurnInput; evidence: ReadonlyArray; validationReceipt: ValidationReceiptInput; @@ -193,8 +190,6 @@ function parseStoredCase(data: unknown, allowNull = false): StoredConversational pendingConsultationQuestion: value.pending_consultation_question, billingState: value.billing_state, latestTurn: value.latest_turn, - ...(value.message_history === undefined - ? {} : { messageHistory: value.message_history }), ...(value.declared_birth_input === undefined ? {} : { declaredBirthInput: value.declared_birth_input }), ...(value.private_candidate === undefined @@ -212,6 +207,22 @@ function requirePublicTurn(turn: ConversationalRectificationTurnInput): Conversa return parsed.data; } +/** + * A new-event marker is only an explicit form of the legacy default. Older + * databases reject that optional field and surface a misleading + * action_conflict, so omit it at the durable boundary. The follow-up migration + * remains required for event_date/event_detail turns. + */ +function turnForDurableContract( + turn: ConversationalRectificationTurnInput, +): ConversationalRectificationTurn { + const parsed = requirePublicTurn(turn); + if (parsed.evidenceRequest?.followUp?.kind !== "new_event") return parsed; + const evidenceRequest = { ...parsed.evidenceRequest }; + delete evidenceRequest.followUp; + return { ...parsed, evidenceRequest }; +} + function invalidDurableInput(): never { throw new ConversationalRectificationError("action_conflict"); } @@ -293,7 +304,9 @@ export class ConversationalRectificationStore { ): Promise { try { const { data, error } = await this.supabase.rpc(functionName, args); - if (error) throw mapConversationalRectificationStoreError(error); + if (error) { + throw mapConversationalRectificationStoreError(error); + } return parseStoredCase(data, allowNull); } catch (error) { if (error instanceof ConversationalRectificationError) throw error; @@ -312,7 +325,7 @@ export class ConversationalRectificationStore { p_revision_of_case_id: input.revisionOfCaseId, p_pending_consultation_question: input.pendingConsultationQuestion, p_declared_birth_input: requireDeclaredBirthInput(input.declaredBirthInput), - p_first_turn: requirePublicTurn(input.firstTurn), + p_first_turn: turnForDurableContract(input.firstTurn), p_validation_receipt: requireValidationReceipt(input.validationReceipt), p_private_candidate: requirePrivateCandidate(input.privateCandidate), }); @@ -324,7 +337,7 @@ export class ConversationalRectificationStore { userId: string; caseId?: string; }>): Promise { - const loaded = await this.callCaseRpc("load_conversational_rectification_case_with_history", { + const loaded = await this.callCaseRpc("load_conversational_rectification_case", { p_user_id: input.userId, p_case_id: input.caseId ?? null, }, true); @@ -354,13 +367,12 @@ export class ConversationalRectificationStore { input: SaveConversationalRectificationTurnInput, ): Promise { const functionName = input.turn.status === "completed" - ? "complete_conversational_rectification_with_range_and_history" - : "save_conversational_rectification_turn_with_history"; + ? "complete_conversational_rectification_with_range" + : "save_conversational_rectification_turn"; const result = await this.callCaseRpc(functionName, { ...mutationArgs(input), p_command_fingerprint: commandFingerprint(input), - p_user_message: input.userMessage, - p_turn: requirePublicTurn(input.turn), + p_turn: turnForDurableContract(input.turn), p_evidence: requireEvidence(input.evidence), p_validation_receipt: requireValidationReceipt(input.validationReceipt), p_private_candidate: requirePrivateCandidate(input.privateCandidate), diff --git a/frontend/src/lib/conversational-rectification/technical-packet.ts b/frontend/src/lib/conversational-rectification/technical-packet.ts index 6fd4ca6e..f0714329 100644 --- a/frontend/src/lib/conversational-rectification/technical-packet.ts +++ b/frontend/src/lib/conversational-rectification/technical-packet.ts @@ -235,17 +235,31 @@ function buildExpertWorkflow( evidence: [], boundary: "当前服务端生时校正评分合同未提供可审计的 KP cusp 结果,禁止声称已使用。", }, + { + technique: "VedAstro official validation", + status: receipt?.externalEngines?.status === "pass" + ? "used" + : receipt?.externalEngines?.status === "fail" ? "blocked" : "not_evaluated", + evidence: [ + String(receipt?.externalEngines?.validation?.vedastro_status ?? "not_evaluated"), + String(receipt?.externalEngines?.status ?? "not_evaluated"), + String(receipt?.externalEngines?.validation?.candidate_time ?? ""), + ].filter((item) => item.length > 0), + boundary: receipt?.externalEngines?.status === "pass" + ? "VedAstro 已返回官方结果,且胜出分钟在已发生事件扫描中严格领先次优分钟;仍需与本地、PyJHora、jyotishganit 及稳定性结果同时成立。" + : "候选未通过本地稳定性前不调用付费验证;进入分钟确认阶段后 VedAstro 官方验证为必跑项。", + }, { technique: "Minute confirmation", status: receipt?.confirmationAllowed === true ? "used" : "blocked", - evidence: receipt?.hardBlockers ?? ["minute_holdout_not_ready"], + evidence: receipt?.hardBlockers ?? ["evidence_and_external_validation_pending"], boundary: receipt?.confirmationAllowed === true ? "仍须用户明确确认后才能替换当前排盘时间。" - : "公开 AA 分钟 holdout 与盲测门禁未通过前,不得自动确认分钟。", + : "继续采集事件,直到本轮稳定性、必需技法、本地三引擎一致性与 VedAstro 官方验证全部通过。", }, ], confirmationAllowed: receipt?.confirmationAllowed === true, - hardBlockers: receipt?.hardBlockers ?? ["minute_holdout_not_ready"], + hardBlockers: receipt?.hardBlockers ?? ["evidence_and_external_validation_pending"], gates: receipt?.gates ?? {}, }; } diff --git a/frontend/src/lib/geoapify-location-service.ts b/frontend/src/lib/geoapify-location-service.ts new file mode 100644 index 00000000..1033b284 --- /dev/null +++ b/frontend/src/lib/geoapify-location-service.ts @@ -0,0 +1,210 @@ +import type { + BirthLocationSearchQuery, + BirthLocationSearchResult, + NormalizedBirthLocation, +} from "./location-contract"; +import { chinaLocations } from "../data/china-locations"; + +type FetchLike = typeof fetch; +type JsonRecord = Record; + +function record(value: unknown): JsonRecord { + return value && typeof value === "object" ? value as JsonRecord : {}; +} + +function text(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function findChinaMatches(query: string, limit: number) { + const needle = query.replace(/\s+/g, "").toLowerCase(); + const matches: Array<{ + code: string; + type: "province" | "city" | "district"; + label: string; + provinceCode: string; + provinceName: string; + cityName: string | null; + districtName: string | null; + latitude: number; + longitude: number; + }> = []; + for (const province of chinaLocations.country.provinces) { + if (province.name.toLowerCase().includes(needle)) matches.push({ + code: province.code, + type: "province", + label: `中国 · ${province.name}`, + provinceCode: province.code, + provinceName: province.name, + cityName: null, + districtName: null, + latitude: province.center[1], + longitude: province.center[0], + }); + for (const city of province.cities) { + if (city.name !== province.name && city.name.toLowerCase().includes(needle)) matches.push({ + code: city.code, + type: "city", + label: `中国 · ${province.name} · ${city.name}`, + provinceCode: province.code, + provinceName: province.name, + cityName: city.name, + districtName: null, + latitude: city.center[1], + longitude: city.center[0], + }); + for (const district of city.districts) { + if (!district.name.toLowerCase().includes(needle)) continue; + matches.push({ + code: district.code, + type: "district", + label: `中国 · ${province.name} · ${city.name} · ${district.name}`, + provinceCode: province.code, + provinceName: province.name, + cityName: city.name, + districtName: district.name, + latitude: district.center[1], + longitude: district.center[0], + }); + } + } + } + return matches.slice(0, limit); +} + +async function resolveTimezone( + fetchImpl: FetchLike, + apiBase: string, + latitude: number, + longitude: number, + query: BirthLocationSearchQuery, +) { + const response = await fetchImpl(`${apiBase}/api/location/timezone`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + latitude, + longitude, + ...(query.birthDate ? { birthDate: query.birthDate } : {}), + ...(query.birthTime ? { birthTime: query.birthTime } : {}), + }), + cache: "no-store", + }); + if (!response.ok) throw new Error("timezone_service_unavailable"); + const payload = record(await response.json()); + if (payload.available !== true || !text(payload.timezoneId)) { + throw new Error("timezone_service_unavailable"); + } + return payload; +} + +export async function searchGlobalBirthLocations( + query: BirthLocationSearchQuery, + options: { + apiKey?: string; + apiBase?: string; + fetchImpl?: FetchLike; + } = {}, +): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const apiBase = options.apiBase ?? process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; + const chinaMatches = findChinaMatches(query.q, query.limit); + if (chinaMatches.length > 0) { + try { + const locations = await Promise.all(chinaMatches.map(async (match): Promise => { + const timezone = await resolveTimezone(fetchImpl, apiBase, match.latitude, match.longitude, query); + return { + provider: "china_locations", + providerPlaceId: match.code, + placeType: match.type, + label: match.label, + countryCode: "CN", + countryName: "中国", + regionCode: match.provinceCode, + regionName: match.provinceName, + localityName: match.cityName, + districtName: match.districtName, + latitude: match.latitude, + longitude: match.longitude, + timezoneId: String(timezone.timezoneId), + timezoneOffset: typeof timezone.timezoneOffset === "number" ? timezone.timezoneOffset : null, + timezoneSource: "iana_historical", + localTimeStatus: ["resolved", "not_provided", "ambiguous", "nonexistent"].includes(String(timezone.localTimeStatus)) + ? timezone.localTimeStatus as NormalizedBirthLocation["localTimeStatus"] + : "not_provided", + }; + })); + return { status: "ok", locations }; + } catch { + return { status: "unavailable", reason: "timezone_service_unavailable" }; + } + } + + const apiKey = options.apiKey?.trim() || process.env.GEOAPIFY_API_KEY?.trim(); + if (!apiKey) return { status: "unavailable", reason: "geoapify_not_configured" }; + const params = new URLSearchParams({ + text: query.q, + apiKey, + limit: String(query.limit), + lang: query.locale.split("-")[0].toLowerCase(), + format: "geojson", + }); + + let response: Response; + try { + response = await fetchImpl(`https://api.geoapify.com/v1/geocode/autocomplete?${params}`, { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + } catch { + return { status: "unavailable", reason: "provider_unavailable" }; + } + if (!response.ok) return { status: "unavailable", reason: "provider_unavailable" }; + const payload = record(await response.json()); + const features = Array.isArray(payload.features) ? payload.features : []; + const normalized: NormalizedBirthLocation[] = []; + + for (const rawFeature of features) { + const feature = record(rawFeature); + const properties = record(feature.properties); + const geometry = record(feature.geometry); + const coordinates = Array.isArray(geometry.coordinates) ? geometry.coordinates.map(Number) : []; + const longitude = coordinates[0] ?? Number(properties.lon); + const latitude = coordinates[1] ?? Number(properties.lat); + const providerPlaceId = text(properties.place_id); + const label = text(properties.formatted) ?? text(properties.address_line1) ?? text(properties.name); + const placeType = text(properties.result_type) ?? "locality"; + if (!providerPlaceId || !label || !Number.isFinite(latitude) || !Number.isFinite(longitude)) continue; + try { + const timezone = await resolveTimezone(fetchImpl, apiBase, latitude, longitude, query); + normalized.push({ + provider: "geoapify", + providerPlaceId, + placeType, + label, + countryCode: text(properties.country_code)?.toUpperCase() ?? null, + countryName: text(properties.country), + regionCode: text(properties.state_code), + regionName: text(properties.state), + localityName: text(properties.city) + ?? text(properties.town) + ?? text(properties.village) + ?? text(properties.municipality) + ?? text(properties.suburb) + ?? text(properties.name), + districtName: text(properties.county) ?? text(properties.district), + latitude, + longitude, + timezoneId: String(timezone.timezoneId), + timezoneOffset: typeof timezone.timezoneOffset === "number" ? timezone.timezoneOffset : null, + timezoneSource: "iana_historical", + localTimeStatus: ["resolved", "not_provided", "ambiguous", "nonexistent"].includes(String(timezone.localTimeStatus)) + ? timezone.localTimeStatus as NormalizedBirthLocation["localTimeStatus"] + : "not_provided", + }); + } catch { + return { status: "unavailable", reason: "timezone_service_unavailable" }; + } + } + return { status: "ok", locations: normalized }; +} diff --git a/frontend/src/lib/location-contract.ts b/frontend/src/lib/location-contract.ts new file mode 100644 index 00000000..a8bd2b88 --- /dev/null +++ b/frontend/src/lib/location-contract.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +function isIsoCalendarDate(value: string) { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return false; + const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))); + return date.getUTCFullYear() === Number(match[1]) + && date.getUTCMonth() === Number(match[2]) - 1 + && date.getUTCDate() === Number(match[3]); +} + +export const birthLocationSearchQuerySchema = z.object({ + q: z.string().trim().min(2).max(160), + birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + birthTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/).optional(), + locale: z.string().trim().min(2).max(16).default("zh"), + limit: z.coerce.number().int().min(1).max(8).default(5), +}).superRefine((value, context) => { + if (value.birthTime && !value.birthDate) context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["birthDate"], + message: "birthTime requires birthDate", + }); + if (value.birthDate && !isIsoCalendarDate(value.birthDate)) { + context.addIssue({ code: z.ZodIssueCode.custom, path: ["birthDate"], message: "invalid birthDate" }); + } +}); + +export const normalizedBirthLocationSchema = z.object({ + provider: z.enum(["geoapify", "china_locations", "mapbox", "geonames"]), + providerPlaceId: z.string().min(1), + placeType: z.string().min(1), + label: z.string().min(1), + countryCode: z.string().nullable(), + countryName: z.string().nullable(), + regionCode: z.string().nullable(), + regionName: z.string().nullable(), + localityName: z.string().nullable(), + districtName: z.string().nullable(), + latitude: z.number().finite().min(-90).max(90), + longitude: z.number().finite().min(-180).max(180), + timezoneId: z.string().min(1), + timezoneOffset: z.number().finite().min(-12).max(14).nullable(), + timezoneSource: z.literal("iana_historical"), + localTimeStatus: z.enum(["resolved", "not_provided", "ambiguous", "nonexistent"]), +}); + +export type BirthLocationSearchQuery = z.infer; +export type NormalizedBirthLocation = z.infer; + +export type BirthLocationSearchResult = + | { status: "ok"; locations: NormalizedBirthLocation[] } + | { status: "unavailable"; reason: "geoapify_not_configured" | "timezone_service_unavailable" | "provider_unavailable" }; diff --git a/frontend/src/lib/onboarding-post.ts b/frontend/src/lib/onboarding-post.ts index 743cad93..7534e444 100644 --- a/frontend/src/lib/onboarding-post.ts +++ b/frontend/src/lib/onboarding-post.ts @@ -30,6 +30,11 @@ export type OnboardingProfileRow = { readonly country_code: string | null; readonly province_code: string | null; readonly city_code: string | null; + readonly latitude: number | null; + readonly longitude: number | null; + readonly timezone_offset: number | null; + readonly birth_place_label: string | null; + readonly timezone_id: string | null; readonly onboarding_payload: unknown; readonly onboarding_version: string | null; readonly onboarding_generated_at: string | null; @@ -92,28 +97,39 @@ function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean { const status = knownStatuses.find((item) => item === profile.birth_time_status) ?? (persistedTime ? "confirmed" : ""); const clock = (value: string | null) => value ? value.slice(0, 5) : ""; + const birthDraft = { + date: profile.birth_date ?? "", + time: clock(persistedTime), + reportedTime: clock(profile.reported_birth_time) + || (source === "legacy_import" ? clock(persistedTime) : ""), + birthTimeSource: source, + birthTimePeriod: profile.birth_time_period === "early_morning" + || profile.birth_time_period === "morning" + || profile.birth_time_period === "afternoon" + || profile.birth_time_period === "evening" + || profile.birth_time_period === "late_night" + ? profile.birth_time_period + : "", + birthTimeClue: profile.birth_time_clue ?? "", + uncertaintyBeforeMinutes: profile.uncertainty_before_minutes, + uncertaintyAfterMinutes: profile.uncertainty_after_minutes, + birthTimeStatus: status, + } as const; + const globalPlace = profile.birth_place_label && profile.timezone_id + ? { + label: profile.birth_place_label, + lat: profile.latitude ?? Number.NaN, + lon: profile.longitude ?? Number.NaN, + tz: profile.timezone_offset, + timezoneId: profile.timezone_id, + } + : null; + const placeComplete = globalPlace + ? isDeclaredBirthProfileComplete(birthDraft, globalPlace) + : Boolean(profile.country_code && profile.province_code && profile.city_code); return Boolean(profile.name - && profile.country_code - && profile.province_code - && profile.city_code - && isDeclaredBirthProfileComplete({ - date: profile.birth_date ?? "", - time: clock(persistedTime), - reportedTime: clock(profile.reported_birth_time) - || (source === "legacy_import" ? clock(persistedTime) : ""), - birthTimeSource: source, - birthTimePeriod: profile.birth_time_period === "early_morning" - || profile.birth_time_period === "morning" - || profile.birth_time_period === "afternoon" - || profile.birth_time_period === "evening" - || profile.birth_time_period === "late_night" - ? profile.birth_time_period - : "", - birthTimeClue: profile.birth_time_clue ?? "", - uncertaintyBeforeMinutes: profile.uncertainty_before_minutes, - uncertaintyAfterMinutes: profile.uncertainty_after_minutes, - birthTimeStatus: status, - })); + && placeComplete + && isDeclaredBirthProfileComplete(birthDraft)); } export function createOnboardingPost(dependencies: OnboardingPostDependencies): () => Promise { diff --git a/frontend/supabase/migrations/20260723010000_align_conversational_follow_up_request.sql b/frontend/supabase/migrations/20260723010000_align_conversational_follow_up_request.sql new file mode 100644 index 00000000..b3983300 --- /dev/null +++ b/frontend/supabase/migrations/20260723010000_align_conversational_follow_up_request.sql @@ -0,0 +1,59 @@ +begin; + +-- Follow-up targeting is part of the language-first rectification contract. +-- Keep the legacy required keys stable, but allow the optional followUp object +-- that the application persists to distinguish a new event from a date/detail +-- clarification. +create or replace function public.conversational_rectification_valid_evidence_request( + p_value jsonb +) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.jsonb_typeof(p_value) = 'object' + and pg_catalog.octet_length(p_value::text) <= 2048 + and public.conversational_rectification_numbers_are_stable(p_value) + and public.conversational_rectification_has_only_keys( + p_value, + array['domains', 'datePrecision', 'freeTextAllowed', 'followUp']::text[] + ) + and p_value ?& array['domains', 'datePrecision', 'freeTextAllowed']::text[] + and pg_catalog.jsonb_typeof(p_value -> 'domains') = 'array' + and pg_catalog.jsonb_array_length(p_value -> 'domains') between 1 and 4 + and not exists ( + select 1 + from pg_catalog.jsonb_array_elements(p_value -> 'domains') domain + where pg_catalog.jsonb_typeof(domain) is distinct from 'string' + or domain #>> '{}' not in ( + 'career', 'education', 'finance', 'health_pressure', 'relocation', + 'relationship', 'family', 'other' + ) + ) + and pg_catalog.jsonb_typeof(p_value -> 'datePrecision') = 'string' + and p_value ->> 'datePrecision' in ('month_preferred', 'year_accepted') + and pg_catalog.jsonb_typeof(p_value -> 'freeTextAllowed') = 'boolean' + and p_value -> 'freeTextAllowed' = 'true'::jsonb + and ( + not (p_value ? 'followUp') + or ( + pg_catalog.jsonb_typeof(p_value -> 'followUp') = 'object' + and public.conversational_rectification_has_only_keys( + p_value -> 'followUp', + array['kind', 'evidenceId']::text[] + ) + and (p_value -> 'followUp') ?& array['kind', 'evidenceId']::text[] + and p_value #>> '{followUp,kind}' in ('new_event', 'event_date', 'event_detail') + and ( + p_value #>> '{followUp,evidenceId}' is null + or public.conversational_rectification_valid_uuid_text( + p_value #>> '{followUp,evidenceId}' + ) + ) + ) + ); +$$; + +commit; diff --git a/frontend/supabase/migrations/20260724010000_global_birth_locations.sql b/frontend/supabase/migrations/20260724010000_global_birth_locations.sql new file mode 100644 index 00000000..85dd1021 --- /dev/null +++ b/frontend/supabase/migrations/20260724010000_global_birth_locations.sql @@ -0,0 +1,39 @@ +begin; + +alter table public.profiles + add column if not exists birth_place_label text, + add column if not exists birth_place_type text, + add column if not exists birth_place_provider text, + add column if not exists birth_place_provider_id text, + add column if not exists timezone_id text, + add column if not exists timezone_source text; + +alter table public.profiles + drop constraint if exists profiles_birth_place_provider_check, + drop constraint if exists profiles_timezone_source_check; + +alter table public.profiles + add constraint profiles_birth_place_provider_check + check (birth_place_provider is null or birth_place_provider in ('geoapify', 'china_locations', 'mapbox', 'geonames')), + add constraint profiles_timezone_source_check + check (timezone_source is null or timezone_source in ('iana_historical')); + +grant update ( + birth_place_label, + birth_place_type, + birth_place_provider, + birth_place_provider_id, + timezone_id, + timezone_source +) on table public.profiles to authenticated; + +grant select, insert, update ( + birth_place_label, + birth_place_type, + birth_place_provider, + birth_place_provider_id, + timezone_id, + timezone_source +) on table public.profiles to service_role; + +commit; diff --git a/frontend/supabase/migrations/20260724020000_align_global_birthplace_rectification_contract.sql b/frontend/supabase/migrations/20260724020000_align_global_birthplace_rectification_contract.sql new file mode 100644 index 00000000..9e658f8a --- /dev/null +++ b/frontend/supabase/migrations/20260724020000_align_global_birthplace_rectification_contract.sql @@ -0,0 +1,154 @@ +begin; + +-- Global birthplace intake stores provider identity and IANA timezone metadata +-- in the immutable rectification declaration. Keep the database validator in +-- lockstep with the strict TypeScript persistence contract so valid non-China +-- profiles are not surfaced as conversational_action_conflict. +create or replace function public.conversational_rectification_valid_declared_birth_input( + p_value jsonb +) +returns boolean +language plpgsql +immutable +strict +set search_path = '' +as $$ +declare + v_place jsonb; + v_source text; + v_key text; + v_before integer; + v_after integer; +begin + if pg_catalog.jsonb_typeof(p_value) is distinct from 'object' + or pg_catalog.octet_length(p_value::text) > 12000 + or public.conversational_rectification_numbers_are_stable(p_value) is not true + or not public.conversational_rectification_has_only_keys( + p_value, + array[ + 'birthDate', 'source', 'birthTimeClue', 'birthplace', 'reportedTime', + 'reportedPeriod', 'uncertaintyBeforeMinutes', 'uncertaintyAfterMinutes' + ]::text[] + ) + or not (p_value ?& array['birthDate', 'source', 'birthTimeClue', 'birthplace']::text[]) + or pg_catalog.jsonb_typeof(p_value -> 'birthDate') is distinct from 'string' + or not public.conversational_rectification_valid_date_text(p_value ->> 'birthDate') + or pg_catalog.jsonb_typeof(p_value -> 'source') is distinct from 'string' + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'source' + ) is not true + or p_value -> 'birthTimeClue' <> 'null'::jsonb and ( + pg_catalog.jsonb_typeof(p_value -> 'birthTimeClue') is distinct from 'string' + or public.conversational_rectification_text_utf16_length( + p_value ->> 'birthTimeClue' + ) not between 1 and 240 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'birthTimeClue' + ) is not true + ) then return false; end if; + + v_place := p_value -> 'birthplace'; + if pg_catalog.jsonb_typeof(v_place) is distinct from 'object' + or pg_catalog.octet_length(v_place::text) > 4096 + or not public.conversational_rectification_has_only_keys( + v_place, + array[ + 'city', 'placeId', 'placeType', 'provider', 'countryCode', + 'provinceCode', 'cityCode', 'districtCode', 'latitude', 'longitude', + 'timezoneId', 'timezoneSource', 'timezoneOffset' + ]::text[] + ) + or not (v_place ? 'timezoneOffset') + or not ((v_place ? 'city') or (v_place ? 'cityCode') or (v_place ? 'placeId')) + or pg_catalog.jsonb_typeof(v_place -> 'timezoneOffset') is distinct from 'number' + or (v_place ->> 'timezoneOffset')::numeric not between -12 and 14 + or (v_place ? 'latitude') <> (v_place ? 'longitude') then return false; end if; + foreach v_key in array array[ + 'city', 'placeId', 'placeType', 'provider', 'provinceCode', 'cityCode', + 'districtCode', 'timezoneId', 'timezoneSource' + ]::text[] loop + if v_place ? v_key and ( + pg_catalog.jsonb_typeof(v_place -> v_key) is distinct from 'string' + or public.conversational_rectification_text_utf16_length( + v_place ->> v_key + ) not between 1 and case + when v_key = 'city' then 120 + when v_key = 'placeId' then 240 + when v_key = 'timezoneId' then 120 + else 80 + end + or public.conversational_rectification_text_is_nonblank( + v_place ->> v_key + ) is not true + ) then return false; end if; + end loop; + if v_place ? 'countryCode' and ( + pg_catalog.jsonb_typeof(v_place -> 'countryCode') is distinct from 'string' + or v_place ->> 'countryCode' !~ '^[A-Z0-9-]{1,8}$' + ) then return false; end if; + if v_place ? 'latitude' and ( + pg_catalog.jsonb_typeof(v_place -> 'latitude') is distinct from 'number' + or pg_catalog.jsonb_typeof(v_place -> 'longitude') is distinct from 'number' + or (v_place ->> 'latitude')::numeric not between -90 and 90 + or (v_place ->> 'longitude')::numeric not between -180 and 180 + ) then return false; end if; + + v_source := p_value ->> 'source'; + if v_source not in ( + 'hospital_record', 'family_exact', 'approximate', 'period_only', 'unknown', 'legacy_import' + ) then return false; end if; + if p_value ? 'reportedTime' and ( + pg_catalog.jsonb_typeof(p_value -> 'reportedTime') is distinct from 'string' + or not public.conversational_rectification_valid_time_text(p_value ->> 'reportedTime') + ) then return false; end if; + if p_value ? 'reportedPeriod' and ( + pg_catalog.jsonb_typeof(p_value -> 'reportedPeriod') is distinct from 'string' + or p_value ->> 'reportedPeriod' not in ( + 'early_morning', 'morning', 'afternoon', 'evening', 'late_night' + ) + ) then return false; end if; + if p_value ? 'uncertaintyBeforeMinutes' then + if pg_catalog.jsonb_typeof(p_value -> 'uncertaintyBeforeMinutes') is distinct from 'number' + or pg_catalog.jsonb_typeof(p_value -> 'uncertaintyAfterMinutes') is distinct from 'number' + or p_value ->> 'uncertaintyBeforeMinutes' !~ '^[0-9]+$' + or p_value ->> 'uncertaintyAfterMinutes' !~ '^[0-9]+$' then + return false; + end if; + v_before := (p_value ->> 'uncertaintyBeforeMinutes')::integer; + v_after := (p_value ->> 'uncertaintyAfterMinutes')::integer; + elsif p_value ? 'uncertaintyAfterMinutes' then + return false; + end if; + + if v_source = 'hospital_record' then + return p_value ? 'reportedTime' + and not (p_value ? 'reportedPeriod') + and v_before = 2 and v_after = 2; + elsif v_source = 'family_exact' then + return p_value ? 'reportedTime' + and not (p_value ? 'reportedPeriod') + and v_before = v_after and v_before in (5, 10, 15); + elsif v_source = 'approximate' then + return p_value ? 'reportedTime' + and not (p_value ? 'reportedPeriod') + and v_before = v_after and v_before in (15, 30, 60); + elsif v_source = 'period_only' then + return p_value ? 'reportedPeriod' + and not (p_value ? 'reportedTime') + and not (p_value ? 'uncertaintyBeforeMinutes') + and not (p_value ? 'uncertaintyAfterMinutes'); + elsif v_source = 'unknown' then + return not (p_value ? 'reportedPeriod') + and not (p_value ? 'reportedTime') + and not (p_value ? 'uncertaintyBeforeMinutes') + and not (p_value ? 'uncertaintyAfterMinutes'); + end if; + return not ((p_value ? 'reportedTime') and (p_value ? 'reportedPeriod')) + and ((p_value ? 'reportedTime') or v_before is null) + and (v_before is null or (v_before between 0 and 720 and v_after between 0 and 720)); +exception when others then + return false; +end; +$$; + +commit; diff --git a/frontend/tests/account-api.test.ts b/frontend/tests/account-api.test.ts index a2b7b963..45357ddc 100644 --- a/frontend/tests/account-api.test.ts +++ b/frontend/tests/account-api.test.ts @@ -38,6 +38,15 @@ test("account API scopes the service-role case lookup to the authenticated accou assert.match(caseSelect, /\.order\("updated_at", \{ ascending: false \}\)/); }); +test("account GET falls back when global birthplace columns are not migrated yet", () => { + const getSource = source.slice(source.indexOf("export async function GET"), source.indexOf("export async function PATCH")); + + assert.match(getSource, /profileError && isMissingProfileColumn\(profileError\)/); + assert.match(getSource, /select\("credits,active_birth_time[^"]*timezone_offset"\)/); + assert.match(getSource, /birth_place_label: undefined/); + assert.match(getSource, /timezone_id: undefined/); +}); + const currentDeclaredProfile = Object.freeze({ credits: 7, active_birth_time: null, @@ -197,6 +206,86 @@ test("account case matching validates optional canonical place labels and can fi ); }); +test("account case matching resumes a normalized global birthplace without China location codes", () => { + const globalProfile = { + ...currentDeclaredProfile, + country_code: "TW", + province_code: null, + city_code: null, + district_code: null, + birth_place_label: "台湾 · 台北市", + birth_place_type: "locality", + birth_place_provider: "geonames", + birth_place_provider_id: "1668341", + timezone_id: "Asia/Taipei", + timezone_source: "iana_historical", + latitude: 25.033, + longitude: 121.5654, + timezone_offset: 8, + }; + const globalDeclaration = { + ...currentDeclaredInput, + birthplace: { + city: "台湾 · 台北市", + placeId: "1668341", + placeType: "locality", + provider: "geonames", + countryCode: "TW", + latitude: 25.033, + longitude: 121.5654, + timezoneId: "Asia/Taipei", + timezoneSource: "iana_historical", + timezoneOffset: 8, + }, + }; + + assert.ok(resolveAccountRectificationCase(globalProfile, [unfinishedCase(globalDeclaration)])); + assert.equal(resolveAccountRectificationCase(globalProfile, [unfinishedCase({ + ...globalDeclaration, + birthplace: { ...globalDeclaration.birthplace, placeId: "wrong-id" }, + })]), null); +}); + +test("account case matching recovers an existing global case when profile offset is null and coordinates need durable rounding", () => { + const globalProfile = { + ...currentDeclaredProfile, + country_code: "US", + province_code: null, + city_code: null, + district_code: null, + birth_place_label: "San Francisco, California, United States", + birth_place_type: "city", + birth_place_provider: "geoapify", + birth_place_provider_id: "sf-place", + timezone_id: "America/Los_Angeles", + timezone_source: "iana_historical", + latitude: 37.7879363, + longitude: -122.4075201, + timezone_offset: null, + }; + const declaration = { + ...currentDeclaredInput, + birthplace: { + city: "San Francisco, California, United States", + placeId: "sf-place", + placeType: "city", + provider: "geoapify", + countryCode: "US", + latitude: 37.787936, + longitude: -122.40752, + timezoneId: "America/Los_Angeles", + timezoneSource: "iana_historical", + timezoneOffset: -8, + }, + }; + + assert.ok(resolveAccountRectificationCase(globalProfile, [unfinishedCase(declaration)])); + assert.equal(resolveAccountRectificationCase( + { ...globalProfile, timezone_id: "America/New_York" }, + [unfinishedCase(declaration)], + ), null); +}); + test("account route reads declaration truth only for server matching and does not key resume to profile case id", () => { const caseSelect = source.match(/\.from\("birth_time_rectification_cases"\)[\s\S]*?\.limit\(\d+\)/)?.[0] ?? ""; const responseStart = source.indexOf("return NextResponse.json({\n user:"); @@ -237,6 +326,16 @@ test("profile patch schema validates calendar, clock, source requirements, and l assert.equal(accountProfilePatchSchema.safeParse({ ...valid, latitude: 91 }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ reported_birth_time: "05:30" }).success, false); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, longitude: null }).success, false); + assert.equal(accountProfilePatchSchema.safeParse({ + ...valid, + timezone_offset: null, + birth_place_label: "New York, New York, United States", + birth_place_type: "place", + birth_place_provider: "mapbox", + birth_place_provider_id: "place.123", + timezone_id: "America/New_York", + timezone_source: "iana_historical", + }).success, true); assert.equal(accountProfilePatchSchema.safeParse({ name: "只改称呼" }).success, true); assert.equal(accountProfilePatchSchema.safeParse({ ...valid, @@ -347,6 +446,7 @@ test("account PATCH uses the shared validator and never writes client birth_time assert.match(patchSource, /"birth_time_status"/); assert.match(patchSource, /"active_birth_time"/); assert.match(source, /latitude,longitude,timezone_offset/); + assert.match(source, /birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source/); assert.match(source, /applyAccountProfileConcurrencyGuards/); assert.match(source, /最新确认结果已保留/); }); diff --git a/frontend/tests/birth-time-intake.test.ts b/frontend/tests/birth-time-intake.test.ts index 95146236..948df6f7 100644 --- a/frontend/tests/birth-time-intake.test.ts +++ b/frontend/tests/birth-time-intake.test.ts @@ -135,6 +135,20 @@ test("declared completeness validates the actual calendar date, clock, source fi lon: 114.49, tz: 8, }), true); + assert.equal(isDeclaredBirthProfileComplete(exact, { + label: "中国 · 河北省 · 邯郸市 · 峰峰矿区", + lat: 36.420487, + lon: 114.209936, + tz: null, + timezoneId: "Asia/Shanghai", + }), true); + assert.equal(isDeclaredBirthProfileComplete(exact, { + label: "缺少时区的地点", + lat: 36.420487, + lon: 114.209936, + tz: null, + timezoneId: "", + }), false); assert.equal(isDeclaredBirthProfileComplete(exact, { label: "越界地点", lat: 91, diff --git a/frontend/tests/birth-time-journey-dynamic-adapters.test.ts b/frontend/tests/birth-time-journey-dynamic-adapters.test.ts index 93590275..78665270 100644 --- a/frontend/tests/birth-time-journey-dynamic-adapters.test.ts +++ b/frontend/tests/birth-time-journey-dynamic-adapters.test.ts @@ -195,7 +195,7 @@ test("dynamic scores map independent engine values into guarded candidates", () assert.equal(parsed.candidate.algorithmVersion, "birth-time-choice-scoring-v2"); }); -test("dynamic score adapters keep minute confirmation closed before holdout release", () => { +test("dynamic score adapters keep minute confirmation closed before VedAstro validation", () => { const parsed = parseDynamicChoiceScoring({ ...apiScore, confidence: "high", @@ -211,5 +211,5 @@ test("dynamic score adapters keep minute confirmation closed before holdout rele assert.equal(parsed.candidate.confidence, "high"); assert.equal(parsed.candidate.canApply, false); - assert.ok(parsed.candidate.reasons.includes("minute_holdout_not_ready")); + assert.ok(parsed.candidate.reasons.includes("vedastro_validation_required")); }); diff --git a/frontend/tests/consultation-entrypoint.test.ts b/frontend/tests/consultation-entrypoint.test.ts index f29b2796..54bb5adf 100644 --- a/frontend/tests/consultation-entrypoint.test.ts +++ b/frontend/tests/consultation-entrypoint.test.ts @@ -98,6 +98,24 @@ test("homepage birth-time card starts the first rectification turn without a sec const handler = source.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/, + ); +}); + +test("a stale start snapshot refreshes and resumes the existing unfinished case after a 409", () => { + 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); + + 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/); }); test("homepage birth-time card opens its dedicated session before the first turn resolves", () => { diff --git a/frontend/tests/consultation-route-service.test.ts b/frontend/tests/consultation-route-service.test.ts index 96088d48..dd69ba59 100644 --- a/frontend/tests/consultation-route-service.test.ts +++ b/frontend/tests/consultation-route-service.test.ts @@ -76,11 +76,61 @@ test("verified route uses only server active time", async () => { assert.equal(prepared.serverChart?.truth.selectedTimeKind, "active"); }); +test("global normalized places use their exact coordinates and historical offset", async () => { + const prepared = await prepareConsultationRoute({ + userId: "user-global", + mode: "unverified_birth_time", + loadProfile: async () => ({ + ...profile, + country_code: "TW", + province_code: null, + city_code: null, + district_code: null, + birth_place_label: "台湾 · 台北市", + birth_place_type: "locality", + birth_place_provider: "geonames", + birth_place_provider_id: "1668341", + timezone_id: "Asia/Taipei", + timezone_source: "iana_historical", + latitude: 25.033, + longitude: 121.5654, + timezone_offset: 9, + }), + reserve: async () => "reserved", + }); + + assert.deepEqual(prepared.serverChart?.toolInput, { + year: 1997, + month: 8, + day: 8, + hour: 5, + minute: 30, + city: "台湾 · 台北市", + lat: 25.033, + lon: 121.5654, + tz: 9, + }); + assert.equal(prepared.serverChart?.truth.placeId, "1668341"); + assert.equal(prepared.serverChart?.truth.timezoneId, "Asia/Taipei"); +}); + +test("legacy China place codes no longer pin exact coordinates to an administrative center", async () => { + const prepared = await prepareConsultationRoute({ + userId: "user-precise-cn", + mode: "unverified_birth_time", + loadProfile: async () => ({ ...profile, latitude: 36.5, timezone_offset: 9 }), + reserve: async () => "reserved", + }); + + assert.equal(prepared.serverChart?.toolInput.lat, 36.5); + assert.equal(prepared.serverChart?.toolInput.tz, 9); +}); + test("incomplete, inconsistent, or mode-mismatched profile fails before billing", async () => { for (const [expectedCode, invalid] of [ ["profile_incomplete", { ...profile, birth_date: null }], - ["profile_inconsistent", { ...profile, latitude: 36.5 }], - ["profile_inconsistent", { ...profile, timezone_offset: 9 }], + ["profile_inconsistent", { ...profile, latitude: 91 }], + ["profile_inconsistent", { ...profile, timezone_offset: 15 }], ["mode_changed", { ...profile, birth_time_status: "confirmed" }], ] as const) { let reserveCalls = 0; @@ -120,6 +170,62 @@ test("profile storage failure is stable and never reaches billing", async () => assert.equal(reserveCalls, 0); }); +test("verified global chart resolves a missing historical offset using the final active time before billing", async () => { + const order: string[] = []; + const prepared = await prepareConsultationRoute({ + userId: "user-global", + mode: "verified_chart", + loadProfile: async () => ({ + ...profile, + birth_time_status: "confirmed", + birth_place_label: "San Francisco, California, United States", + country_code: "US", + province_code: null, + city_code: null, + district_code: null, + latitude: 37.7879363, + longitude: -122.4075201, + timezone_id: "America/Los_Angeles", + timezone_offset: null, + }), + async resolveTimezoneOffset(value, selectedTime) { + order.push("timezone"); + assert.equal(selectedTime, "05:18"); + return { ...(value as Record), timezone_offset: -8 }; + }, + async reserve() { + order.push("reserve"); + return "reserved"; + }, + }); + + assert.deepEqual(order, ["timezone", "reserve"]); + assert.equal(prepared.serverChart?.toolInput.tz, -8); + assert.equal(prepared.serverChart?.toolInput.hour, 5); + assert.equal(prepared.serverChart?.toolInput.minute, 18); +}); + +test("historical timezone failure remains pre-billing", async () => { + let reserveCalls = 0; + await assert.rejects(prepareConsultationRoute({ + userId: "user-global", + mode: "verified_chart", + loadProfile: async () => ({ + ...profile, + birth_time_status: "confirmed", + timezone_id: "America/Los_Angeles", + timezone_offset: null, + }), + resolveTimezoneOffset: async () => { throw new Error("timezone unavailable"); }, + reserve: async () => { + reserveCalls += 1; + return "reserved"; + }, + }), (error: unknown) => error instanceof ConsultationProfileTruthError + && error.code === "profile_unavailable"); + assert.equal(reserveCalls, 0); +}); + test("general route reserves without loading chart profile", async () => { let profileLoads = 0; const prepared = await prepareConsultationRoute({ diff --git a/frontend/tests/conversational-convergence.test.ts b/frontend/tests/conversational-convergence.test.ts index 35972db5..c12ea6c5 100644 --- a/frontend/tests/conversational-convergence.test.ts +++ b/frontend/tests/conversational-convergence.test.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - MAXIMUM_SCOREABLE_EVENTS, - rangeCompletionReason, + convergenceNotes, + nextPlateauCount, } from "../src/lib/conversational-rectification/convergence.ts"; import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts"; @@ -12,22 +12,40 @@ const pendingPacket = { { domain: "relationship", layer: "D9", reason: "relationship evidence" }, { domain: "finance", layer: "D11", reason: "finance evidence" }, ], -} as RectificationTechnicalPacket; +} as unknown as RectificationTechnicalPacket; -test("does not stop at the evidence count limit while discriminating domains remain unanswered", () => { - assert.equal(rangeCompletionReason({ - packet: pendingPacket, - scoreableEventCount: MAXIMUM_SCOREABLE_EVENTS, - plateauCount: 1, - unansweredSuggestedDomainCount: 2, - }), null); +test("an unchanged range only advances background plateau state", () => { + const candidate = { + rangeStart: "04:30", + rangeEnd: "06:30", + workingState: { notes: ["range_plateau_count:50", "keep-me"] }, + }; + const packet = { + ...pendingPacket, + candidate: { + ...pendingPacket.candidate, + range: { startTime: "04:30", endTime: "06:30" }, + }, + } as RectificationTechnicalPacket; + + const plateauCount = nextPlateauCount(candidate, packet); + assert.equal(plateauCount, 51); + assert.deepEqual(convergenceNotes(candidate, plateauCount), ["keep-me", "range_plateau_count:51"]); }); -test("stops at the evidence count limit after the suggested domains are covered", () => { - assert.equal(rangeCompletionReason({ - packet: pendingPacket, - scoreableEventCount: MAXIMUM_SCOREABLE_EVENTS, - plateauCount: 1, - unansweredSuggestedDomainCount: 0, - }), "evidence_limit"); +test("a changed range resets background plateau state without completing the session", () => { + const candidate = { + rangeStart: "04:30", + rangeEnd: "06:30", + workingState: { notes: ["range_plateau_count:8"] }, + }; + const packet = { + ...pendingPacket, + candidate: { + ...pendingPacket.candidate, + range: { startTime: "05:10", endTime: "05:40" }, + }, + } as RectificationTechnicalPacket; + + assert.equal(nextPlateauCount(candidate, packet), 0); }); diff --git a/frontend/tests/conversational-evidence-extractor.test.ts b/frontend/tests/conversational-evidence-extractor.test.ts index 73cc9dec..23da2750 100644 --- a/frontend/tests/conversational-evidence-extractor.test.ts +++ b/frontend/tests/conversational-evidence-extractor.test.ts @@ -87,6 +87,19 @@ test("never invents a missing month or day", () => { assert.equal(evidence?.eventSummary, "毕业"); }); +test("expands abbreviated historical years without losing the first scoreable event", () => { + const evidence = extractLifeEventEvidence({ + rawText: "之后 21 年元旦回家准备备考考研,然后又遇到疫情封控,一直到21年底,我实际上在家呆到23年才出来", + sourceTurnId, + asOfDate: "2026-07-24", + }); + + assert.equal(evidence[0]?.dateValue, "2021"); + assert.equal(evidence[0]?.eventSummary, "元旦回家准备备考考研"); + assert.equal(evidence[0]?.scoreable, true); + assert.ok(evidence.some((item) => item.dateValue === "2023")); +}); + test("accepts nineteenth-century Chinese and ISO dates as scoreable historical evidence", () => { const [education] = extractLifeEventEvidence({ rawText: "1891年11月进入巴黎大学学习", @@ -170,6 +183,8 @@ test("keeps a bare year as non-scoreable clarification instead of an event summa for (const rawText of [ "2021年7月毕业并次年工作", + "2021年7月毕业并来年工作", + "2021年7月毕业并翌年工作", "2021年7月毕业并后来工作", "2021年7月毕业并第二年工作", "2021年7月毕业并此前工作", diff --git a/frontend/tests/conversational-narrative-agent.test.ts b/frontend/tests/conversational-narrative-agent.test.ts index a8912c95..8e745aef 100644 --- a/frontend/tests/conversational-narrative-agent.test.ts +++ b/frontend/tests/conversational-narrative-agent.test.ts @@ -119,7 +119,7 @@ test("validates a rich first-turn narrative against the technical packet", async assert.doesNotMatch(result.narrative, /^哪一个时间段[\s\S]*\d{4}[–—-]\d{4}/); }); -test("repairs missing safety wording without replacing the model-authored narrative", async () => { +test("preserves a complete model-authored first-turn narrative verbatim", async () => { const packet = syntheticTechnicalPacket(); const output = { ...richOutput(), @@ -139,13 +139,28 @@ test("repairs missing safety wording without replacing the model-authored narrat assert.equal(result.attempts, 1); assert.equal(result.fallbackUsed, false); - assert.match(result.narrative, /印象最深的一次关系变化/); - assert.match(result.narrative, /05:16–05:24/); - assert.match(result.narrative, /不能直接当作已经确认/); - assert.match(result.narrative, /关系变化[\s\S]*什么时候发生/); - assert.doesNotMatch(result.narrative, /请只提供已经发生/); - assert.match(result.output.evidenceRequest?.prompt ?? "", /关系大约是什么时候/); - assert.doesNotMatch(result.output.evidenceRequest?.prompt ?? "", /请以已经发生的真实事件为准/); + assert.equal(result.narrative, output.narrative); + assert.doesNotMatch(result.narrative, /05:16–05:24|不能直接当作已经确认/); + assert.equal(result.output.evidenceRequest?.prompt, "那段关系大约是什么时候发生的?"); +}); + +test("completes a first-turn introduction with the model-authored question", async () => { + const result = await generateRectificationNarrative({ + phase: "first", + packet: syntheticTechnicalPacket(), + generator: generator([{ + narrative: "我们先把你记得的出生范围当作起点,再用真实经历逐步核对。", + evidenceRequest: { + domains: ["relationship"], + datePrecision: "month_preferred", + prompt: "先说一件时间比较明确的关系转折,好吗?", + }, + }]), + }); + + assert.equal(result.fallbackUsed, false); + assert.match(result.narrative, /真实经历逐步核对。[\s\S]*关系转折,好吗?/); + assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1); }); test("repairs a natural intermediate follow-up prompt without discarding the specific reply", async () => { @@ -173,7 +188,45 @@ test("repairs a natural intermediate follow-up prompt without discarding the spe assert.equal(result.output.evidenceRequest?.prompt, "第一份工作是什么时候开始的?"); }); -test("makes an internal evidence prompt visible when the acknowledgement contains no question", async () => { +test("preserves the target event in an intermediate detail follow-up", async () => { + const evidenceId = "00000000-0000-4000-8000-000000000321"; + const output = { + ...richOutput(), + narrative: "记下了你在 2017 年 5 月参加工作。那是第一份正式工作,还是实习或兼职?", + evidenceRequest: { + domains: ["career" as const], + datePrecision: "month_preferred" as const, + prompt: "那是第一份正式工作,还是实习或兼职?", + followUp: { kind: "event_detail" as const, evidenceId }, + }, + }; + + const result = await generateRectificationNarrative({ + phase: "intermediate", + packet: syntheticTechnicalPacket(), + generator: generator([output]), + context: { + eventLedger: [{ + id: evidenceId, + rawText: "2017年5月参加工作", + dateLabel: "2017-05", + summary: "参加工作", + domain: "career", + extractionStatus: "clear", + active: true, + correctsEvidenceIds: [], + }], + }, + }); + + assert.equal(result.fallbackUsed, false); + assert.deepEqual(result.output.evidenceRequest?.followUp, { + kind: "event_detail", + evidenceId, + }); +}); + +test("appends the model-authored question when an intermediate reply only acknowledges the event", async () => { const output = { ...richOutput(), narrative: "已记录本科毕业后衔接读研,这是一段连续教育转折,暂时不重复计数。", @@ -191,12 +244,36 @@ test("makes an internal evidence prompt visible when the acknowledgement contain }); assert.equal(result.fallbackUsed, false); - assert.match(result.narrative, /连续教育转折/); - assert.match(result.narrative, /第一份工作是什么时候开始的/); - assert.doesNotMatch(result.narrative, /请只提供已经发生/); + assert.match(result.narrative, /连续教育转折[\s\S]*第一份工作是什么时候开始的?/); + assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1); }); -test("adds the concrete question when the acknowledgement only says whether more detail is needed", async () => { +test("repairs a narrative that stops at a dangling question introduction", async () => { + const output = { + ...richOutput(), + narrative: [ + "谢谢你补充了当时的压力感受,这让这段经历更完整。", + "为了判断这段经历的具体节奏,我需要确认一个关键信息——", + ].join("\n\n"), + evidenceRequest: { + domains: ["career" as const], + datePrecision: "month_preferred" as const, + prompt: "你感到压力最强的时候,大约是哪一年、哪一个月?", + }, + }; + + const result = await generateRectificationNarrative({ + phase: "intermediate", + packet: syntheticTechnicalPacket(), + generator: generator([output]), + }); + + assert.equal(result.fallbackUsed, false); + assert.match(result.narrative, /关键信息——[\s\S]*压力最强的时候[\s\S]*哪一年、哪一个月/); + assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1); +}); + +test("turns a complete intermediate acknowledgement into a visible one-question turn", async () => { const output = { ...richOutput(), narrative: "这次入职会作为一条事业事件记录,但还需要知道后续是否发生过离职、转岗或升职。", @@ -214,11 +291,11 @@ test("adds the concrete question when the acknowledgement only says whether more }); assert.equal(result.fallbackUsed, false); - assert.match(result.narrative, /还需要知道后续是否发生过/); - assert.match(result.narrative, /第一次发生明确变化是在什么时候/); + assert.match(result.narrative, /离职、转岗或升职。[\s\S]*第一次发生明确变化是在什么时候?/); + assert.equal((result.narrative.match(/[??]/g) ?? []).length, 1); }); -test("passes the bounded expert workflow to the skill-guided narrator", async () => { +test("hides an uninvoked technique inventory during evidence collection", async () => { const prompts: string[] = []; const packet = { ...syntheticTechnicalPacket(), @@ -242,16 +319,142 @@ test("passes the bounded expert workflow to the skill-guided narrator", async () }; await generateRectificationNarrative({ - phase: "first", + phase: "intermediate", packet, generator: generator([richOutput()], prompts), }); assert.match(prompts[0] ?? "", /expertWorkflow/); - assert.match(prompts[0] ?? "", /KP cusp \/ sub-lord/); + assert.doesNotMatch(prompts[0] ?? "", /KP cusp \/ sub-lord/); + assert.doesNotMatch(prompts[0] ?? "", /minute_holdout_not_ready/); assert.match(prompts[0] ?? "", /blockedOrNotEvaluatedTechniquesMustNeverBeClaimedAsUsed/); }); +test("appends the three auditable tables to every final Agent answer", async () => { + const packet: RectificationTechnicalPacket = { + ...syntheticTechnicalPacket(), + scoredHistoricalEvidence: [{ + evidenceId: "00000000-0000-4000-8000-000000000602", + domain: "career", + candidateTime: "05:20", + score: 3.5, + ruleRefs: ["vim-md-career"], + }], + expertWorkflow: { + boundary: "not_auto_rectified", + candidateWindows: [{ + startTime: "05:16", + endTime: "05:24", + status: "ready_for_confirmation", + }], + techniqueAuditTable: [{ + technique: "VedAstro official validation", + status: "used", + evidence: ["official_verified", "event_discrimination_pass"], + boundary: "胜出分钟必须在已发生事件扫描中严格领先次优分钟。", + }], + confirmationAllowed: true, + hardBlockers: [], + gates: {}, + }, + }; + const output: RectificationNarrativeModelOutput = { + ...richOutput(), + narrative: "当前证据支持 05:20 作为建议确认的分钟;写回前仍需你明确确认。", + evidenceRequest: null, + }; + const prompts: string[] = []; + const result = await generateRectificationNarrative({ + phase: "final", + packet, + context: { + eventLedger: [{ + id: "00000000-0000-4000-8000-000000000602", + rawText: "2019年7月入职第一家公司", + dateLabel: "2019-07", + summary: "入职第一家公司", + domain: "career", + extractionStatus: "clear", + active: true, + correctsEvidenceIds: [], + }], + }, + generator: generator([output], prompts), + }); + + assert.match(result.narrative, /### Technique Audit Table/); + assert.match(result.narrative, /VedAstro official validation/); + assert.match(result.narrative, /### 事件验证表/); + assert.match(result.narrative, /2019-07[\s\S]*入职第一家公司[\s\S]*已纳入验证[\s\S]*已纳入当前候选比较/); + assert.doesNotMatch(result.narrative, /3\.5|得分 \/ 状态|内部(?:分数|权重)/); + assert.doesNotMatch(prompts[0] ?? "", /"score"\s*:\s*3\.5/); + assert.match(result.narrative, /### 候选时间差异表/); + assert.match(result.narrative, /05:16–05:24[\s\S]*D9[\s\S]*minute_sensitive/); +}); + +test("appends canonical final tables when the model only emits empty or incomplete headings", async () => { + const output: RectificationNarrativeModelOutput = { + ...richOutput(), + narrative: [ + "当前证据已经形成建议结论,写回前仍需确认。", + "### Technique Audit Table", + "### 事件验证表", + "| 时间 | 事件 |", + "|---|---|", + "### 候选时间差异表", + ].join("\n\n"), + evidenceRequest: null, + }; + + const result = await generateRectificationNarrative({ + phase: "final", + packet: syntheticTechnicalPacket(), + generator: generator([output]), + }); + + assert.equal(result.fallbackUsed, false); + assert.match(result.narrative, /\| 技法 \| 状态 \| 证据 \| 使用边界 \|/); + assert.match(result.narrative, /\| 时间 \| 事件 \| 领域 \| 验证状态 \| 结论 \|/); + assert.match(result.narrative, /\| 候选范围 \| 层 \| 状态 \| 差异 \/ 证据 \|/); + assert.equal((result.narrative.match(/### Technique Audit Table/g) ?? []).length, 2); + assert.equal((result.narrative.match(/### 事件验证表/g) ?? []).length, 2); + assert.equal((result.narrative.match(/### 候选时间差异表/g) ?? []).length, 2); +}); + +test("rejects a model-authored event table that exposes private numeric scoring", async () => { + const packet: RectificationTechnicalPacket = { + ...syntheticTechnicalPacket(), + scoredHistoricalEvidence: [{ + evidenceId: "00000000-0000-4000-8000-000000000602", + domain: "career", + candidateTime: "05:20", + score: 3.5, + ruleRefs: ["vim-md-career"], + }], + }; + const unsafe: RectificationNarrativeModelOutput = { + ...richOutput(), + narrative: [ + "当前证据形成了候选结论。", + "### 事件验证表", + "| 时间 | 事件 | 得分 / 状态 |", + "|---|---|---|", + "| 2019-07 | 入职第一家公司 | 3.5 |", + ].join("\n"), + evidenceRequest: null, + }; + + const result = await generateRectificationNarrative({ + phase: "final", + packet, + generator: generator([unsafe, unsafe]), + }); + + assert.equal(result.fallbackUsed, true); + assert.doesNotMatch(result.narrative, /3\.5|得分 \/ 状态/); + assert.match(result.narrative, /\| 时间 \| 事件 \| 领域 \| 验证状态 \| 结论 \|/); +}); + test("passes the user's latest concrete event to an intermediate skill-guided reply", async () => { const prompts: string[] = []; await generateRectificationNarrative({ @@ -268,10 +471,48 @@ test("passes the user's latest concrete event to an intermediate skill-guided re }); assert.match(prompts[0] ?? "", /离开家乡去上海开始第一份长期工作/); - assert.match(prompts[0] ?? "", /acknowledgeLatestEvidenceSpecificallyBeforeAsking/); + assert.match(prompts[0] ?? "", /respondToLatestEvidenceBeforeAsking/); assert.match(prompts[0] ?? "", /doNotRepeatCandidateBoundaryUnlessItChangedOrTheUserAsked/); }); +test("keeps internal event domains and suggested-domain routing out of the narrator context", async () => { + const prompts: string[] = []; + await generateRectificationNarrative({ + phase: "intermediate", + packet: syntheticTechnicalPacket(), + context: { + latestEvidence: [{ + id: "00000000-0000-4000-8000-000000000111", + dateLabel: "2020-04", + summary: "去石油化工研究院实习做研究员", + domain: "other", + }], + eventLedger: [{ + id: "00000000-0000-4000-8000-000000000111", + rawText: "2020年4月去石油化工研究院实习做研究员", + dateLabel: "2020-04", + summary: "去石油化工研究院实习做研究员", + domain: "career", + extractionStatus: "clear", + active: true, + correctsEvidenceIds: [], + }], + }, + generator: generator([richOutput()], prompts), + }); + + const prompt = JSON.parse(prompts[0] ?? "{}") as { + conversationContext?: { + latestEvidence?: Array>; + eventLedger?: Array>; + }; + packet?: Record; + }; + assert.equal(prompt.conversationContext?.latestEvidence?.[0]?.domain, undefined); + assert.equal(prompt.conversationContext?.eventLedger?.[0]?.domain, undefined); + assert.equal(prompt.packet?.suggestedDomains, undefined); +}); + test("passes the active event ledger and unresolved facts to the intermediate agent", async () => { const prompts: string[] = []; await generateRectificationNarrative({ @@ -309,7 +550,7 @@ test("passes the active event ledger and unresolved facts to the intermediate ag assert.match(prompt, /23年关系结束后发生过一次交通事故/); assert.match(prompt, /2024-08-08/); assert.match(prompt, /一段重要关系结束/); - assert.match(prompt, /finishCurrentEventBeforeSwitchingDomains/); + assert.match(prompt, /continueCurrentEventWhenItRemainsInformative/); assert.match(prompt, /resolveDateContradictionsBeforeScoring/); assert.match(prompt, /mergeSameEventDetailsWithoutDoubleCounting/); }); @@ -330,35 +571,57 @@ test("rejects invented representative times, layers, and references", () => { assert.ok(result.issues.some((issue) => issue.includes("invented-reference"))); }); -test("accepts a concise first turn without exposing technical layer values", () => { +test("accepts a natural first turn without a forced range, boundary, or date request", () => { const packet = syntheticTechnicalPacket(); const invalid = { ...richOutput(), - narrative: "当前仍在核对 05:16–05:24 的候选范围,不能视为已经确认的出生分钟。先说一件过去的重要关系经历好吗?请写明哪一年、哪一月。", + narrative: "我们先从你印象最深、最愿意讲的一段人生变化开始。发生了什么?", } satisfies RectificationNarrativeModelOutput; const result = validateNarrativeAgainstPacket(invalid, packet, "first"); assert.deepEqual(result, { valid: true, issues: [] }); }); -test("rejects duplicated generic domain reasons that are not packet-grounded", () => { +test("accepts natural domain explanations while still rejecting unknown domain-layer pairs", () => { const packet = syntheticTechnicalPacket(); const relationshipReason = richOutput().domainReasons[0]; assert.ok(relationshipReason); - const invalid = { + const paraphrased = { ...richOutput(), domainReasons: [ { ...relationshipReason, reason: "D9 may matter for this question" }, { ...relationshipReason, reason: "D9 may matter for another question" }, ], } satisfies RectificationNarrativeModelOutput; - const result = validateNarrativeAgainstPacket(invalid, packet, "first"); + assert.deepEqual(validateNarrativeAgainstPacket(paraphrased, packet, "first"), { valid: true, issues: [] }); + const ungrounded = { + ...richOutput(), + domainReasons: [{ domain: "finance" as const, layer: "D60", reason: "This invented layer should be rejected." }], + } satisfies RectificationNarrativeModelOutput; + const result = validateNarrativeAgainstPacket(ungrounded, packet, "first"); assert.equal(result.valid, false); - assert.ok(result.issues.some((issue) => issue.includes("packet discrimination explanation"))); + assert.ok(result.issues.some((issue) => issue.includes("domain reason finance/D60"))); }); -test("rejects a generic broad-year choice questionnaire and uses a safe scoring-compatible fallback", async () => { +test("allows packet-grounded technical tables during an intermediate turn", () => { + const packet = syntheticTechnicalPacket(); + const output = { + ...richOutput(), + narrative: [ + "你补充的关系变化已经记入当前事件线。", + "### 候选时间差异表", + "| 层级 | 当前观察 |", + "| --- | --- |", + "| D9 | 在候选范围内呈分钟敏感差异 |", + "下一步我想继续了解这段关系结束后的直接变化。", + ].join("\n"), + } satisfies RectificationNarrativeModelOutput; + + assert.deepEqual(validateNarrativeAgainstPacket(output, packet, "intermediate"), { valid: true, issues: [] }); +}); + +test("rejects a generic broad-year choice questionnaire without replacing the first Agent answer with a template", async () => { const invalid = { ...richOutput(), narrative: [ @@ -377,14 +640,14 @@ test("rejects a generic broad-year choice questionnaire and uses a safe scoring- assert.equal(direct.valid, false); assert.ok(direct.issues.some((issue) => issue.includes("broad-year choice questionnaire"))); - const result = await generateRectificationNarrative({ - phase: "first", - packet: syntheticTechnicalPacket(), - generator: generator([invalid, invalid]), - }); - assert.equal(result.attempts, 2); - assert.equal(result.fallbackUsed, true); - assert.equal(result.allowEvidenceScoringAdvance, true); + await assert.rejects( + generateRectificationNarrative({ + phase: "first", + packet: syntheticTechnicalPacket(), + generator: generator([invalid, invalid]), + }), + /RectificationNarrativeUnavailable/, + ); }); test("rejects generic individual-year options even without a written range", () => { @@ -464,7 +727,7 @@ test("rejects ungrounded layers and references nested in a domain reason", () => const invalid = { ...output, domainReasons: [ - { ...firstReason, reason: `${firstReason.reason} D60 另见 invented-reference。` }, + { ...firstReason, reason: `${firstReason.reason} D60 另见【invented-reference】。` }, ...output.domainReasons.slice(1), ], } satisfies RectificationNarrativeModelOutput; @@ -482,7 +745,7 @@ test("rejects ungrounded times and references nested in the evidence request pro ...output, evidenceRequest: { ...output.evidenceRequest, - prompt: `${output.evidenceRequest.prompt} 请以 06:45 和 invented-reference 为准。`, + prompt: `${output.evidenceRequest.prompt} 请以 06:45 和【invented-reference】为准。`, }, } satisfies RectificationNarrativeModelOutput; const result = validateNarrativeAgainstPacket(invalid, syntheticTechnicalPacket(), "first"); @@ -492,11 +755,11 @@ test("rejects ungrounded times and references nested in the evidence request pro assert.ok(result.issues.some((issue) => issue.includes("evidenceRequest.prompt") && issue.includes("invented-reference"))); }); -test("rejects an invented plain-text technical reference omitted from the reference list", () => { +test("rejects an invented citation-marked technical reference omitted from the reference list", () => { const packet = syntheticTechnicalPacket(); const invalid = { ...richOutput(), - narrative: `${richOutput().narrative}\n另见 invented-reference。`, + narrative: `${richOutput().narrative}\n另见【invented-reference】。`, }; const result = validateNarrativeAgainstPacket(invalid, packet, "first"); @@ -504,7 +767,19 @@ test("rejects an invented plain-text technical reference omitted from the refere assert.ok(result.issues.some((issue) => issue.includes("invented-reference"))); }); -test("retries expression exactly once with the same grounded packet", async () => { +test("does not mistake ordinary machine-readable status words for technical citations", () => { + const output = { + ...richOutput(), + narrative: `${richOutput().narrative}\n当前仍是 pending_validation,并遵守 not_auto_rectified 边界。`, + } satisfies RectificationNarrativeModelOutput; + + assert.deepEqual( + validateNarrativeAgainstPacket(output, syntheticTechnicalPacket(), "first"), + { valid: true, issues: [] }, + ); +}); + +test("retries a grounded validation failure once with a compact packet", async () => { const packet = syntheticTechnicalPacket(); const prompts: string[] = []; const result = await generateRectificationNarrative({ @@ -519,51 +794,163 @@ test("retries expression exactly once with the same grounded packet", async () = assert.equal(prompts.length, 2); assert.match(prompts[0] ?? "", /rectification-technical-v1/); assert.match(prompts[1] ?? "", /rectification-technical-v1/); - assert.match(prompts[0] ?? "", /time_linked_candidate_scan_samples/); + assert.ok((prompts[0]?.length ?? Number.POSITIVE_INFINITY) < 5_000); + assert.equal(prompts.some((prompt) => prompt.includes("scoredHistoricalEvidence")), false); + assert.equal(prompts.some((prompt) => prompt.includes("referenceIds")), false); assert.equal(prompts.some((prompt) => prompt.includes("candidateWeights")), false); assert.equal(prompts.some((prompt) => prompt.includes("private-partition")), false); }); -test("uses a deterministic one-question conversational fallback after the second mismatch", async () => { - const packet = syntheticTechnicalPacket(); - const invalid = { ...richOutput(), sensitiveLayers: ["D60"] }; - const first = await generateRectificationNarrative({ - phase: "first", - packet, - generator: generator([invalid, invalid]), - }); - const second = await generateRectificationNarrative({ - phase: "first", - packet, - generator: generator([invalid, invalid]), - }); - - assert.equal(first.attempts, 2); - assert.equal(first.fallbackUsed, true); - assert.equal(first.allowEvidenceScoringAdvance, true); - assert.equal(first.narrative, second.narrative); - assert.match(first.narrative, /05:16–05:24[\s\S]*不能/); - assert.doesNotMatch(first.narrative, /\bD\d+\b/); - assert.match(first.narrative, /已经发生[\s\S]*年[\s\S]*月/); - assert.equal((first.narrative.match(/[??]/g) ?? []).length, 1); - assert.deepEqual(first.output.evidenceRequest?.domains, ["relationship"]); -}); - -test("bounds fallback validation issues for the durable receipt", async () => { - const inventedReference = `invented-${"x".repeat(500)}`; - const invalid = { - ...richOutput(), - narrative: `${richOutput().narrative}\n另见 ${inventedReference}。`, - }; +test("gives each validation attempt a fresh bounded deadline", async () => { + const signals: Array = []; + const attempts: Array<1 | 2 | undefined> = []; + const invalid = { ...richOutput(), representativeTime: "06:45" }; const result = await generateRectificationNarrative({ phase: "first", packet: syntheticTechnicalPacket(), - generator: generator([invalid, invalid]), + generator: { + modelId: "test-model", + async generate(_prompt, options) { + signals.push(options?.signal); + attempts.push(options?.attempt); + return { text: JSON.stringify(signals.length === 1 ? invalid : richOutput()) }; + }, + }, }); - assert.equal(result.fallbackUsed, true); - assert.ok(result.validationReceipt.issues.length <= 20); - assert.ok(result.validationReceipt.issues.every((issue) => issue.length <= 240)); + assert.equal(result.fallbackUsed, false); + assert.equal(signals.length, 2); + assert.ok(signals[0] instanceof AbortSignal); + assert.ok(signals[1] instanceof AbortSignal); + assert.notEqual(signals[0], signals[1]); + assert.deepEqual(attempts, [1, 2]); +}); + +test("accepts lightweight model-authored output and injects packet facts on the server", async () => { + const packet = syntheticTechnicalPacket(); + const result = await generateRectificationNarrative({ + phase: "first", + packet, + generator: generator([{ + narrative: "先从一件你印象最深、时间也比较明确的人生转折说起吧。", + evidenceRequest: { + datePrecision: "year_accepted", + prompt: "那件事大约发生在哪一年?", + }, + }]), + }); + + assert.equal(result.attempts, 1); + assert.equal(result.output.candidateStatus, packet.candidate.status); + assert.equal(result.output.representativeTime, packet.candidate.representativeTime); + assert.deepEqual(result.output.stableLayers, ["D1"]); + assert.deepEqual(result.output.sensitiveLayers, ["D9", "D10"]); + assert.deepEqual(result.output.referenceIds, []); + assert.deepEqual(result.output.domainReasons, packet.suggestedDomains); + assert.deepEqual(result.output.evidenceRequest?.domains, ["relationship", "career"]); +}); + +test("replaces legacy model-selected evidence domains instead of rejecting the answer", async () => { + const packet = syntheticTechnicalPacket(); + const output = richOutput(); + const result = await generateRectificationNarrative({ + phase: "intermediate", + packet, + generator: generator([{ + ...output, + evidenceRequest: { + ...output.evidenceRequest, + domains: ["finance"], + }, + }]), + }); + + assert.equal(result.attempts, 1); + assert.deepEqual(result.output.evidenceRequest?.domains, ["relationship", "career"]); +}); + +test("uses a fresh second provider request after the first attempt times out", async () => { + let calls = 0; + const signals: Array = []; + const result = await generateRectificationNarrative({ + phase: "first", + packet: syntheticTechnicalPacket(), + generator: { + modelId: "pro-model", + async generate(_prompt, options) { + calls += 1; + signals.push(options?.signal); + if (options?.attempt === 1) { + throw new DOMException("timed out", "TimeoutError"); + } + return { + text: JSON.stringify(richOutput()), + modelId: "flash-model", + }; + }, + }, + }); + + assert.equal(calls, 2); + assert.equal(result.attempts, 2); + assert.equal(result.fallbackUsed, false); + assert.equal(result.validationReceipt.modelId, "flash-model"); + assert.equal(result.validationReceipt.retryCount, 1); + assert.ok(signals[0] instanceof AbortSignal); + assert.ok(signals[1] instanceof AbortSignal); + assert.notEqual(signals[0], signals[1]); +}); + +test("records first-turn generation timeouts separately from schema failures", async () => { + const warnings: string[] = []; + const originalWarn = console.warn; + console.warn = (...values: unknown[]) => warnings.push(values.map(String).join(" ")); + try { + await assert.rejects( + generateRectificationNarrative({ + phase: "first", + packet: syntheticTechnicalPacket(), + generator: { + modelId: "test-model", + async generate() { throw new DOMException("timed out", "TimeoutError"); }, + }, + }), + /RectificationNarrativeUnavailable/, + ); + } finally { + console.warn = originalWarn; + } + + assert.match(warnings.at(-1) ?? "", /"issueCodes":\["timeout"\]/); +}); + +test("rejects the first turn instead of presenting a deterministic template as an agent answer", async () => { + const packet = syntheticTechnicalPacket(); + const invalid = { ...richOutput(), sensitiveLayers: ["D60"] }; + await assert.rejects( + generateRectificationNarrative({ + phase: "first", + packet, + generator: generator([invalid, invalid]), + }), + /RectificationNarrativeUnavailable/, + ); +}); + +test("rejects an invalid intermediate narrative instead of showing a deterministic template", async () => { + const inventedReference = `invented-${"x".repeat(500)}`; + const invalid = { + ...richOutput(), + narrative: `${richOutput().narrative}\n另见【${inventedReference}】。`, + }; + await assert.rejects( + generateRectificationNarrative({ + phase: "intermediate", + packet: syntheticTechnicalPacket(), + generator: generator([invalid, invalid]), + }), + /RectificationNarrativeUnavailable/, + ); }); test("builds distinct first, intermediate, and final grounded prompts", async () => { diff --git a/frontend/tests/conversational-rectification-client.test.ts b/frontend/tests/conversational-rectification-client.test.ts index 6510d37c..e3d697c9 100644 --- a/frontend/tests/conversational-rectification-client.test.ts +++ b/frontend/tests/conversational-rectification-client.test.ts @@ -4,6 +4,7 @@ import { CONVERSATIONAL_RECTIFICATION_UNAVAILABLE, ConversationalRectificationRequestError, createConversationalRectificationActionRegistry, + conversationalRectificationHistoryForTurn, sendConversationalRectificationCommand, } from "../src/lib/conversational-rectification/client.ts"; import type { ConversationalRectificationTurn } from "../src/lib/conversational-rectification/contracts.ts"; @@ -122,6 +123,7 @@ test("client replays the exact action body after a lost response without adding caseId, actionId: firstActionId, turnVersion: 3, + modelId: "gpt-5-5", domain: "career", answer: "2021 年 7 月开始第一份工作", }); @@ -129,9 +131,55 @@ test("client replays the exact action body after a lost response without adding assert.deepEqual(result, turn); assert.equal(attempts, 2); assert.equal(bodies[0], bodies[1]); + assert.equal((JSON.parse(bodies[0]) as { modelId?: string }).modelId, "gpt-5-5"); assert.equal(Object.hasOwn(JSON.parse(bodies[0]) as object, "price"), false); }); +test("client keeps recovered one-question-at-a-time history outside the strict turn contract", async (context) => { + const conversationMessages = [ + { role: "assistant" as const, text: "请先告诉我一件时间明确的重要经历。" }, + { role: "user" as const, text: "2014 年 6 月大学毕业。" }, + { role: "assistant" as const, text: turn.narrative }, + ]; + context.mock.method(globalThis, "fetch", async () => Response.json({ + ...turn, + conversationMessages, + })); + + const result = await sendConversationalRectificationCommand({ + type: "resume", + caseId, + actionId: firstActionId, + turnVersion: 4, + }); + + assert.deepEqual(result, turn); + assert.deepEqual(conversationalRectificationHistoryForTurn(result), conversationMessages); +}); + +test("client sends regenerate as a payload-free replay command", async (context) => { + let body: unknown; + context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + return Response.json({ ...turn, turnVersion: 5, narrative: "重新生成后的回答。" }); + }); + + const result = await sendConversationalRectificationCommand({ + type: "regenerate", + caseId, + actionId: firstActionId, + turnVersion: 4, + }); + + assert.deepEqual(body, { + type: "regenerate", + caseId, + actionId: firstActionId, + turnVersion: 4, + }); + assert.equal(result.narrative, "重新生成后的回答。"); +}); + test("a JSON 502 is retried once with the exact action body before succeeding", async (context) => { const bodies: string[] = []; context.mock.method(globalThis, "fetch", async (_input: string | URL | Request, init?: RequestInit) => { diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index 4d6d3b74..ef5b69f8 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -58,16 +58,27 @@ const turn: ConversationalRectificationTurn = { pendingConsultationQuestion: "我适合什么时候换工作?", }; +const messages = [{ + role: "user" as const, + text: "2021 年 7 月开始第一份长期工作。", + renderKey: "user-history-1", +}, { + role: "assistant" as const, + text: "这段事业经历很有区分度。目前已经形成一个待确认候选,下一步请说说一段重要关系经历。", + renderKey: "assistant-history-1", +}]; + function controller(overrides: Partial = {}): ConversationalRectificationController { return { turn, + messages, draft: "", selectedDomain: null, correctionTarget: null, pending: false, error: "", getSnapshot: () => ({ - turn, draft: "", selectedDomain: null, correctionTarget: null, pending: false, error: "", + turn, messages, draft: "", selectedDomain: null, correctionTarget: null, pending: false, error: "", }), subscribe: () => () => undefined, synchronizeInitialTurn: () => undefined, @@ -78,6 +89,7 @@ function controller(overrides: Partial = start: async () => turn, resume: async () => turn, answer: async () => turn, + regenerate: async () => turn, pause: async () => turn, abandon: async () => turn, confirm: async () => turn, @@ -85,17 +97,26 @@ function controller(overrides: Partial = }; } +function surfaceProps(value: ConversationalRectificationController) { + return { + controller: value, + models: [{ id: "deepseek-chat", label: "DeepSeek", description: "", creditCost: 1, isDefault: true }] as const, + selectedModelId: "deepseek-chat", + onSelectModel: () => undefined, + }; +} + test("rectification is a language-first exchange with one free-text answer path", () => { const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { controller: controller() }, + surfaceProps(controller()), )); - assert.match(markup, /当前判断/); - assert.match(markup, /D9 与 D10/); - assert.doesNotMatch(markup, /已记录的经历|已记录的真实经历|更正这条经历/); - assert.doesNotMatch(markup, /当前候选 05:18/); - assert.doesNotMatch(markup, /目前已经形成一个待确认候选/); + assert.match(markup, /2021 年 7 月开始第一份长期工作/); + assert.match(markup, /这段事业经历很有区分度/); + assert.match(markup, /目前已经形成一个待确认候选/); + assert.doesNotMatch(markup, /当前候选 05:18|校正进度 ·/); + assert.doesNotMatch(markup, /D9 与 D10|当前判断/); assert.match(markup, /]+id="conversational-rectification-answer"/); assert.match(markup, /像聊天一样回答即可/); assert.match(markup, /2018 年 6 月去了上海工作/); @@ -104,7 +125,7 @@ test("rectification is a language-first exchange with one free-text answer path" assert.doesNotMatch(markup, /2006[^<]*2011|BirthTimeChoiceQuestion|birth-time-choice-question/); }); -test("a resumed legacy turn preserves its Agent narrative without a separate evidence panel", () => { +test("a resumed legacy turn replaces repeated technical prose with actionable guidance", () => { const legacyTurn = { ...turn, status: "active", @@ -114,13 +135,12 @@ test("a resumed legacy turn preserves its Agent narrative without a separate evi } satisfies ConversationalRectificationTurn; const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { controller: controller({ turn: legacyTurn }) }, + surfaceProps(controller({ turn: legacyTurn })), )); - assert.match(markup, /05:30 是范围内的待验证候选/); - assert.match(markup, /D1 保持稳定/); - assert.doesNotMatch(markup, /2021 年 7 月 · 开始第一份长期工作|已记录的经历/); - assert.doesNotMatch(markup, /范围暂未变化不代表提交失败/); + assert.match(markup, /2021 年 7 月开始第一份长期工作/); + assert.match(markup, /下一步请说说一段重要关系经历/); + assert.doesNotMatch(markup, /D1 保持稳定/); }); test("an incomplete concrete event gets a targeted clarification instead of the generic question", () => { @@ -134,15 +154,21 @@ test("an incomplete concrete event gets a targeted clarification instead of the dateLabel: "日期待补充", domain: "relocation", }], - narrative: "你提到离开家去北京开始工作,这件事很有区分度。大致是什么年月?", actions: ["answer", "pause", "abandon"], } satisfies ConversationalRectificationTurn; const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { controller: controller({ turn: incompleteTurn }) }, + surfaceProps(controller({ + turn: incompleteTurn, + messages: [{ + role: "assistant", + text: "你提到“离开家去北京开始工作”,它大致是什么年月?", + renderKey: "assistant-clarification", + }], + })), )); - assert.match(markup, /你提到离开家去北京开始工作/); + assert.match(markup, /你提到“离开家去北京开始工作”/); assert.match(markup, /大致是什么年月/); assert.doesNotMatch(markup, /接下来请说一件/); }); @@ -162,55 +188,53 @@ test("an uninitialized surface shows progress without a second start card", () = }); const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { controller: emptyController }, + surfaceProps(emptyController), )); assert.match(markup, /正在建立校正记录/); assert.doesNotMatch(markup, /系统会先说明候选边界|开始生时校正<\/button>/); }); -test("the first Agent guidance streams into the empty rectification surface", () => { - const emptyController = controller({ - turn: null, - pending: true, - getSnapshot: () => ({ - turn: null, - draft: "", - selectedDomain: null, - correctionTarget: null, - pending: true, - error: "", - }), - }); +test("evidence history stays out of the chat surface while confirmation remains explicit", () => { const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { - controller: emptyController, - openingAssistantText: "先说一件已经发生、并且记得年月的重要经历。", - }, + surfaceProps(controller()), )); - assert.match(markup, /先说一件已经发生、并且记得年月的重要经历/); - assert.doesNotMatch(markup, /正在建立校正记录/); -}); - -test("history management stays hidden while final confirmation remains explicit", () => { - const markup = renderToStaticMarkup(React.createElement( - ConversationalRectificationSurface, - { controller: controller() }, - )); - - assert.doesNotMatch(markup, /已记录的真实经历|2021 年 7 月|更正这条经历/); + assert.doesNotMatch(markup, /已记录的真实经历|更正这条经历:开始第一份长期工作/); assert.doesNotMatch(markup, /本轮分析|等待经历验证/); assert.doesNotMatch(markup, /本轮技术回执|rectification-technical-v1|consult-d9/); - assert.doesNotMatch(markup, /当前候选 05:18/); + assert.doesNotMatch(markup, /当前候选 05:18|待确认,尚未验证/); assert.match(markup, /确认采用 05:18(尚未验证)/); assert.match(markup, /aria-label="确认将 05:18 设为当前排盘时间;当前分钟尚未验证"/); assert.doesNotMatch(markup, /已记录 1 条经历|候选只用于继续验证|这一步不会自动采用候选/); assert.doesNotMatch(markup, /暂停,稍后继续|继续校正|放弃本次校正/); }); -test("an existing correction target can still be cancelled without rendering history management", () => { +test("terminal workflow states do not append synthetic assistant bubbles", () => { + for (const state of [ + { status: "paused", candidateStatus: "pending_validation" }, + { status: "abandoned", candidateStatus: "pending_validation" }, + { status: "completed", candidateStatus: "confirmed" }, + ] as const) { + const terminalTurn = { + ...turn, + status: state.status, + candidate: { ...turn.candidate, status: state.candidateStatus }, + actions: [], + } satisfies ConversationalRectificationTurn; + const markup = renderToStaticMarkup(React.createElement( + ConversationalRectificationSurface, + surfaceProps(controller({ turn: terminalTurn })), + )); + + assert.doesNotMatch(markup, /校正已暂停,输入与现有证据都已保留/); + assert.doesNotMatch(markup, /本次校正已放弃,候选时间没有应用/); + assert.doesNotMatch(markup, /候选时间已经过你的明确确认|候选范围已保存/); + } +}); + +test("an active correction target remains cancellable without rendering evidence history", () => { const revisedTurn = { ...turn, evidenceRecap: [{ ...turn.evidenceRecap[0]!, isCorrection: true }], @@ -218,7 +242,7 @@ test("an existing correction target can still be cancelled without rendering his const correctionTarget = revisedTurn.evidenceRecap[0]!; const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { controller: controller({ + surfaceProps(controller({ turn: revisedTurn, draft: "更正:其实是 2020 年 11 月离职", correctionTarget, @@ -230,13 +254,13 @@ test("an existing correction target can still be cancelled without rendering his pending: false, error: "", }), - }) }, + })), )); assert.match(markup, /正在更正/); assert.match(markup, /开始第一份长期工作/); assert.match(markup, /取消更正/); - assert.doesNotMatch(markup, /(已修订)|已记录的经历|更正这条经历/); + assert.doesNotMatch(markup, /(已修订)|已记录的真实经历/); }); test("pending markup and responsive CSS expose accessibility contracts", () => { @@ -254,14 +278,7 @@ test("pending markup and responsive CSS expose accessibility contracts", () => { }); const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { controller: pendingController }, - )); - const streamingMarkup = renderToStaticMarkup(React.createElement( - ConversationalRectificationSurface, - { controller: controller({ - pending: true, - streamingAssistantText: "已记录关系事件,接下来核对开始时间。", - }) }, + surfaceProps(pendingController), )); const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); const component = readFileSync( @@ -272,22 +289,16 @@ test("pending markup and responsive CSS expose accessibility contracts", () => { assert.match(markup, /aria-busy="true"/); assert.match(markup, /Jyotisha 正在核对经历/); - assert.match(markup, /正在核对这段经历/); + assert.doesNotMatch(markup, /正在核对这段经历|Enter 发送|已发送,2\.5 秒/); assert.match(markup, /Jyotisha 正在分析/); - assert.match(streamingMarkup, /已记录关系事件,接下来核对开始时间/); - assert.doesNotMatch(streamingMarkup, /Jyotisha 正在分析/); assert.match(markup, /]+disabled=""[^>]*>保留中的文字<\/textarea>/); assert.match(markup, /aria-label="生时校正对话"/); assert.match(markup, /role="alert"|aria-live="polite"/); assert.match(css, /\.conversation\.is-rectification[^}]*padding-bottom:\s*0/); assert.match(css, /\.rectification-chat[^}]*height:\s*100%[^}]*display:\s*flex/); assert.match(css, /\.rectification-message-list[^}]*flex:\s*1[^}]*overflow-y:\s*auto/); - assert.match(css, /\.rectification-message-list[^}]*scrollbar-color:\s*transparent transparent/); - assert.match(css, /\.rectification-message-list[^}]*scrollbar-width:\s*thin/); - assert.match(css, /\.rectification-message-list::\-webkit-scrollbar-thumb[^}]*background:\s*transparent[^}]*background-clip:\s*padding-box/); - assert.match(css, /\.rectification-message-list\.is-scrollbar-visible::\-webkit-scrollbar-thumb[^}]*color-mix/); - assert.match(css, /\.rectification-message-list::\-webkit-scrollbar-thumb:hover[^}]*color-mix/); - assert.match(css, /\.rectification-correction-target button[^}]*min-height:\s*44px/); + assert.match(component, /conversationEnd\.current\?\.scrollIntoView/); + assert.match(component, /
/); assert.match(css, /\.composer:focus-within[^}]*border-color:/); assert.match(css, /\.composer textarea[^}]*border:\s*0/); assert.match(css, /\.rectification-composer-wrap[^}]*position:\s*static/); @@ -295,16 +306,36 @@ test("pending markup and responsive CSS expose accessibility contracts", () => { assert.doesNotMatch(css, /\.conversational-domain-picker|\.conversational-event-date/); assert.doesNotMatch(css, /button\[aria-label\$="下一步建议"\]/); assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/); - assert.doesNotMatch(css, /\.rectification-message-details/); assert.doesNotMatch(component, /确认放弃且不应用候选|本轮技术回执/); - assert.match(component, /onPointerMove=\{revealScrollbar\}/); - assert.match(component, /onScroll=\{revealScrollbar\}/); - assert.match(component, /classList\.remove\("is-scrollbar-visible"\)/); assert.match(component, /controller\.answer\(undefined, text\)/); assert.match(component, /event\.key === "Enter" && !event\.shiftKey/); assert.match(component, /onPendingChange/); assert.match(component, /onPendingChange:\s*props\.onPendingChange/); assert.match(component, /onContinueOriginalQuestion\?\./); + assert.match(markup, /aria-label="赞"/); + assert.match(markup, /aria-label="踩"/); + assert.match(markup, /aria-label="复制回答"/); + assert.match(markup, /aria-label="重跑回答"/); + assert.match(component, /navigator\.clipboard\.writeText/); + assert.match(component, /setRegeneratingMessageKey\(messageKey\)/); + assert.match(component, /regeneratingMessageKey === message\.renderKey[\s\S]*?state: "thinking"/); + assert.match(component, /controller\.pending && canAnswer && regeneratingMessageKey === null/); + assert.match(component, /await controller\.regenerate\(\)/); + assert.match(component, /message\.renderKey !== latestAssistantKey/); + assert.match(css, /\.rectification-message-actions/); +}); + +test("the page persists and rehydrates the full rectification transcript", () => { + const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + const component = readFileSync( + new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), + "utf8", + ); + + assert.match(page, /firstSessionMessages[\s\S]*?role: "assistant"[\s\S]*?turn\.narrative\.trim\(\)/); + assert.match(page, /messages:\s*durableRectificationMessages\(messages\)/); + assert.match(page, /initialMessages=\{activeSession\?\.messages \?\? \[\]\}/); + assert.match(component, /initialMessages:\s*props\.initialMessages/); }); type CdpResponse = Readonly<{ @@ -463,8 +494,6 @@ class CdpSession { function chromiumExecutable(): string { if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH; const systemCandidates = [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", @@ -509,7 +538,7 @@ function chromiumExecutable(): string { for (const candidate of systemCandidates) { if (existsSync(candidate)) return candidate; } - throw new Error("Chromium is required for the real DOM contract; set CHROME_PATH to its executable."); + throw new Error("A Playwright headless Chromium is required for the real DOM contract; install it without pointing CHROME_PATH at the user's desktop Chrome."); } async function waitFor(probe: () => Promise, label: string): Promise { @@ -715,14 +744,7 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro }); const turns = { activeA1: makeTurn(caseA, 1), - activeA3: { - ...makeTurn(caseA, 13), - messageHistory: Array.from({ length: 12 }, (_, index) => ({ - turnVersion: index + 1, - userMessage: "第 " + (index + 1) + " 条已发生经历,用于制造可滚动的真实对话历史。", - narrative: "已记录第 " + (index + 1) + " 条经历,并继续核对其他领域的候选差异。", - })), - }, + activeA3: makeTurn(caseA, 3), activeB1: makeTurn(caseB, 1), abandonedB2: makeTurn(caseB, 2, "abandoned"), }; @@ -732,16 +754,8 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro const [initialTurn, setInitialTurn] = useState(null); const [transportLabel, setTransportLabel] = useState("first"); const [callbackLabel, setCallbackLabel] = useState("first"); - const send = async (command, options) => { + const send = async (command) => { events.push("send:" + transportLabel + ":" + command.type); - if (command.type === "answer") { - await new Promise((resolveSend) => setTimeout(resolveSend, 20)); - options?.onNarrativeDelta?.("正在结合这段经历核对候选。 "); - events.push("delta:first"); - await new Promise((resolveSend) => setTimeout(resolveSend, 20)); - options?.onNarrativeDelta?.("下一步会继续验证其他领域。"); - events.push("delta:second"); - } await new Promise((resolveSend) => setTimeout(resolveSend, 20)); if (command.type === "pause") { return makeTurn(command.caseId, command.turnVersion + 1, "paused"); @@ -765,7 +779,12 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro }; globalThis.__rectificationReady = true; }); - return ; + return undefined} + />; } createRoot(document.getElementById("root")).render(); `; @@ -785,7 +804,7 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro outfile: bundlePath, platform: "browser", }), 10_000, "browser fixture bundle"); - writeFileSync(htmlPath, `
`); + writeFileSync(htmlPath, `
`); ({ browser, cdp } = await launchFixture(htmlPath, userDataDirectory)); await cdp.send("Runtime.enable"); @@ -802,9 +821,8 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro ); await cdp.evaluate("globalThis.__rectificationHarness.setTurn('activeA1')"); await waitFor( - () => cdp?.evaluate(`document.body.textContent.includes('当前判断') - && !document.body.textContent.includes('当前候选 05:18') - && !document.body.textContent.includes('已记录的经历')`) ?? Promise.resolve(false), + () => cdp?.evaluate(`document.body.textContent.includes('2021-07 · 开始第一份长期工作') + && !document.body.textContent.includes('已记录这段经历')`) ?? Promise.resolve(false), "streamlined async initial turn", ); @@ -858,9 +876,8 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro await waitFor( () => cdp?.evaluate(`(() => { const text = document.body.textContent; - return text.includes('当前判断') - && !text.includes('当前候选 05:18') - && !text.includes('已记录的经历') + return !text.includes('当前候选') + && !text.includes('候选时间') && !text.includes('本轮技术回执') && !text.includes('暂停,稍后继续') && !text.includes('放弃本次校正') @@ -869,42 +886,6 @@ test("real Chromium at 390px verifies layout, keyboard focus, streamlined contro "streamlined rectification controls", ); - const followsBottom = `(() => { - const list = document.querySelector('.rectification-message-list'); - return list.scrollHeight > list.clientHeight - && Math.abs(list.scrollHeight - list.clientHeight - list.scrollTop) <= 2; - })()`; - await waitFor( - () => cdp?.evaluate(followsBottom) ?? Promise.resolve(false), - "initial rectification history at the bottom", - ); - await cdp.evaluate("document.querySelector('.rectification-message-list').scrollTop = 0"); - const correctedAnswer = "2020年9月底主动离职,原因是组织内耗严重。"; - await cdp.evaluate(`(() => { - const textarea = document.getElementById('conversational-rectification-answer'); - const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; - setter.call(textarea, ${JSON.stringify(correctedAnswer)}); - textarea.dispatchEvent(new Event('input', { bubbles: true })); - document.querySelector('[aria-label="发送"]').click(); - })()`); - await waitFor( - () => cdp?.evaluate(followsBottom) ?? Promise.resolve(false), - "optimistic user message follows the bottom", - ); - await waitFor( - () => cdp?.evaluate(`globalThis.__rectificationHarness.events.includes('delta:first') - && document.body.textContent.includes('正在结合这段经历核对候选。') - && ${followsBottom}`) ?? Promise.resolve(false), - "streaming assistant delta follows the bottom", - ); - await waitFor( - () => cdp?.evaluate(`globalThis.__rectificationHarness.events.includes('delta:second') - && document.body.textContent.includes('回答已完成') - && document.body.textContent.includes(${JSON.stringify(correctedAnswer)}) - && ${followsBottom}`) ?? Promise.resolve(false), - "settled assistant answer follows the bottom", - ); - } finally { try { cdp?.close(); diff --git a/frontend/tests/conversational-rectification-controller.test.ts b/frontend/tests/conversational-rectification-controller.test.ts index f6c7c120..352209f4 100644 --- a/frontend/tests/conversational-rectification-controller.test.ts +++ b/frontend/tests/conversational-rectification-controller.test.ts @@ -6,6 +6,7 @@ import { import { CONVERSATIONAL_RECTIFICATION_UNAVAILABLE, ConversationalRectificationRequestError, + sendConversationalRectificationCommand, } from "../src/lib/conversational-rectification/client.ts"; import type { ConversationalRectificationCommand, @@ -104,6 +105,30 @@ function deferred() { return { promise, reject, resolve }; } +test("controller sends the selected model for generated rectification turns", async () => { + const commands: ConversationalRectificationCommand[] = []; + let selectedModelId = "gpt-5-5"; + const controller = createConversationalRectificationController({ + initialTurn: activeTurn(), + createActionId: idFactory(), + getModelId: () => selectedModelId, + send: async (command) => { + commands.push(command); + return activeTurn(commands.length + 2); + }, + }); + + controller.setDraft("2020 年 10 月主动离职"); + await controller.answer(); + selectedModelId = "deepseek-pro"; + await controller.regenerate(); + + assert.equal(commands[0]?.type, "answer"); + assert.equal(commands[0]?.modelId, "gpt-5-5"); + assert.equal(commands[1]?.type, "regenerate"); + assert.equal(commands[1]?.modelId, "deepseek-pro"); +}); + test("controller admits only one in-flight mutation and clears text only after success", async () => { const commands: ConversationalRectificationCommand[] = []; const pendingChanges: boolean[] = []; @@ -172,9 +197,13 @@ test("controller publishes streamed Agent text while the durable rectification t test("controller retains alternating user and Agent messages after each answer", async () => { const initial = activeTurn(); + let persistedMessages: readonly { role: "assistant" | "user"; text: string }[] = []; const controller = createConversationalRectificationController({ initialTurn: initial, createActionId: idFactory(), + onTurn: (_turn, messages) => { + persistedMessages = messages; + }, send: async () => ({ ...activeTurn(3), narrative: "你提到 2021 年 7 月开始第一份工作,这次职业起点已经记下。接下来想核对一次搬迁。", @@ -189,6 +218,54 @@ test("controller retains alternating user and Agent messages after each answer", { role: "user", text: "2021 年 7 月开始第一份工作" }, { role: "assistant", text: "你提到 2021 年 7 月开始第一份工作,这次职业起点已经记下。接下来想核对一次搬迁。" }, ]); + assert.deepEqual(persistedMessages.map(({ role, text }) => ({ role, text })), [ + { role: "assistant", text: initial.narrative }, + { role: "user", text: "2021 年 7 月开始第一份工作" }, + { role: "assistant", text: "你提到 2021 年 7 月开始第一份工作,这次职业起点已经记下。接下来想核对一次搬迁。" }, + ]); +}); + +test("controller restores the persisted opening guidance before later turns after refresh", () => { + const latestTurn = { + ...correctableTurn(3), + narrative: "这段教育经历已经记下。下一步想核对一件职业事件。", + }; + const controller = createConversationalRectificationController({ + initialTurn: latestTurn, + initialMessages: [ + { role: "assistant", text: "请先告诉我一件时间明确的重要经历。" }, + { role: "user", text: "2014 年 6 月大学毕业。" }, + { role: "assistant", text: latestTurn.narrative }, + ], + }); + + assert.deepEqual(controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })), [ + { role: "assistant", text: "请先告诉我一件时间明确的重要经历。" }, + { role: "user", text: "2014 年 6 月大学毕业。" }, + { role: "assistant", text: "这段教育经历已经记下。下一步想核对一件职业事件。" }, + ]); +}); + +test("legacy fallback never stacks the full evidence recap as consecutive user messages", () => { + const controller = createConversationalRectificationController({ + initialTurn: { + ...correctableTurn(5), + narrative: "最后一条经历已经记下,接下来核对关系领域。", + evidenceRecap: [ + { id: evidenceId, summary: "大学毕业", dateLabel: "2014-06" }, + { + id: "00000000-0000-4000-8000-000000000820", + summary: "入职第一家公司", + dateLabel: "2017-07", + }, + ], + }, + }); + + assert.deepEqual(controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })), [ + { role: "user", text: "2017-07 · 入职第一家公司" }, + { role: "assistant", text: "最后一条经历已经记下,接下来核对关系领域。" }, + ]); }); test("controller restores the real user and Agent history without synthesizing recap templates", () => { @@ -682,3 +759,95 @@ test("an external same-version turn is not replaced by a late response", async ( assert.equal(controller.getSnapshot().turn?.narrative, "外部已同步的最新轮次"); }); + +test("a same-version recovery response repairs user and assistant bubbles from durable history", async (context) => { + const initial = { ...activeTurn(7), narrative: "请继续说一件已经发生的重要经历。" }; + const answer = "补充:我是 2020 年 10 月辞的职"; + const narrative = "明白了,2020 年 10 月正式辞职。辞职之后是很快工作,还是经历了空窗期?"; + const recovered = { ...activeTurn(8), narrative }; + const response = deferred(); + context.mock.method(globalThis, "fetch", async () => response.promise); + const controller = createConversationalRectificationController({ + initialTurn: initial, + initialMessages: [{ role: "assistant", text: initial.narrative }], + createActionId: idFactory(), + send: sendConversationalRectificationCommand, + }); + controller.setDraft(answer); + const pending = controller.answer("career"); + + controller.synchronizeInitialTurn(recovered); + response.resolve(Response.json({ + ...recovered, + conversationMessages: [ + { role: "assistant", text: initial.narrative }, + { role: "user", text: answer }, + { role: "assistant", text: narrative }, + ], + })); + await pending; + + assert.deepEqual( + controller.getSnapshot().messages?.map(({ role, text }) => ({ role, text })), + [ + { role: "assistant", text: initial.narrative }, + { role: "user", text: answer }, + { role: "assistant", text: narrative }, + ], + ); +}); + +test("regenerate replaces only the latest assistant reply without replaying the user event", async () => { + const commands: ConversationalRectificationCommand[] = []; + let observedMessages: readonly { role: "assistant" | "user"; text: string }[] = []; + const initialMessages = [{ + role: "assistant" as const, + text: "请先说一件时间明确的重要经历。", + }, { + role: "user" as const, + text: "1972 年秋进入里德学院,读了一个学期后退学。", + }, { + role: "assistant" as const, + text: "为了进一步判断,我需要确认一个关键信息——", + }]; + const controller = createConversationalRectificationController({ + initialTurn: { + ...activeTurn(2), + narrative: initialMessages[2].text, + }, + initialMessages, + createActionId: () => actionIds[0], + send: async (command) => { + commands.push(command); + return { + ...activeTurn(3), + narrative: "这段教育经历已经记下。你当时感到压力最强,大约是在 1972 年哪一个月?", + }; + }, + onTurn: (_turn, messages) => { + observedMessages = messages; + }, + }); + controller.setDraft("尚未发送的草稿"); + + await controller.regenerate(); + + assert.deepEqual(commands, [{ + type: "regenerate", + caseId, + actionId: actionIds[0], + turnVersion: 2, + }]); + const messages = controller.getSnapshot().messages ?? []; + assert.equal(messages.length, 3); + assert.deepEqual(messages.map(({ role, text }) => ({ role, text })), [ + initialMessages[0], + initialMessages[1], + { + role: "assistant", + text: "这段教育经历已经记下。你当时感到压力最强,大约是在 1972 年哪一个月?", + }, + ]); + assert.equal(controller.getSnapshot().draft, "尚未发送的草稿"); + assert.equal(observedMessages.length, 3); +}); diff --git a/frontend/tests/conversational-rectification-domain-migration.test.ts b/frontend/tests/conversational-rectification-domain-migration.test.ts index 58989c17..8b0330c2 100644 --- a/frontend/tests/conversational-rectification-domain-migration.test.ts +++ b/frontend/tests/conversational-rectification-domain-migration.test.ts @@ -23,6 +23,13 @@ const requestCardinalityMigration = readFileSync( ), "utf8", ); +const followUpMigration = readFileSync( + new URL( + "../supabase/migrations/20260723010000_align_conversational_follow_up_request.sql", + import.meta.url, + ), + "utf8", +); test("durable rectification SQL accepts every application evidence domain", () => { for (const validator of [ @@ -73,3 +80,16 @@ test("durable evidence requests allow one focused follow-up domain", () => { assert.match(requestCardinalityMigration, /'between 2 and 4'/); assert.match(requestCardinalityMigration, /'between 1 and 4'/); }); + +test("durable evidence requests accept bounded follow-up targeting", () => { + assert.match( + followUpMigration, + /array\['domains', 'datePrecision', 'freeTextAllowed', 'followUp'\]/, + ); + assert.match(followUpMigration, /array\['kind', 'evidenceId'\]/); + assert.match( + followUpMigration, + /'new_event', 'event_date', 'event_detail'/, + ); + assert.match(followUpMigration, /valid_uuid_text/); +}); diff --git a/frontend/tests/conversational-rectification-e2e.test.ts b/frontend/tests/conversational-rectification-e2e.test.ts index cdd8095b..b93a5af9 100644 --- a/frontend/tests/conversational-rectification-e2e.test.ts +++ b/frontend/tests/conversational-rectification-e2e.test.ts @@ -468,11 +468,17 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato let turn = await post(handler, { type: "start", actionId: caseId, pendingConsultationQuestion: originalQuestion }); assert.equal(turn.pendingConsultationQuestion, originalQuestion); assert.equal(turn.status, "active"); - assert.match(turn.narrative, /05:00–06:00.*不能/); + assert.equal( + turn.narrative, + "当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。", + "the first visible reply must remain the narrator's authored answer rather than receive a deterministic prefix or suffix", + ); assert.doesNotMatch(turn.narrative, /\bD\d+\b/); - assert.equal((turn.narrative.match(/[??]/g) ?? []).length, 1); - assert.match(turn.narrative, /哪一年、哪一月/); - assert.deepEqual(turn.evidenceRequest?.domains, ["career"]); + assert.deepEqual( + turn.evidenceRequest?.domains, + ["career"], + "the narrator chooses the next useful domain instead of receiving a program-authored domain list", + ); assert.equal(turn.evidenceRequest?.freeTextAllowed, true); assert.equal(JSON.stringify(turn).includes("candidateWeights"), false); assert.equal(JSON.stringify(turn).includes("private-synthetic-partition"), false); @@ -485,16 +491,23 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato turnVersion: turn.turnVersion, domain: "career", answer: "都不符合,我想换一个方向", }); assert.equal(turn.status, "active"); - assert.match(turn.narrative, /不沿用不符合.*自由描述另一件已经发生/); + assert.equal( + turn.narrative, + "当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。", + "a direction change must show the model reply instead of a program-authored redirect template", + ); + assert.doesNotMatch(turn.narrative, /不沿用不符合|自由描述另一件已经发生/); assert.equal(backend.billing().chargeCount, 1); turn = await post(handler, { type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009004", turnVersion: turn.turnVersion, domain: "career", answer: "后来工作压力很大", }); - assert.match(turn.narrative, /工作压力很大/); - assert.match(turn.narrative, /什么年月/); - assert.doesNotMatch(turn.narrative, /请提供.*真实事件/); + assert.equal( + turn.narrative, + "当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。", + ); + assert.doesNotMatch(turn.narrative, /我先按你的原话记下|我已保存你的原话|还差时间定位/); assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充"); const futureEvidenceAction = "00000000-0000-4000-8000-000000009050"; @@ -502,7 +515,12 @@ test("authenticated synthetic flow covers soft entry, rich evidence, resume, ato type: "answer", caseId, actionId: futureEvidenceAction, turnVersion: turn.turnVersion, domain: "career", answer: "2027年计划换工作", }); - assert.match(turn.narrative, /未来事件只能作为背景.*不能用于校正评分/); + assert.equal( + turn.narrative, + "当前仍在核对 05:00–06:00 的候选范围,不能视为已经确认的出生分钟。先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。", + "future evidence must remain non-scoreable without replacing the model's visible answer", + ); + assert.doesNotMatch(turn.narrative, /未来事件只能作为背景|不能用于校正评分/); assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "2027(未来,仅作背景)"); const futureEvidenceId = turn.evidenceRecap.at(-1)?.id; assert.ok(futureEvidenceId); @@ -881,12 +899,20 @@ test("rollback flag stops only new cases while existing v3 resume stays readable allowNewCaseCreation: pendingPolicy.allowNewCaseCreation, }); backend.cases.set(caseId, enabled.cases.get(caseId)!); + const blockedHandler = createBirthTimeConversationPostHandler({ + authenticate: async () => ({ + userId: "00000000-0000-4000-8000-000000009098", + context: {}, + }), + createService: async () => backend.service, + telemetry: () => undefined, + }); const handler = createBirthTimeConversationPostHandler({ authenticate: async () => ({ userId, context: {} }), createService: async () => backend.service, telemetry: () => undefined, }); - const blockedStart = await handler(new Request("https://example.invalid/api/birth-time-conversation", { + const blockedStart = await blockedHandler(new Request("https://example.invalid/api/birth-time-conversation", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "start", actionId: "00000000-0000-4000-8000-000000009050" }), diff --git a/frontend/tests/conversational-rectification-orchestrator.test.ts b/frontend/tests/conversational-rectification-orchestrator.test.ts index ca0e3429..b89ea737 100644 --- a/frontend/tests/conversational-rectification-orchestrator.test.ts +++ b/frontend/tests/conversational-rectification-orchestrator.test.ts @@ -25,6 +25,7 @@ const laterActionId = "00000000-0000-4000-8000-000000000710"; const secondAnswerActionId = "00000000-0000-4000-8000-000000000711"; const thirdAnswerActionId = "00000000-0000-4000-8000-000000000712"; const fourthAnswerActionId = "00000000-0000-4000-8000-000000000713"; +const secondStartActionId = "00000000-0000-4000-8000-000000000714"; const declaredBirthInput = { source: "approximate" as const, @@ -100,10 +101,19 @@ function validGenerator( varyNarrative = false, invalidNarrativeFromGeneration?: number, prompts: string[] = [], + continueLatestEvent = false, + detailQuestion = "这份工作是正式工作,还是实习或兼职?", + mislabelLatestDetailAsNewEvent = false, + classifyEvidenceDomain?: (text: string) => "career" | "education" | "finance" | "health_pressure" | "relocation" | "relationship" | "family" | "other" | null, ) { let generation = 0; return { modelId: "synthetic-rectification-model", + ...(classifyEvidenceDomain ? { + async classifyEvidenceDomain(input: Readonly<{ text: string }>) { + return classifyEvidenceDomain(input.text); + }, + } : {}), async generate(prompt: string) { generation += 1; events.push("narrative"); @@ -115,8 +125,17 @@ function validGenerator( const request = JSON.parse(prompt) as { phase: "first" | "intermediate" | "final"; conversationContext?: { - latestEvidence?: Array<{ dateLabel: string; summary: string; domain: string }>; - eventLedger?: Array<{ domain: string; active: boolean }>; + latestEvidence?: Array<{ dateLabel: string; summary: string }>; + eventLedger?: Array<{ + id: string; + summary: string; + active: boolean; + }>; + unresolvedEvidence?: Array<{ + id: string; + summary: string; + dateLabel: string; + }>; }; packet: Omit, "candidate"> & { candidate: ReturnType["candidate"] & { @@ -126,20 +145,32 @@ function validGenerator( }; }; const value = request.packet; - const answeredDomains = new Set(request.conversationContext?.eventLedger - ?.filter((item) => item.active) - .map((item) => item.domain) ?? []); - const nextSuggested = value.suggestedDomains.find((item) => !answeredDomains.has(item.domain)) - ?? value.suggestedDomains[0]; - const domains = nextSuggested ? [nextSuggested.domain] : []; + const hasRelationshipEvidence = request.conversationContext?.eventLedger + ?.some((item) => item.active && /关系|恋爱|分手|结婚|离婚|伴侣/.test(item.summary)) === true; + const domains = hasRelationshipEvidence + ? ["career" as const] + : ["relationship" as const]; const nextDomain = domains[0] === "relationship" ? "重要关系" : "事业"; const latest = request.conversationContext?.latestEvidence?.at(-1); + const latestActiveEvent = request.conversationContext?.eventLedger + ?.filter((item) => item.active) + .at(-1); + const unresolved = request.conversationContext?.unresolvedEvidence?.at(-1); + const asksForLatestDetail = continueLatestEvent + && request.phase === "intermediate" + && latestActiveEvent !== undefined; const narrative = [ request.phase === "intermediate" && latest ? `记下了:${latest.dateLabel} · ${latest.summary}。` : `当前仍在核对 ${value.candidate.rangeStart}–${value.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`, varyNarrative ? `这是第 ${generation} 次合成措辞。` : "", - request.phase === "final" ? "当前证据已形成候选总结。" : `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`, + request.phase === "final" + ? "当前证据已形成候选总结。" + : unresolved?.dateLabel === "日期待补充" + ? `我先把“${unresolved.summary}”这件事补完整:它大约发生在哪一年、哪一月?` + : asksForLatestDetail + ? detailQuestion + : `先说一件已经发生的${nextDomain}经历好吗?请写明哪一年、哪一月以及发生了什么。`, ].join(""); return { text: JSON.stringify({ narrative, @@ -148,14 +179,25 @@ function validGenerator( rangeStart: value.candidate.rangeStart, rangeEnd: value.candidate.rangeEnd, useBoundary: value.useBoundary, - stableLayers: value.stableLayers.map((item) => item.layer), - sensitiveLayers: value.sensitiveLayers.map((item) => item.layer), + stableLayers: value.stableLayers.map((item) => typeof item === "string" ? item : item.layer), + sensitiveLayers: value.sensitiveLayers.map((item) => typeof item === "string" ? item : item.layer), referenceIds: [], - domainReasons: value.suggestedDomains.map((item) => ({ ...item })), + domainReasons: [], evidenceRequest: request.phase === "final" ? null : { domains, datePrecision: "month_preferred", - prompt: `请说一件已经发生的${nextDomain}经历,并写明哪一年、哪一月以及发生了什么。`, + prompt: unresolved?.dateLabel === "日期待补充" + ? `“${unresolved.summary}”大约发生在哪一年、哪一月?` + : asksForLatestDetail + ? detailQuestion + : `请说一件已经发生的${nextDomain}经历,并写明哪一年、哪一月以及发生了什么。`, + followUp: unresolved?.dateLabel === "日期待补充" + ? { kind: "event_date", evidenceId: unresolved.id } + : asksForLatestDetail + ? mislabelLatestDetailAsNewEvent + ? { kind: "new_event", evidenceId: null } + : { kind: "event_detail", evidenceId: latestActiveEvent.id } + : { kind: "new_event", evidenceId: null }, }, }) }; }, @@ -168,11 +210,16 @@ type MutableCase = { function harness(options: { readonly packetFailure?: Error; + readonly packetFailureFromBuild?: number; readonly completeFailures?: number; readonly releaseFailure?: boolean; readonly readyAfterEvidenceCount?: number; readonly varyNarrative?: boolean; readonly invalidNarrativeFromGeneration?: number; + readonly continueLatestEvent?: boolean; + readonly detailQuestion?: string; + readonly mislabelLatestDetailAsNewEvent?: boolean; + readonly classifyEvidenceDomain?: (text: string) => "career" | "education" | "finance" | "health_pressure" | "relocation" | "relationship" | "family" | "other" | null; } = {}) { const events: string[] = []; const narrativePrompts: string[] = []; @@ -394,7 +441,9 @@ function harness(options: { } : null); events.push(input.evidence.length > 0 ? "score-packet" : "packet"); - if (options.packetFailure) throw options.packetFailure; + if (options.packetFailure + && (options.packetFailureFromBuild === undefined + || packetBuilds >= options.packetFailureFromBuild)) throw options.packetFailure; return input.evidence.length >= (options.readyAfterEvidenceCount ?? 1) ? { packet: packet(true), resultId } : { packet: packet(false), resultId: null }; @@ -404,6 +453,10 @@ function harness(options: { options.varyNarrative, options.invalidNarrativeFromGeneration, narrativePrompts, + options.continueLatestEvent, + options.detailQuestion, + options.mislabelLatestDetailAsNewEvent, + options.classifyEvidenceDomain, ), asOfDate: () => "2026-07-21", }; @@ -495,6 +548,50 @@ test("a start retry settles an existing reservation without reserving or computi assert.deepEqual(value.events.slice(-3), ["profile", "price", "complete"]); }); +test("a duplicate start with the same declared birth input reuses the unfinished case", async () => { + const value = harness(); + const first = await start(value, null); + const countsBeforeRetry = value.counts(); + + const replayed = await value.service.start(userId, { + type: "start", + actionId: secondStartActionId, + pendingConsultationQuestion: null, + }); + + assert.deepEqual(replayed, first); + assert.deepEqual(value.counts(), countsBeforeRetry); + assert.deepEqual(value.events, [ + "profile", "price", "reserve:9", "packet", "narrative", "create", "complete", + "profile", "price", + ]); +}); + +test("a duplicate start with changed declared birth input remains a conflict", async () => { + const value = harness(); + await start(value, null); + + const current = value.cases.get(startActionId)?.row; + assert.ok(current); + value.cases.set(startActionId, { + row: { + ...current, + declaredBirthInput: { + ...current.declaredBirthInput, + birthDate: "2000-01-02", + }, + }, + }); + await assert.rejects(value.service.start(userId, { + type: "start", + actionId: secondStartActionId, + pendingConsultationQuestion: null, + }), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "action_conflict"); + assert.equal(value.counts().reserveCount, 1); + assert.equal(value.counts().packetBuilds, 1); +}); + test("a settlement failure releases its created case once and cannot replay as success", async () => { const value = harness({ completeFailures: 1 }); await assert.rejects(start(value), (error: unknown) => error instanceof ConversationalRectificationError @@ -585,6 +682,56 @@ test("evidence corrections are append-only while recap and scoring use only the assert.deepEqual(value.packetEvidenceCounts, [0, 1, 1, 1]); }); +test("uses Agent semantic classification when a single event falls through the deterministic keywords", async () => { + const classifiedTexts: string[] = []; + const value = harness({ + readyAfterEvidenceCount: 99, + classifyEvidenceDomain(text) { + classifiedTexts.push(text); + return "career"; + }, + }); + await start(value, null); + + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2020年4月去石油化工研究院实习做研究员", + }); + + assert.deepEqual(classifiedTexts, ["2020年4月去石油化工研究院实习做研究员"]); + const saved = value.cases.get(startActionId)?.row.eventEvidence.at(-1); + assert.equal(saved?.dateValue, "2020-04"); + assert.equal(saved?.domain, "career"); + assert.equal(saved?.scoreable, true); +}); + +test("repeated evidence remains auditable but identical date-domain-semantics score only once", async () => { + const value = harness({ readyAfterEvidenceCount: 2 }); + await start(value, null); + await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: answerActionId, + turnVersion: 0, answer: "2019年7月开始第一份工作", + }); + const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id; + assert.ok(firstId); + + const repeated = await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: secondAnswerActionId, + turnVersion: 1, answer: "2019年7月开始第一份工作", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, 2, "both user submissions remain in the audit ledger"); + assert.equal(repeated.evidenceRecap.length, 2); + assert.deepEqual(value.packetEvidenceCounts, [0, 1, 1]); + assert.deepEqual(value.packetEvidenceIds.at(-1), [firstId]); + assert.equal(repeated.status, "active"); + assert.equal(repeated.actions.includes("confirm"), false); +}); + test("an unclear correction immediately retires the wrong fact and stays retired after later turns", async () => { const value = harness(); await start(value, null); @@ -634,22 +781,10 @@ test("every non-confirmable correction rescans the declared range and withdraws { name: "unclear", answer: "更正:具体年月记不清" }, { name: "future", answer: "更正:2099年3月开始新工作" }, { name: "direction change", answer: "更正:这些都不符合" }, - { - name: "narrative validation fallback", - answer: "更正:其实是2020年11月离职", - invalidNarrativeFromGeneration: 3, - }, ] as const; for (const scenario of scenarios) { - const invalidNarrativeFromGeneration = "invalidNarrativeFromGeneration" in scenario - ? scenario.invalidNarrativeFromGeneration - : undefined; - const value = harness({ - ...(invalidNarrativeFromGeneration === undefined - ? {} - : { invalidNarrativeFromGeneration }), - }); + const value = harness(); await start(value, null); const prior = await value.service.answer(userId, { type: "answer", @@ -696,6 +831,36 @@ test("every non-confirmable correction rescans the declared range and withdraws } }); +test("a narrative fallback cannot discard a confirmation candidate produced by a valid correction", async () => { + const value = harness({ invalidNarrativeFromGeneration: 3 }); + await start(value, null); + await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: answerActionId, + turnVersion: 0, answer: "2018年6月毕业,2019年7月开始第一份工作,2021年3月搬家", + }); + const wrongId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id; + assert.ok(wrongId); + + const corrected = await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: secondAnswerActionId, + turnVersion: 1, answer: "更正:其实是2020年11月离职", correctsEvidenceId: wrongId, + }); + const stored = value.cases.get(startActionId)?.row; + assert.ok(stored); + assert.equal(stored.validationReceipts.at(-1)?.fallbackUsed, true); + assert.equal(corrected.status, "confirming"); + assert.equal(corrected.candidate.status, "ready_for_confirmation"); + assert.equal(stored.privateCandidate.resultId, resultId); + assert.equal(stored.privateCandidate.workingState?.phase, "ready"); + + const confirmed = await value.service.confirm(userId, { + type: "confirm", caseId: startActionId, actionId: confirmActionId, + turnVersion: corrected.turnVersion, time: "05:18", + }); + assert.equal(confirmed.status, "completed"); + assert.equal(value.mutations.at(-1), "confirm"); +}); + test("a clear one-to-one correction can form a new confirmation candidate only after a declared-range rescan", async () => { const value = harness(); await start(value, null); @@ -847,7 +1012,11 @@ test("a concrete event without a date is acknowledged and a date-only follow-up }); assert.match(clarification.narrative, /离开家去北京开始工作/); - assert.match(clarification.narrative, /什么年月/); + assert.match(clarification.narrative, /哪一年、哪一月/); + assert.doesNotMatch( + clarification.narrative, + /你提到.*具体内容我已经记下了|只记得年份也可以/, + ); assert.equal(clarification.evidenceRecap.at(-1)?.dateLabel, "日期待补充"); const completed = await value.service.answer(userId, { @@ -871,6 +1040,285 @@ test("a concrete event without a date is acknowledged and a date-only follow-up assert.match(completed.narrative, /记下了:2023-03 · 我离开家去北京开始工作/); }); +test("a descriptive date follow-up completes the targeted event instead of becoming a new event", async () => { + const value = harness({ readyAfterEvidenceCount: 99 }); + await start(value, null); + + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "我彻底离开了学校", + }); + + const completed = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "2023年1月我正式办完了手续", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, 2, "the original answer and its completion remain auditable"); + assert.deepEqual(stored[1]?.correctsEvidenceIds, [stored[0]?.id]); + assert.equal(stored[1]?.dateValue, "2023-01"); + assert.equal(stored[1]?.eventSummary, "我彻底离开了学校"); + assert.equal(stored[1]?.rawText, "我彻底离开了学校\n补充:2023年1月我正式办完了手续"); + assert.equal(completed.evidenceRecap.length, 1); + assert.doesNotMatch(completed.narrative, /还差时间定位/); +}); + +test("an independent future event does not get swallowed as the date of an unresolved historical event", async () => { + const value = harness({ readyAfterEvidenceCount: 99 }); + await start(value, null); + + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "后来工作压力很大", + }); + + const completed = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "2027年计划换工作", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, 2); + assert.equal(stored[0]?.dateValue, null); + assert.deepEqual(stored[1]?.correctsEvidenceIds, []); + assert.equal(stored[1]?.dateValue, "2027"); + assert.equal(stored[1]?.scoreable, false); + assert.deepEqual(completed.evidenceRecap.map((item) => item.dateLabel), [ + "日期待补充", + "2027(未来,仅作背景)", + ]); +}); + +test("a next-year month answer resolves from the event being discussed", async () => { + const value = harness({ readyAfterEvidenceCount: 99, continueLatestEvent: true }); + await start(value, null); + + const first = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2022年12月正式退学", + }); + assert.equal(first.evidenceRequest?.followUp?.kind, "event_detail"); + + const completed = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "来年 1 月份我彻底离开的学校", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.deepEqual(stored.map((item) => item.dateValue), ["2022-12", "2023-01"]); + assert.equal(stored[1]?.rawText, "来年 1 月份我彻底离开的学校"); + assert.equal(stored[1]?.scoreable, true); + assert.doesNotMatch(completed.narrative, /还差时间定位|大约是哪一年、哪一月/); +}); + +test("a bare month-day answer refines the targeted month without another confirmation", async () => { + const value = harness({ readyAfterEvidenceCount: 99, continueLatestEvent: true }); + await start(value, null); + + await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2026年7月我们三个人决定一起开公司", + }); + + const current = value.cases.get(startActionId)?.row; + const target = current?.eventEvidence.at(-1); + assert.ok(current); + assert.ok(target); + assert.ok(current.latestTurn.evidenceRequest); + value.cases.set(startActionId, { + row: { + ...current, + latestTurn: { + ...current.latestTurn, + evidenceRequest: { + ...current.latestTurn.evidenceRequest, + followUp: { kind: "event_date", evidenceId: target.id }, + }, + }, + }, + }); + + const completed = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "7 月 10 号", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, 2, "the month event and day refinement remain auditable"); + assert.deepEqual(stored[1]?.correctsEvidenceIds, [target.id]); + assert.equal(stored[1]?.dateValue, "2026-07-10"); + assert.equal(stored[1]?.eventSummary, "我们三个人决定一起开公司"); + assert.equal(stored[1]?.rawText, "2026年7月我们三个人决定一起开公司\n补充:7 月 10 号"); + assert.equal(completed.evidenceRecap.at(-1)?.dateLabel, "2026-07-10"); + assert.doesNotMatch(completed.narrative, /是指.*7 月 10|哪一天|哪一年、哪一月/); +}); + +test("an authored event-detail follow-up survives progress decoration and keeps the prior date", async () => { + const value = harness({ readyAfterEvidenceCount: 99, continueLatestEvent: true }); + await start(value, null); + + const firstAnswer = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2017年5月参加工作", + }); + + const current = value.cases.get(startActionId)?.row; + const target = current?.eventEvidence.at(-1); + assert.ok(current); + assert.ok(target); + assert.match(firstAnswer.narrative, /正式工作,还是实习或兼职/); + assert.deepEqual( + value.cases.get(startActionId)?.row.latestTurn.evidenceRequest?.followUp, + { kind: "event_detail", evidenceId: target.id }, + ); + + const completed = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "正式工作", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, 2, "the original event and merged correction remain auditable"); + assert.deepEqual(stored[1]?.correctsEvidenceIds, [target.id]); + assert.equal(stored[1]?.dateValue, "2017-05"); + assert.equal(stored[1]?.eventSummary, "参加工作;正式工作"); + assert.equal(stored[1]?.scoreable, true); + assert.equal(completed.evidenceRecap.at(-1)?.dateLabel, "2017-05"); + assert.doesNotMatch(completed.narrative, /大致是什么年月|只记得年份/); +}); + +test("a mislabeled new-event follow-up never lets program heuristics rewrite an undated reply as a correction", async () => { + const value = harness({ + readyAfterEvidenceCount: 99, + continueLatestEvent: true, + detailQuestion: "这几个月里,学业压力具体体现在哪些方面,主要原因是什么?", + mislabelLatestDetailAsNewEvent: true, + }); + await start(value, null); + + const firstAnswer = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "1972年12月因为学业压力正式退学", + }); + + const target = value.cases.get(startActionId)?.row.eventEvidence.at(-1); + assert.ok(target); + assert.match(firstAnswer.narrative, /压力具体体现在哪些方面/); + assert.deepEqual( + value.cases.get(startActionId)?.row.latestTurn.evidenceRequest?.followUp, + { kind: "new_event", evidenceId: null }, + ); + + const completed = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: secondAnswerActionId, + turnVersion: 1, + answer: "经济负担导致无法继续", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, 2); + assert.deepEqual(stored[1]?.correctsEvidenceIds, []); + assert.equal(stored[1]?.dateValue, null); + assert.match(stored[1]?.eventSummary ?? "", /经济负担导致无法继续/); + assert.equal(completed.evidenceRecap.length, 2); + assert.equal(completed.evidenceRecap.some((item) => item.id === target.id), true); + assert.doesNotMatch(completed.narrative, /这件事很有用|还差时间定位|大约是哪一年、哪一月/); +}); + +test("an undated independent event is not swallowed by a broad detail question mislabeled as new_event", async () => { + const value = harness({ + readyAfterEvidenceCount: 99, + continueLatestEvent: true, + detailQuestion: "这几个月里,学业压力具体体现在哪些方面,主要原因是什么?", + mislabelLatestDetailAsNewEvent: true, + }); + await start(value, null); + await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: answerActionId, + turnVersion: 0, answer: "2017年5月硕士毕业", + }); + const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id; + assert.ok(firstId); + + const next = await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: secondAnswerActionId, + turnVersion: 1, answer: "后来我搬去上海开始工作", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, 2); + assert.deepEqual(stored[1]?.correctsEvidenceIds, []); + assert.equal(stored[1]?.dateValue, null); + assert.match(stored[1]?.eventSummary ?? "", /搬去上海开始工作/); + assert.equal(next.evidenceRecap.some((item) => item.id === firstId), true); + assert.equal(next.evidenceRecap.at(-1)?.dateLabel, "日期待补充"); +}); + +test("a dated independent event is not swallowed by a broad detail question mislabeled as new_event", async () => { + const value = harness({ + readyAfterEvidenceCount: 99, + continueLatestEvent: true, + detailQuestion: "这几个月里,学业压力具体体现在哪些方面,主要原因是什么?", + mislabelLatestDetailAsNewEvent: true, + }); + await start(value, null); + await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: answerActionId, + turnVersion: 0, answer: "2017年5月硕士毕业", + }); + const firstId = value.cases.get(startActionId)?.row.eventEvidence[0]?.id; + assert.ok(firstId); + + const next = await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: secondAnswerActionId, + turnVersion: 1, answer: "2017年7月入职第一家公司", + }); + + const stored = value.cases.get(startActionId)?.row.eventEvidence ?? []; + assert.equal(stored.length, 2); + assert.deepEqual(stored[1]?.correctsEvidenceIds, []); + assert.equal(stored[1]?.dateValue, "2017-07"); + assert.match(stored[1]?.eventSummary ?? "", /入职第一家公司/); + assert.equal(next.evidenceRecap.length, 2); + assert.equal(next.evidenceRecap.some((item) => item.id === firstId), true); +}); + test("the next evidence request moves past a domain the user already answered", async () => { const value = harness({ readyAfterEvidenceCount: 99 }); await start(value, null); @@ -888,25 +1336,22 @@ test("the next evidence request moves past a domain the user already answered", assert.doesNotMatch(turn.narrative, /下一步[^\n]*重要关系/); }); -test("a rejected professional narrative falls back safely while the first scoreable answer still narrows", async () => { +test("a rejected intermediate narrative returns a retryable error without saving a template turn", async () => { const value = harness({ invalidNarrativeFromGeneration: 2 }); await start(value, null); - const turn = await value.service.answer(userId, { - type: "answer", - caseId: startActionId, - actionId: answerActionId, - turnVersion: 0, - answer: "2021年7月开始第一份长期工作", - }); + await assert.rejects(value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: answerActionId, + turnVersion: 0, answer: "2021年7月开始第一份长期工作", + }), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "service_unavailable"); const stored = value.cases.get(startActionId)?.row; - assert.equal(turn.status, "active"); - assert.equal(turn.candidate.status, "pending_validation"); + assert.equal(stored?.turnVersion, 0); assert.equal(stored?.privateCandidate.resultId, null); - assert.equal(stored?.validationReceipts.at(-1)?.fallbackUsed, true); - assert.match(turn.narrative, /已记录:|本轮区分重点/); - assert.doesNotMatch(turn.narrative, /候选没有推进|请稍后重试/); + assert.equal(stored?.eventEvidence.length, 0); + assert.equal(stored?.validationReceipts.length, 1); + assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 0); }); test("one and two supported events save and narrate before the third accumulated event ranks", async () => { @@ -962,7 +1407,7 @@ test("intermediate narrative receives the complete active event ledger", async ( assert.match(prompt, /eventLedger/); }); -test("a non-confirmable conversational case asks each discriminating domain before completing its saved range", async () => { +test("a non-confirmable conversational case remains open after the current discriminating domains are covered", async () => { const value = harness({ readyAfterEvidenceCount: 99 }); await start(value, "请继续回答原来的事业问题"); const answers = [ @@ -987,12 +1432,11 @@ test("a non-confirmable conversational case asks each discriminating domain befo } } - assert.equal(latest?.status, "completed"); + assert.equal(latest?.status, "active"); assert.equal(latest?.candidate.status, "pending_validation"); - assert.deepEqual(latest?.actions, ["continue_original_question"]); - assert.equal(latest?.evidenceRequest, null); - assert.match(latest?.narrative ?? "", /候选范围.*不会替换当前排盘时间/); - assert.equal(value.cases.get(startActionId)?.row.status, "completed"); + assert.deepEqual(latest?.actions, ["answer", "pause", "abandon"]); + assert.ok(latest?.evidenceRequest); + assert.equal(value.cases.get(startActionId)?.row.status, "active"); assert.equal(value.cases.get(startActionId)?.row.privateCandidate.representativeTime, "05:20"); }); @@ -1049,7 +1493,7 @@ test("two valid events plus pre-birth evidence wait until a later valid third ev assert.equal(preBirth?.extractionStatus, "needs_clarification"); assert.equal(stored?.eventEvidence.length, 4); assert.equal(latest?.evidenceRecap.length, 4); - assert.deepEqual(value.packetEvidenceCounts, [0, 1, 2, 3]); + assert.deepEqual(value.packetEvidenceCounts, [0, 1, 2, 2, 3]); }); test("vague, future, and unmatched answers stay conversational and never score", async () => { @@ -1068,21 +1512,42 @@ test("vague, future, and unmatched answers stay conversational and never score", answer, ...(domain ? { domain } : {}), }); - assert.equal(value.counts().packetBuilds, 1); + assert.equal(value.counts().packetBuilds, 2); assert.equal(turn.status, "active"); - assert.match(turn.narrative, /年月|已发生|已经发生|换个方向|未来/); + assert.match(turn.narrative, /哪一年|哪一月|年月|已发生|已经发生|换个方向|未来/); + assert.doesNotMatch(turn.narrative, /好的,我们不沿用不符合你的方向|已保存这段描述|我已保存你的原话|这条更正已保存/); assert.doesNotMatch(turn.narrative, /A[.、:]|B[.、:]|2006.?2011/); assert.equal(value.cases.get(startActionId)?.row.eventEvidence.at(-1)?.scoreable, false); } }); +test("a non-scoring packet failure responds to the current turn instead of replaying the prior agent message", async () => { + const value = harness({ + packetFailure: new Error("synthetic packet outage"), + packetFailureFromBuild: 2, + }); + const initial = await start(value, null); + + const turn = await value.service.answer(userId, { + type: "answer", caseId: startActionId, actionId: answerActionId, + turnVersion: 0, answer: "化学专业", + }); + + assert.notEqual(turn.narrative, initial.narrative); + assert.match(turn.narrative, /化学专业|这轮|这次分析/); + assert.match(turn.narrative, /暂时没有完成/); + assert.equal(value.cases.get(startActionId)?.row.validationReceipts.at(-1)?.fallbackUsed, true); + assert.equal(value.cases.get(startActionId)?.row.eventEvidence.at(-1)?.rawText, "化学专业"); +}); + test("resume returns the latest owned turn on a stale new-device version without mutation or charge", async () => { const value = harness(); - await start(value, null); + const initial = await start(value, null); const paused = await value.service.pause(userId, { type: "pause", caseId: startActionId, actionId: pauseActionId, turnVersion: 0, }); assert.equal(paused.status, "paused"); + assert.equal(paused.narrative, initial.narrative); const latest = await value.service.answer(userId, { type: "answer", caseId: startActionId, actionId: answerActionId, turnVersion: 1, answer: "2021年7月毕业,并在2022年3月去外地工作", @@ -1119,6 +1584,78 @@ test("a lost-response retry replays the saved answer without rescoring or regene assert.deepEqual(value.events, before); }); +test("regenerate rewrites only the current narrative and preserves evidence, scoring, candidate, and billing", async () => { + const value = harness({ readyAfterEvidenceCount: 99, varyNarrative: true }); + await start(value, null); + const answered = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2012年12月正式退学,2013年1月彻底离开学校", + }); + const storedBefore = value.cases.get(startActionId)?.row; + assert.ok(storedBefore); + const evidenceBefore = structuredClone(storedBefore.eventEvidence); + const candidateBefore = structuredClone(storedBefore.privateCandidate); + const countsBefore = value.counts(); + + const regenerated = await value.service.regenerate(userId, { + type: "regenerate", + caseId: startActionId, + actionId: laterActionId, + turnVersion: answered.turnVersion, + }); + + const storedAfter = value.cases.get(startActionId)?.row; + assert.ok(storedAfter); + assert.equal(regenerated.turnVersion, answered.turnVersion + 1); + assert.notEqual(regenerated.narrative, answered.narrative); + assert.deepEqual(storedAfter.eventEvidence, evidenceBefore); + assert.deepEqual(storedAfter.privateCandidate, candidateBefore); + assert.equal(value.counts().reserveCount, countsBefore.reserveCount); + assert.equal(value.packetEvidenceCounts.at(-1), evidenceBefore.length); + assert.equal(value.mutations.filter((mutation) => mutation === "saveTurn").length, 2); + assert.match(value.narrativePrompts.at(-1) ?? "", /2012年12月正式退学/); +}); + +test("a failed regenerate preserves the prior turn, evidence, candidate, and billing", async () => { + const value = harness({ readyAfterEvidenceCount: 99, invalidNarrativeFromGeneration: 3 }); + await start(value, null); + const answered = await value.service.answer(userId, { + type: "answer", + caseId: startActionId, + actionId: answerActionId, + turnVersion: 0, + answer: "2012年12月正式退学,2013年1月彻底离开学校", + }); + const storedBefore = value.cases.get(startActionId)?.row; + assert.ok(storedBefore); + const snapshotBefore = structuredClone(storedBefore); + const countsBefore = value.counts(); + const saveTurnsBefore = value.mutations.filter((mutation) => mutation === "saveTurn").length; + + await assert.rejects(value.service.regenerate(userId, { + type: "regenerate", + caseId: startActionId, + actionId: laterActionId, + turnVersion: answered.turnVersion, + }), (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "service_unavailable"); + + const storedAfter = value.cases.get(startActionId)?.row; + assert.deepEqual(storedAfter, snapshotBefore); + assert.equal(storedAfter?.turnVersion, answered.turnVersion); + assert.equal(storedAfter?.latestTurn.narrative, answered.narrative); + assert.equal(value.counts().reserveCount, countsBefore.reserveCount); + assert.equal(value.counts().releaseCount, countsBefore.releaseCount); + assert.equal(value.counts().packetBuilds, countsBefore.packetBuilds + 1); + assert.equal( + value.mutations.filter((mutation) => mutation === "saveTurn").length, + saveTurnsBefore, + ); +}); + test("overlapping identical answers converge on the first receipt despite different derived narratives", async () => { const value = harness({ varyNarrative: true }); await start(value, null); @@ -1255,6 +1792,7 @@ test("confirm delegates to the atomic store call, preserves the old baseline unt assert.deepEqual(value.mutations.slice(before), ["confirm"]); assert.equal(confirmed.status, "completed"); assert.equal(confirmed.candidate.status, "confirmed"); + assert.equal(confirmed.narrative, ready.narrative); assert.equal(confirmed.pendingConsultationQuestion, "请继续回答原来的事业问题"); assert.deepEqual(confirmed.actions, ["continue_original_question"]); }); diff --git a/frontend/tests/conversational-rectification-route.test.ts b/frontend/tests/conversational-rectification-route.test.ts index f6cf94d4..c1b20888 100644 --- a/frontend/tests/conversational-rectification-route.test.ts +++ b/frontend/tests/conversational-rectification-route.test.ts @@ -3,10 +3,13 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { buildProductionConversationalRectificationPacket, + conversationMessagesFromStoredTurns, createBirthTimeConversationPostHandler, declaredBirthInputForLegacyCase, loadProductionConversationalRectificationProfile, - streamConversationalRectificationResponse, + rectificationNarrativeTextFromMastraResult, + resolveRectificationNarrativeModels, + resolveMissingProfileTimezoneOffset, type BirthTimeConversationRouteService, } from "../src/app/api/birth-time-conversation/route.ts"; import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts"; @@ -22,45 +25,6 @@ const actionId = "00000000-0000-4000-8000-000000000712"; const caseId = "00000000-0000-4000-8000-000000000713"; const requestId = "00000000-0000-4000-8000-000000000714"; -test("validated rectification responses stream narrative deltas before the durable turn", async () => { - const narrative = "已记录具体经历,并继续追问关系事件。"; - const turn = { - caseId, - journeyProtocol: "conversational-evidence-v3" as const, - status: "active" as const, - turnVersion: 2, - narrative, - candidate: { - status: "pending_validation" as const, - representativeTime: "05:30", - rangeStart: "04:30", - rangeEnd: "06:30", - }, - technicalReceipt: { - calculationVersion: "rectification-technical-v1" as const, - stableLayers: ["D1"], - sensitiveLayers: ["D9"], - candidateDifferenceRefs: ["relationship"], - }, - evidenceRequest: { - domains: ["relationship" as const], - datePrecision: "month_preferred" as const, - freeTextAllowed: true as const, - }, - evidenceRecap: [], - actions: ["answer" as const], - pendingConsultationQuestion: null, - }; - - const response = streamConversationalRectificationResponse(turn, 0); - const lines = (await response.text()).trim().split("\n").map((line) => JSON.parse(line)); - - assert.match(response.headers.get("content-type") ?? "", /application\/x-ndjson/); - assert.equal(response.headers.get("x-accel-buffering"), "no"); - assert.equal(lines.filter((event) => event.type === "delta").map((event) => event.text).join(""), narrative); - assert.deepEqual(lines.at(-1), { type: "turn", turn }); -}); - test("production narrator loads the Jyotish Skill without overriding packet truth", () => { const source = readFileSync(new URL("../src/app/api/birth-time-conversation/route.ts", import.meta.url), "utf8"); @@ -69,7 +33,64 @@ test("production narrator loads the Jyotish Skill without overriding packet trut assert.match(source, /explain the supplied expert workflow/); assert.match(source, /supplied packet facts as the exclusive source/); assert.match(source, /blocked or not_evaluated technique must never be described as used/); + assert.match(source, /Never expose internal event classifications, domain labels, scores, weights, or routing metadata/); + assert.match(source, /on collecting turns, keep the reply conversational and ask exactly one next question/); assert.match(source, /Never invent, recalculate, or confirm candidate data/); + assert.match(source, /schema:\s*rectificationNarrativeAuthoredOutputSchema/); + assert.match(source, /RECTIFICATION_NARRATIVE_RETRY_MODEL_ID/); + assert.match(source, /deepseek-v4-flash/); + assert.match(source, /options\?\.attempt === 2 \? retryModel : preferredModel/); +}); + +test("rectification narrator prefers the model selected in the session UI", () => { + const models = new Map([ + ["gpt-5-5", { id: "gpt-5-5" }], + ["deepseek-v4-pro", { id: "deepseek-v4-pro" }], + ["deepseek-v4-flash", { id: "deepseek-v4-flash" }], + ]); + const selected = resolveRectificationNarrativeModels({ + requestedModelId: "gpt-5-5", + configuredPreferredModelId: "deepseek-v4-pro", + configuredRetryModelId: "deepseek-v4-flash", + resolveModel: (modelId) => models.get(modelId) ?? null, + defaultModel: () => models.get("deepseek-v4-pro") ?? null, + }); + + assert.equal(selected?.preferredModel.id, "gpt-5-5"); + assert.equal(selected?.retryModel.id, "deepseek-v4-flash"); +}); + +test("rectification narrator rejects a stale or unavailable selected model", () => { + assert.throws( + () => resolveRectificationNarrativeModels({ + requestedModelId: "removed-model", + configuredPreferredModelId: "deepseek-v4-pro", + configuredRetryModelId: "deepseek-v4-flash", + resolveModel: () => null, + defaultModel: () => ({ id: "default-model" }), + }), + (error: unknown) => error instanceof ConversationalRectificationError + && error.code === "model_unavailable", + ); +}); + +test("production narrator accepts provider JSON text when Mastra leaves result.object empty", () => { + const text = '{"narrative":"自然首轮回答"}'; + assert.equal(rectificationNarrativeTextFromMastraResult({ object: undefined, text }), text); +}); + +test("production narrator preserves timeout classification when Mastra resolves empty after abort", () => { + const controller = new AbortController(); + const timeout = new DOMException("The operation was aborted due to timeout", "TimeoutError"); + controller.abort(timeout); + + assert.throws( + () => rectificationNarrativeTextFromMastraResult( + { object: undefined, text: undefined }, + controller.signal, + ), + (error) => error === timeout, + ); }); const turn = { @@ -100,6 +121,26 @@ const turn = { pendingConsultationQuestion: null, }; +test("stored turn history is restored as real alternating Agent and user messages", () => { + const messages = conversationMessagesFromStoredTurns([ + { id: "turn-0", turn_version: 0, narrative: "请先告诉我一件时间明确的重要经历。" }, + { id: "turn-1", turn_version: 1, narrative: "大学毕业已经记下。下一步核对职业事件。" }, + { id: "turn-2", turn_version: 2, narrative: "职业起点已经记下。下一步核对关系事件。" }, + ], [ + { source_turn_id: "turn-1", raw_text: "2014 年 6 月大学毕业。" }, + { source_turn_id: "turn-1", raw_text: "2014 年 6 月大学毕业。" }, + { source_turn_id: "turn-2", raw_text: "2017 年 7 月入职第一家公司。" }, + ]); + + assert.deepEqual(messages, [ + { role: "assistant", text: "请先告诉我一件时间明确的重要经历。" }, + { role: "user", text: "2014 年 6 月大学毕业。" }, + { role: "assistant", text: "大学毕业已经记下。下一步核对职业事件。" }, + { role: "user", text: "2017 年 7 月入职第一家公司。" }, + { role: "assistant", text: "职业起点已经记下。下一步核对关系事件。" }, + ]); +}); + function request(body: unknown, events: string[]) { return { headers: new Headers({ "x-request-id": requestId }), @@ -117,6 +158,7 @@ function service(overrides: Partial = {}): Bi start: response, resume: response, answer: response, + regenerate: response, pause: response, abandon: response, confirm: response, @@ -191,7 +233,7 @@ function packetEngine(options: { }, async score() { throw new Error("unexpected questionnaire score"); }, async scoreEvents(input) { - assert.ok(input.events.length >= 3 && input.events.length <= 8); + assert.ok(input.events.length >= 3); for (const event of input.events) { const birthBoundary = event.precision === "year" ? input.birthDate.slice(0, 4) @@ -351,13 +393,15 @@ test("strict invalid commands return 400 before admin, billing, or service const test("valid commands dispatch exactly one authenticated service method", async () => { const events: string[] = []; const calls: unknown[] = []; + const serviceCommands: unknown[] = []; const handler = createBirthTimeConversationPostHandler({ async authenticate() { events.push("auth"); return { userId, context: { authenticated: true } }; }, - async createService() { + async createService(_authenticated, command) { events.push("service"); + serviceCommands.push(command); return service({ async answer(receivedUserId, command) { calls.push([receivedUserId, command]); @@ -367,14 +411,79 @@ test("valid commands dispatch exactly one authenticated service method", async ( }, createRequestId: () => requestId, }); - const command = { type: "answer", caseId, actionId, turnVersion: 1, answer: "2021年7月毕业" }; + const command = { + type: "answer", + caseId, + actionId, + turnVersion: 1, + modelId: "gpt-5-5", + answer: "2021年7月毕业", + }; const response = await handler(request(command, events)); assert.equal(response.status, 200); assert.deepEqual(events, ["auth", "body", "service"]); + assert.deepEqual(serviceCommands, [command]); assert.deepEqual(calls, [[userId, command]]); assert.deepEqual(await response.json(), turn); }); +test("regenerate dispatches without replaying an answer payload", async () => { + const events: string[] = []; + const calls: unknown[] = []; + const handler = createBirthTimeConversationPostHandler({ + async authenticate() { + return { userId, context: null }; + }, + async createService() { + return service({ + async regenerate(receivedUserId, command) { + calls.push([receivedUserId, command]); + return { ...turn, turnVersion: 2, narrative: "重新生成后的完整回答。" }; + }, + }); + }, + createRequestId: () => requestId, + }); + const command = { type: "regenerate", caseId, actionId, turnVersion: 1 }; + + const response = await handler(request(command, events)); + + assert.equal(response.status, 200); + assert.deepEqual(calls, [[userId, command]]); + assert.equal((await response.json() as { narrative: string }).narrative, "重新生成后的完整回答。"); +}); + +test("resume includes the durable alternating transcript when the service can restore it", async () => { + const conversationMessages = [ + { role: "assistant" as const, text: "请先告诉我一件时间明确的重要经历。" }, + { role: "user" as const, text: "2014 年 6 月大学毕业。" }, + { role: "assistant" as const, text: turn.narrative }, + ]; + const handler = createBirthTimeConversationPostHandler({ + async authenticate() { return { userId, context: null }; }, + async createService() { + return service({ + async loadConversationMessages(receivedUserId, receivedCaseId) { + assert.equal(receivedUserId, userId); + assert.equal(receivedCaseId, caseId); + return conversationMessages; + }, + }); + }, + createRequestId: () => requestId, + }); + + const response = await handler(request({ + type: "resume", + caseId, + actionId, + turnVersion: 2, + }, [])); + + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { ...turn, conversationMessages }); +}); + test("known conflicts and unavailable failures use stable safe Chinese responses", async () => { for (const [failure, status, code] of [ [new ConversationalRectificationError("stale_turn"), 409, "stale_turn"], @@ -522,6 +631,63 @@ test("legacy import declaration uses the immutable old case time while preservin }); }); +test("period-only global profile resolves its historical offset before rectification", async () => { + const profile = { + birth_date: "1955-02-24", + reported_birth_time: null, + active_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "evening", + birth_time_clue: "大约晚上七点左右,可能前后差四十五分钟。", + uncertainty_before_minutes: null, + uncertainty_after_minutes: null, + birth_place_label: "旧金山, 加利福尼亚州, 美国", + birth_place_type: "city", + birth_place_provider: "geoapify", + birth_place_provider_id: "san-francisco", + country_code: "US", + province_code: null, + city_code: null, + district_code: null, + latitude: 37.7879363, + longitude: -122.4075201, + timezone_id: "America/Los_Angeles", + timezone_source: "iana_historical", + timezone_offset: null, + rectification_case_id: null, + }; + let timezoneRequest: Record | null = null; + const resolve = (value: unknown) => resolveMissingProfileTimezoneOffset( + value, + async (_input, init) => { + timezoneRequest = JSON.parse(String(init?.body)) as Record; + return Response.json({ + available: true, + timezoneId: "America/Los_Angeles", + timezoneOffset: -8, + }); + }, + "http://api:5200", + ); + const loaded = await loadProductionConversationalRectificationProfile({ + loadProfile: async () => profile, + loadRectificationCase: async () => null, + resolveTimezoneOffset: resolve, + }, userId); + + assert.deepEqual(timezoneRequest, { + latitude: 37.7879363, + longitude: -122.4075201, + birthDate: "1955-02-24", + birthTime: "20:30", + }); + assert.equal(loaded.declaredBirthInput.birthplace.timezoneOffset, -8); + assert.equal(loaded.declaredBirthInput.birthplace.timezoneId, "America/Los_Angeles"); + assert.equal(loaded.declaredBirthInput.birthplace.latitude, 37.787936); + assert.equal(loaded.declaredBirthInput.birthplace.longitude, -122.40752); + assert.equal(loaded.declaredBirthInput.source, "period_only"); +}); + test("an abandoned imported v3 profile pointer becomes the paid revision base", async () => { const importedCaseId = "00000000-0000-4000-8000-000000000120"; const importedFromCaseId = "00000000-0000-4000-8000-000000000121"; @@ -943,12 +1109,12 @@ test("production rescans the declared range after correction while ordinary evid assert.equal(ordinary.packet.sensitivityScope.sampleTimes.includes("04:50"), false); }); -test("production packet sends health evidence and uses the shared eight-event convergence limit", async () => { +test("production packet sends every accumulated event without an eight-event cap", async () => { const scoreCalls: LifeEvent[][] = []; const differenceCalls: DifferencePacketInput[] = []; const engine = packetEngine({ scoreCalls, differenceCalls }); const domains = ["education", "relocation", "career", "relationship", "health_pressure"] as const; - const evidence = Array.from({ length: 8 }, (_, index) => + const evidence = Array.from({ length: 12 }, (_, index) => syntheticEvidence(index + 1, domains[index % domains.length] ?? "career")); await buildProductionConversationalRectificationPacket(engine, { diff --git a/frontend/tests/conversational-rectification-store.test.ts b/frontend/tests/conversational-rectification-store.test.ts index 0e8abbae..8e144f29 100644 --- a/frontend/tests/conversational-rectification-store.test.ts +++ b/frontend/tests/conversational-rectification-store.test.ts @@ -192,6 +192,76 @@ test("creates an account-level case and first public turn through one RPC", asyn assert.deepEqual(result.validationReceipts, storedRow.validation_receipts); }); +test("omits the optional new-event follow-up marker for legacy first-turn SQL validators", async () => { + let firstTurnPayload: unknown; + const store = new ConversationalRectificationStore(rpcClient((name, args) => { + assert.equal(name, "create_conversational_rectification_case"); + firstTurnPayload = args.p_first_turn; + return storedRow; + })); + + await store.createCaseWithFirstTurn({ + userId, + caseId, + actionId: caseId, + expectedVersion: 0, + revisionOfCaseId: null, + pendingConsultationQuestion: null, + declaredBirthInput: storedRow.declared_birth_input as never, + firstTurn: { + ...firstTurn, + evidenceRequest: { + ...firstTurn.evidenceRequest, + followUp: { kind: "new_event", evidenceId: null }, + }, + }, + validationReceipt, + privateCandidate: storedRow.private_candidate, + }); + + assert.deepEqual( + (firstTurnPayload as { evidenceRequest?: { followUp?: unknown } }).evidenceRequest, + { + domains: firstTurn.evidenceRequest.domains, + datePrecision: firstTurn.evidenceRequest.datePrecision, + freeTextAllowed: true, + }, + ); +}); + +test("omits the optional new-event follow-up marker when saving a turn", async () => { + let turnPayload: unknown; + const store = new ConversationalRectificationStore(rpcClient((name, args) => { + assert.equal(name, "save_conversational_rectification_turn"); + turnPayload = args.p_turn; + return storedRow; + })); + + await store.saveTurn({ + userId, + caseId, + actionId: "00000000-0000-4000-8000-000000000099", + expectedVersion: 0, + commandFingerprint: "a".repeat(64), + turn: { + ...firstTurn, + turnVersion: 1, + evidenceRequest: { + ...firstTurn.evidenceRequest, + followUp: { kind: "new_event", evidenceId: null }, + }, + }, + evidence: [], + validationReceipt, + privateCandidate: storedRow.private_candidate, + }); + + assert.equal( + (turnPayload as { evidenceRequest?: { followUp?: unknown } }).evidenceRequest?.followUp, + undefined, + ); +}); + test("loads the latest unfinished case by account without a chat identifier", async () => { const calls: unknown[] = []; const store = new ConversationalRectificationStore(rpcClient((name, args) => { @@ -503,6 +573,33 @@ test("declared birth input is strict, source-aware, bounded, and location-comple timezoneOffset: 8, }, }; + assert.equal(declaredBirthInputSchema.safeParse({ + ...commonDeclaration, + source: "unknown", + birthplace: { + city: "台湾 · 台北市", + placeId: "1668341", + placeType: "locality", + provider: "geonames", + countryCode: "TW", + latitude: 25.03, + longitude: 121.56, + timezoneId: "Asia/Taipei", + timezoneSource: "iana_historical", + timezoneOffset: 8, + }, + }).success, true); + assert.equal(declaredBirthInputSchema.safeParse({ + ...commonDeclaration, + source: "unknown", + birthplace: { + placeId: "provider-place-id", + latitude: 40.7128, + longitude: -74.006, + timezoneId: "America/New_York", + timezoneOffset: -5, + }, + }).success, true); for (const value of [ { ...commonDeclaration, source: "hospital_record", reportedTime: "05:20", uncertaintyBeforeMinutes: 2, uncertaintyAfterMinutes: 2 }, { ...commonDeclaration, source: "family_exact", reportedTime: "05:20", uncertaintyBeforeMinutes: 10, uncertaintyAfterMinutes: 10 }, diff --git a/frontend/tests/conversational-visible-narrative.test.ts b/frontend/tests/conversational-visible-narrative.test.ts index 5b84c606..2f78a81a 100644 --- a/frontend/tests/conversational-visible-narrative.test.ts +++ b/frontend/tests/conversational-visible-narrative.test.ts @@ -68,7 +68,7 @@ test("visible rectification copy does not replace a tailored Agent answer with a assert.match(narrative, /事业转折/); }); -test("the rectification chat renders alternating messages without a separate evidence panel", () => { +test("the rectification chat renders the controller's alternating message history", () => { const source = readFileSync( new URL("../src/components/conversational-birth-time-rectification.tsx", import.meta.url), "utf8", @@ -76,5 +76,7 @@ test("the rectification chat renders alternating messages without a separate evi assert.match(source, /controller\.messages/); assert.match(source, /controller\.messages[\s\S]*?\.map\(\(message\)/); - assert.doesNotMatch(source, /rectification-progress-details|turn\.evidenceRecap\.map|更正这条经历/); + const messageHistoryIndex = source.indexOf("controller.messages"); + assert.doesNotMatch(source, /rectification-progress-details|turn\.evidenceRecap\.map/); + assert.ok(messageHistoryIndex >= 0); }); diff --git a/frontend/tests/global-birth-location.test.ts b/frontend/tests/global-birth-location.test.ts new file mode 100644 index 00000000..fe0325f4 --- /dev/null +++ b/frontend/tests/global-birth-location.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { birthLocationSearchQuerySchema } from "../src/lib/location-contract.ts"; +import { searchGlobalBirthLocations } from "../src/lib/geoapify-location-service.ts"; + +const migration = readFileSync( + new URL("../supabase/migrations/20260724010000_global_birth_locations.sql", import.meta.url), + "utf8", +); +const rectificationContractMigration = readFileSync( + new URL( + "../supabase/migrations/20260724020000_align_global_birthplace_rectification_contract.sql", + import.meta.url, + ), + "utf8", +); + +test("global birthplace columns keep the account service-role read and upsert path usable", () => { + assert.match(migration, /grant select, insert, update\s*\([\s\S]*?birth_place_label[\s\S]*?timezone_source[\s\S]*?\) on table public\.profiles to service_role;/i); +}); + +test("global birthplace metadata is accepted by the durable rectification declaration", () => { + assert.match( + rectificationContractMigration, + /create or replace function public\.conversational_rectification_valid_declared_birth_input/i, + ); + for (const key of ["placeId", "placeType", "provider", "timezoneId", "timezoneSource"]) { + assert.match(rectificationContractMigration, new RegExp(`'${key}'`)); + } + assert.match( + rectificationContractMigration, + /\(v_place \? 'city'\) or \(v_place \? 'cityCode'\) or \(v_place \? 'placeId'\)/, + ); +}); + +test("location search is explicitly unavailable without a server Geoapify key", async () => { + const result = await searchGlobalBirthLocations({ q: "London", locale: "zh", limit: 5 }, { + apiKey: "", + fetchImpl: async () => { throw new Error("must not fetch"); }, + }); + assert.deepEqual(result, { status: "unavailable", reason: "geoapify_not_configured" }); +}); + +test("Geoapify results are normalized and enriched with historical IANA timezone data", async () => { + const urls: string[] = []; + const fetchImpl = async (input: string | URL | Request) => { + const url = String(input); + urls.push(url); + if (url.startsWith("https://api.geoapify.com/")) return new Response(JSON.stringify({ + features: [{ + geometry: { coordinates: [-74.006, 40.7128] }, + properties: { + place_id: "51-test-new-york", + result_type: "city", + formatted: "New York, New York, United States", + city: "New York", + state: "New York", + state_code: "NY", + country: "United States", + country_code: "us", + }, + }], + }), { status: 200 }); + return new Response(JSON.stringify({ + available: true, + timezoneId: "America/New_York", + timezoneOffset: -4, + timezoneSource: "iana_historical", + localTimeStatus: "resolved", + }), { status: 200 }); + }; + + const result = await searchGlobalBirthLocations({ + q: "New York", + birthDate: "1990-07-01", + birthTime: "12:00", + locale: "en-US", + limit: 3, + }, { apiKey: "secret-key", apiBase: "http://api:5200", fetchImpl: fetchImpl as typeof fetch }); + + assert.equal(result.status, "ok"); + if (result.status !== "ok") return; + assert.deepEqual(result.locations[0], { + provider: "geoapify", + providerPlaceId: "51-test-new-york", + placeType: "city", + label: "New York, New York, United States", + countryCode: "US", + countryName: "United States", + regionCode: "NY", + regionName: "New York", + localityName: "New York", + districtName: null, + latitude: 40.7128, + longitude: -74.006, + timezoneId: "America/New_York", + timezoneOffset: -4, + timezoneSource: "iana_historical", + localTimeStatus: "resolved", + }); + assert.equal(JSON.stringify(result).includes("secret-key"), false); + assert.match(urls[0], /format=geojson/); + assert.match(urls[0], /lang=en(?:&|$)/); + assert.equal(urls[1], "http://api:5200/api/location/timezone"); +}); + +test("Chinese administrative places use the existing local dataset without spending Geoapify quota", async () => { + const fetchImpl = async (input: string | URL | Request) => { + assert.equal(String(input), "http://api:5200/api/location/timezone"); + return new Response(JSON.stringify({ + available: true, + timezoneId: "Asia/Shanghai", + timezoneOffset: 8, + timezoneSource: "iana_historical", + localTimeStatus: "resolved", + }), { status: 200 }); + }; + const result = await searchGlobalBirthLocations({ + q: "峰峰矿区", + birthDate: "1997-08-08", + birthTime: "06:30", + locale: "zh-CN", + limit: 5, + }, { apiKey: "", apiBase: "http://api:5200", fetchImpl: fetchImpl as typeof fetch }); + + assert.equal(result.status, "ok"); + if (result.status !== "ok") return; + assert.deepEqual(result.locations[0], { + provider: "china_locations", + providerPlaceId: "130406", + placeType: "district", + label: "中国 · 河北省 · 邯郸市 · 峰峰矿区", + countryCode: "CN", + countryName: "中国", + regionCode: "130000", + regionName: "河北省", + localityName: "邯郸市", + districtName: "峰峰矿区", + latitude: 36.420487, + longitude: 114.209936, + timezoneId: "Asia/Shanghai", + timezoneOffset: 8, + timezoneSource: "iana_historical", + localTimeStatus: "resolved", + }); +}); + +test("birth time cannot be submitted without a birth date", () => { + assert.equal(birthLocationSearchQuerySchema.safeParse({ q: "Paris", birthTime: "05:30" }).success, false); + assert.equal(birthLocationSearchQuerySchema.safeParse({ q: "Paris", birthDate: "2021-02-29" }).success, false); +}); diff --git a/frontend/tests/global-birth-profile-routes.test.ts b/frontend/tests/global-birth-profile-routes.test.ts new file mode 100644 index 00000000..553393be --- /dev/null +++ b/frontend/tests/global-birth-profile-routes.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { profilePayload } from "../src/app/api/daily-starlanguage/route.ts"; +import { payloadFromProfile } from "../src/app/api/birth-rectification/route.ts"; +import { birthPayload } from "../src/app/api/synastry/route.ts"; +import { resolveMissingBirthTimezoneOffset } from "../src/lib/birth-profile-timezone.ts"; +import { parseBirthTimeProfile } from "../src/lib/birth-time-journey-adapters.ts"; + +const sanFrancisco = Object.freeze({ + date: "1955-02-24", + time: "19:00", + countryCode: "US", + provinceCode: "", + cityCode: "", + districtCode: "", + latitude: 37.7879363, + longitude: -122.4075201, + timezoneId: "America/Los_Angeles", + timezoneOffset: -8, +}); + +test("daily, synastry, and legacy rectification use global coordinates instead of requiring China codes", async () => { + const daily = await profilePayload(sanFrancisco, "2026-07-24"); + assert.deepEqual(daily && { lat: daily.lat, lon: daily.lon, tz: daily.tz }, { + lat: 37.7879363, + lon: -122.4075201, + tz: -8, + }); + + const synastry = await birthPayload(sanFrancisco); + assert.deepEqual({ lat: synastry.lat, lon: synastry.lon, tz: synastry.tz }, { + lat: 37.7879363, + lon: -122.4075201, + tz: -8, + }); + + const legacy = await payloadFromProfile(sanFrancisco); + assert.deepEqual(legacy && { lat: legacy.lat, lon: legacy.lon, tz: legacy.tz }, { + lat: 37.7879363, + lon: -122.4075201, + tz: -8, + }); +}); + +test("shared timezone resolver supports camelCase browser profiles and preserves the historical reference time", async () => { + let requestBody: Record | null = null; + const resolved = await resolveMissingBirthTimezoneOffset({ + ...sanFrancisco, + timezoneOffset: null, + }, { + apiBase: "http://jyotish:5200", + preferredTime: "19:00", + fetchImpl: async (input, init) => { + assert.equal(String(input), "http://jyotish:5200/api/location/timezone"); + requestBody = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ + available: true, + timezoneId: "America/Los_Angeles", + timezoneOffset: -8, + }), { status: 200 }); + }, + }) as Record; + + assert.deepEqual(requestBody, { + latitude: 37.7879363, + longitude: -122.4075201, + birthDate: "1955-02-24", + birthTime: "19:00", + }); + assert.equal(resolved.timezone_offset, -8); + assert.equal(resolved.timezoneOffset, -8); +}); + +test("journey entry and stored-case loader resolve nullable offsets before strict chart parsing", async () => { + const resolved = await resolveMissingBirthTimezoneOffset({ + birth_date: "1955-02-24", + reported_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "evening", + birth_time_clue: "大约晚上七点", + uncertainty_before_minutes: null, + uncertainty_after_minutes: null, + latitude: 37.7879363, + longitude: -122.4075201, + timezone_id: "America/Los_Angeles", + timezone_offset: null, + }, { + fetchImpl: async () => new Response(JSON.stringify({ + available: true, + timezoneId: "America/Los_Angeles", + timezoneOffset: -8, + }), { status: 200 }), + }); + const assessment = parseBirthTimeProfile(resolved); + assert.deepEqual(assessment.location, { lat: 37.7879363, lon: -122.4075201, tz: -8 }); + + const routeSource = readFileSync(new URL("../src/app/api/birth-time-journey/route.ts", import.meta.url), "utf8"); + const loaderSource = readFileSync(new URL("../src/lib/birth-time-journey-case-loader.ts", import.meta.url), "utf8"); + assert.match(routeSource, /timezone_id,timezone_offset/); + assert.match(routeSource, /resolveMissingBirthTimezoneOffset\(profile\)/); + assert.match(loaderSource, /birth_date,reported_birth_time,birth_time_period,latitude,longitude,timezone_id,timezone_offset/); + assert.match(loaderSource, /resolveMissingBirthTimezoneOffset\(profile\)/); +}); diff --git a/frontend/tests/location-search-combobox.test.ts b/frontend/tests/location-search-combobox.test.ts new file mode 100644 index 00000000..4d3eaeda --- /dev/null +++ b/frontend/tests/location-search-combobox.test.ts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { + LocationSearchCombobox, + parseLocationSearchResults, + type ResolvedBirthLocation, +} from "../src/components/location-search-combobox.tsx"; + +Object.assign(globalThis, { React }); + +const taipei: ResolvedBirthLocation = { + id: "nominatim:1293250", + label: "台北市, 台湾", + placeType: "city", + provider: "nominatim", + providerPlaceId: "1293250", + countryCode: "TW", + countryName: "台湾", + admin1: "台北市", + admin2: "", + locality: "台北市", + latitude: 25.0375, + longitude: 121.5637, + timezoneId: "Asia/Taipei", + timezoneOffset: 8, + timezoneSource: "historical_tzdb", +}; + +test("location search parser keeps only complete coordinate and timezone results", () => { + assert.deepEqual(parseLocationSearchResults({ + locations: [ + { ...taipei, id: undefined, regionName: taipei.admin1, localityName: taipei.locality }, + { ...taipei, id: "", providerPlaceId: "", label: "invalid" }, + { ...taipei, id: "mapbox:unknown-time", timezoneOffset: null }, + ], + }), [taipei, { ...taipei, id: "mapbox:unknown-time", timezoneOffset: null }]); + assert.deepEqual(parseLocationSearchResults({ results: "not-an-array" }), []); +}); + +test("location combobox exposes accessible search semantics and selected state", () => { + const emptyMarkup = renderToStaticMarkup(React.createElement(LocationSearchCombobox, { + value: null, + birthDate: "1997-08-08", + birthTime: "06:30", + onChange: () => undefined, + })); + assert.match(emptyMarkup, /role="combobox"/); + assert.match(emptyMarkup, /aria-autocomplete="list"/); + assert.match(emptyMarkup, /aria-expanded="false"/); + assert.match(emptyMarkup, /aria-controls="[^"]+-listbox"/); + assert.match(emptyMarkup, /输入至少两个字开始搜索/); + + const selectedMarkup = renderToStaticMarkup(React.createElement(LocationSearchCombobox, { + value: taipei, + onChange: () => undefined, + })); + assert.match(selectedMarkup, /value="台北市, 台湾"/); + assert.match(selectedMarkup, /已选择 台北市, 台湾/); + assert.match(selectedMarkup, /aria-label="清除出生地点"/); +}); + +test("profile integrates global location persistence while retaining legacy China fallback", () => { + const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + assert.match(pageSource, /birthPlaceProviderId:\s*string/); + assert.match(pageSource, /birth_place_provider_id:\s*nextProfile\.birthPlaceProviderId/); + assert.match(pageSource, /timezone_id:\s*nextProfile\.timezoneId/); + assert.match(pageSource, /function selectedLocationValue/); + assert.match(pageSource, /legacy-cn:/); + assert.match(pageSource, /provinceCode:\s*""[\s\S]*cityCode:\s*""[\s\S]*districtCode:\s*""/); + assert.doesNotMatch(pageSource, /目前先支持中国大陆地区/); +}); + +test("selecting a location cancels stale searches and keeps the result list outside the onboarding card", () => { + const componentSource = readFileSync(new URL("../src/components/location-search-combobox.tsx", import.meta.url), "utf8"); + const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + assert.match(componentSource, /function choose\(location: ResolvedBirthLocation\) \{\s*requestSequence\.current \+= 1;/); + assert.match(globalStyles, /\.birth-time-transition-card \{ position: relative; overflow: visible; \}/); +}); + +test("profile accepts a selected IANA location before a numeric offset is resolved", () => { + const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + assert.match(pageSource, /profile\.timezoneId\.trim\(\)/); + assert.match(pageSource, /profile\.timezoneOffset === null \|\| Number\.isFinite\(profile\.timezoneOffset\)/); + assert.match(pageSource, /timezoneId: birthPlace\.timezoneId/); +}); diff --git a/frontend/tests/onboarding-route-fake.ts b/frontend/tests/onboarding-route-fake.ts index 7de9b02c..888f2deb 100644 --- a/frontend/tests/onboarding-route-fake.ts +++ b/frontend/tests/onboarding-route-fake.ts @@ -85,6 +85,11 @@ export function completeProfileRow( country_code: "CN", province_code: "110000", city_code: "110100", + latitude: 39.9042, + longitude: 116.4074, + timezone_offset: 8, + birth_place_label: null, + timezone_id: "Asia/Shanghai", onboarding_payload: null, onboarding_version: null, onboarding_generated_at: null, diff --git a/frontend/tests/onboarding-route.test.ts b/frontend/tests/onboarding-route.test.ts index de58c9db..d52eb014 100644 --- a/frontend/tests/onboarding-route.test.ts +++ b/frontend/tests/onboarding-route.test.ts @@ -128,6 +128,36 @@ test("period-only birth declaration can generate the home starter questions with assert.deepEqual(body, { ...payloadA, source: "agent" }); }); +test("global place with an IANA timezone can enter home before a numeric offset is resolved", async () => { + const repository = new StatefulOnboardingProfileRepository(completeProfileRow({ + name: "jesse", + birth_date: "1955-02-24", + birth_time: null, + reported_birth_time: null, + active_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "evening", + birth_time_clue: "大约晚上七点左右,可能前后差四十五分钟。", + birth_time_status: null, + country_code: "US", + province_code: null, + city_code: null, + latitude: 37.7879363, + longitude: -122.4075201, + timezone_offset: null, + birth_place_label: "旧金山, 加利福尼亚州, 美国", + timezone_id: "America/Los_Angeles", + })); + let generationCount = 0; + const response = await createPost(repository, async () => { + generationCount += 1; + return generatedText(payloadA); + })(); + + assert.equal(response.status, 200); + assert.equal(generationCount, 1); +}); + test("stale A generation returns pending after profile B replaces its claim", async () => { // Given: A owns a claim whose generation remains in flight. const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); diff --git a/frontend/tests/rectification-question-handoff.test.ts b/frontend/tests/rectification-question-handoff.test.ts index 8f8e8c0c..4c5d8840 100644 --- a/frontend/tests/rectification-question-handoff.test.ts +++ b/frontend/tests/rectification-question-handoff.test.ts @@ -64,20 +64,33 @@ function controllerFor(turn: ConversationalRectificationTurn): ConversationalRec start: async () => turn, resume: async () => turn, answer: async () => turn, + regenerate: async () => turn, pause: async () => turn, abandon: async () => turn, confirm: async () => turn, }; } -test("confirmed surface requires an explicit click before returning to the original question", () => { +function surfaceProps( + controller: ConversationalRectificationController, + overrides: Record = {}, +) { + return { + controller, + models: [{ id: "deepseek-chat", label: "DeepSeek", description: "", creditCost: 1, isDefault: true }] as const, + selectedModelId: "deepseek-chat", + onSelectModel: () => undefined, + ...overrides, + }; +} + +test("confirmed surface requires an explicit click before continuing the original question", () => { let continuationCalls = 0; const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { - controller: controllerFor(confirmedTurn()), + surfaceProps(controllerFor(confirmedTurn()), { onContinueOriginalQuestion: () => { continuationCalls += 1; }, - }, + }), )); assert.doesNotMatch(markup, /原问题:未来半年是否适合换工作?/); @@ -88,11 +101,10 @@ test("confirmed surface requires an explicit click before returning to the origi test("continuation action is visibly locked while ordinary consultation is pending", () => { const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { - controller: controllerFor(confirmedTurn()), + surfaceProps(controllerFor(confirmedTurn()), { continuationPending: true, onContinueOriginalQuestion: () => undefined, - }, + }), )); assert.match(markup, /]+disabled=""[^>]*>正在继续回答…<\/button>/); @@ -417,11 +429,10 @@ test("confirmed surface never revives an old local question after durable consum }; const markup = renderToStaticMarkup(React.createElement( ConversationalRectificationSurface, - { - controller: controllerFor(consumed), + surfaceProps(controllerFor(consumed), { pendingConsultationQuestion: "浏览器里的旧问题", onContinueOriginalQuestion: () => undefined, - }, + }), )); assert.doesNotMatch(markup, /使用新确认时间继续回答原问题/); diff --git a/references/open_source_sources/jyotishganit/jyotishganit/core/astronomical.py b/references/open_source_sources/jyotishganit/jyotishganit/core/astronomical.py index 2dcf6c0e..02379306 100644 --- a/references/open_source_sources/jyotishganit/jyotishganit/core/astronomical.py +++ b/references/open_source_sources/jyotishganit/jyotishganit/core/astronomical.py @@ -39,6 +39,10 @@ def _get_data_directory(): Returns: str: Path to the data directory following platform conventions """ + configured_dir = os.environ.get("JYOTISHGANIT_DATA_DIR", "").strip() + if configured_dir: + return os.path.abspath(os.path.expanduser(configured_dir)) + system = platform.system() if system == "Windows": diff --git a/requirements-reference-engines.txt b/requirements-reference-engines.txt new file mode 100644 index 00000000..0995f855 --- /dev/null +++ b/requirements-reference-engines.txt @@ -0,0 +1,9 @@ +# Optional isolated reference-engine environment. +# +# PyJHora is AGPL-3.0 and must remain an external benchmark/reference engine. +# Do not merge this file into the commercial production runtime requirements. +# PyJHora 4.8.7 omits geocoder from its PyPI dependency metadata even though +# jhora.utils imports it, so the missing runtime dependency is explicit here. +PyJHora==4.8.7 +geocoder==1.38.1 +geopy==2.4.1 diff --git a/requirements.txt b/requirements.txt index 6090a5df..ddbb3fcb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,13 @@ timezonefinder>=6.5 # VedAstro 官方 Python SDK(官方快照与交叉验证) vedastro==1.23.25 +# Vendored jyotishganit 0.1.3 (MIT) runtime dependencies. +# The source remains under references/open_source_sources/jyotishganit so its +# provenance and license stay auditable. +skyfield>=1.45,<2 +pandas>=1.3,<3 +numpy>=1.20,<3 + # 严格证据收集器通过 mcp_server 复用(API 运行时必需) mcp>=1.0 diff --git a/scripts/active_rectification_events.py b/scripts/active_rectification_events.py index 323bc2f8..fdbb49d3 100644 --- a/scripts/active_rectification_events.py +++ b/scripts/active_rectification_events.py @@ -88,6 +88,7 @@ class CandidateResult(TypedDict): calculation_contract: dict[str, Any] stability_diagnostics: dict[str, Any] missing_layers: list[str] + candidate_ranking_summary: NotRequired[list[dict[str, Any]]] def precision_weight(precision: EventPrecision) -> float: @@ -270,12 +271,39 @@ def adjudicate_candidate_rows( reasons.append("neighbor_stability_not_passed") if (leave_one_event_out or {}).get("status") != "pass": reasons.append("leave_one_event_out_not_passed") - # Minute confirmation remains release-gated until the frozen public AA holdout passes. - reasons.append("minute_holdout_not_ready") + can_apply = ( + confidence == "high" + and segment is not None + and neighbor_stability["all_required_passed"] + and (leave_one_event_out or {}).get("status") == "pass" + and not missing_layers + and not any(reason in reasons for reason in ( + "tied_leader", + "insufficient_events", + "insufficient_domains", + "winning_interval_too_wide", + "lead_margin_below_medium_threshold", + )) + ) + ranking_summary: list[dict[str, Any]] = [] + for score in ranked_scores[:3]: + score_rows = sorted( + (row for row in rows if row["score"] == score), + key=lambda row: _minute_value(row["time"]), + ) + if not score_rows: + continue + representative = score_rows[(len(score_rows) - 1) // 2] + ranking_summary.append({ + "rank": len(ranking_summary) + 1, + "time": representative["time"], + "score": score, + "tied_minute_count": len(score_rows), + }) return { "result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{request_fingerprint}")), "confidence": confidence, - "can_apply": False, + "can_apply": can_apply, "winning_segment": segment, "event_count": event_count, "domain_count": domain_count, @@ -292,6 +320,7 @@ def adjudicate_candidate_rows( "leave_one_event_out": leave_one_event_out or {"status": "not_evaluated", "runs": []}, }, "missing_layers": missing_layers, + "candidate_ranking_summary": ranking_summary, } diff --git a/scripts/diagnose_pyjhora_adapter.py b/scripts/diagnose_pyjhora_adapter.py index d194602c..fba2a704 100644 --- a/scripts/diagnose_pyjhora_adapter.py +++ b/scripts/diagnose_pyjhora_adapter.py @@ -4,7 +4,10 @@ from __future__ import annotations import argparse +import contextlib +import importlib import importlib.util +import io import json import os from pathlib import Path @@ -19,11 +22,22 @@ def build_report() -> dict: module_name = os.environ.get("PYJHORA_MODULE_NAME", "jhora").strip() or "jhora" adapter_exists = adapter.exists() module_available = importlib.util.find_spec(module_name) is not None + import_error = None + if module_available: + try: + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + importlib.import_module(f"{module_name}.utils") + importlib.import_module(f"{module_name}.horoscope.chart.charts") + importlib.import_module(f"{module_name}.panchanga.drik") + except Exception as exc: + import_error = f"{exc.__class__.__name__}: {exc}" if not adapter_exists: status = "missing_adapter" elif not module_available: status = "missing_dependency" + elif import_error: + status = "dependency_import_failed" else: status = "available" @@ -35,14 +49,17 @@ def build_report() -> dict: "dependency_module": module_name, "dependency_available": module_available, "missing_dependency": None if module_available else module_name, + "dependency_import_error": import_error, "install_hint": { "package": "PyJHora", - "commands": ["pip install PyJHora"], + "commands": [ + "pip install -r requirements.txt -r requirements-reference-engines.txt" + ], "note": "Install in an isolated optional benchmark environment, not as a hard runtime dependency.", }, "license_boundary": "AGPL external benchmark only; do not vendor or make it a runtime dependency.", "ephemeris_data_note": "Recent PyJHora releases may require separate Swiss Ephemeris data download/configuration before full chart comparison can run.", - "boundary": "This is an adapter readiness smoke check only; it does not run PyJHora chart comparison.", + "boundary": "This verifies required PyJHora modules import successfully, but does not run chart comparison.", } diff --git a/scripts/domain_calculation_service.py b/scripts/domain_calculation_service.py index 08fbd49d..4ddda2cb 100644 --- a/scripts/domain_calculation_service.py +++ b/scripts/domain_calculation_service.py @@ -7,7 +7,7 @@ import hashlib import json import math import threading -from datetime import datetime +from datetime import datetime, timezone from typing import Any from zoneinfo import ZoneInfo @@ -53,18 +53,66 @@ def _lookup_timezone_name(lat: float, lon: float) -> str | None: def infer_timezone_offset(*, lat: float, lon: float, local_datetime: datetime) -> float: + timezone_context = resolve_timezone_context( + lat=lat, + lon=lon, + local_datetime=local_datetime, + ) + offset = timezone_context["timezone_offset"] + if offset is None: + raise TimezoneInferenceError( + f"local time is {timezone_context['local_time_status']} in IANA zone" + ) + return float(offset) + + +def resolve_timezone_context( + *, lat: float, lon: float, local_datetime: datetime | None = None +) -> dict[str, Any]: + """Resolve an IANA zone and, when safe, its historical local UTC offset. + + A missing local time still permits timezone identification. DST folds and + gaps deliberately return no offset instead of silently choosing one. + """ if not (-90 <= lat <= 90 and -180 <= lon <= 180): raise TimezoneInferenceError("timezone inference received invalid coordinates") tz_name = _lookup_timezone_name(lat, lon) if not tz_name: raise TimezoneInferenceError("timezone inference returned no IANA zone") + if local_datetime is None: + return { + "timezone_id": tz_name, + "timezone_offset": None, + "local_time_status": "not_provided", + } try: - offset = local_datetime.replace(tzinfo=ZoneInfo(tz_name)).utcoffset() + zone = ZoneInfo(tz_name) + valid_offsets: set[float] = set() + for fold in (0, 1): + aware = local_datetime.replace(tzinfo=zone, fold=fold) + round_trip = aware.astimezone(timezone.utc).astimezone(zone).replace(tzinfo=None) + offset = aware.utcoffset() + if round_trip == local_datetime and offset is not None: + valid_offsets.add(offset.total_seconds() / 3600.0) except Exception as exc: raise TimezoneInferenceError("timezone inference failed for IANA zone") from exc - if offset is None: - raise TimezoneInferenceError("timezone inference returned no UTC offset") - return offset.total_seconds() / 3600.0 + if not valid_offsets: + return { + "timezone_id": tz_name, + "timezone_offset": None, + "local_time_status": "nonexistent", + } + if len(valid_offsets) > 1: + return { + "timezone_id": tz_name, + "timezone_offset": None, + "local_time_status": "ambiguous", + } + return { + "timezone_id": tz_name, + "timezone_offset": valid_offsets.pop(), + "local_time_status": "resolved", + } def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]: @@ -83,9 +131,16 @@ def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]: lat = float(payload["lat"]) lon = float(payload["lon"]) tz_requested = payload.get("tz") + timezone_id = payload.get("timezone_id", payload.get("timezoneId")) timezone_source = "explicit_offset" if tz_requested in {None, ""}: - tz = infer_timezone_offset(lat=lat, lon=lon, local_datetime=local_dt) + timezone_context = resolve_timezone_context(lat=lat, lon=lon, local_datetime=local_dt) + timezone_id = timezone_context["timezone_id"] + tz = timezone_context["timezone_offset"] + if tz is None: + raise TimezoneInferenceError( + f"local time is {timezone_context['local_time_status']} in IANA zone" + ) timezone_source = "iana_inferred" else: tz = float(tz_requested) @@ -101,6 +156,7 @@ def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]: "lat": lat, "lon": lon, "tz": tz, + "timezone_id": str(timezone_id).strip() if timezone_id else None, "timezone_source": timezone_source, "ayanamsa": ayanamsa, "node_mode": requested_node, @@ -151,11 +207,16 @@ def compute_chart(payload: dict[str, Any]) -> dict[str, Any]: "ephemeris_source": "swisseph_calc_ut", "ephemeris_flags_verified": False, } + if request["timezone_id"]: + effective["timezone_id"] = request["timezone_id"] requested = { "ayanamsa": payload.get("ayanamsa", "lahiri"), "node_mode": payload.get("node_mode", payload.get("nodeMode", "mean")), "timezone_offset": payload.get("tz"), } + requested_timezone_id = payload.get("timezone_id", payload.get("timezoneId")) + if requested_timezone_id: + requested["timezone_id"] = requested_timezone_id contract = _contract(requested, effective, algorithm="sidereal_natal_chart") hash_payload = { "contract": contract, diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index eacdd86e..77ca1491 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -90,6 +90,246 @@ _RATE_LIMIT_LOCK = threading.Lock() _RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {} +def resolve_location_timezone_payload(body): + """Resolve a global coordinate to IANA timezone and safe historical offset.""" + calculation_service = _load_local_module('domain_calculation_service') + lat = float(body['latitude']) + lon = float(body['longitude']) + birth_date = str(body.get('birthDate') or '').strip() + birth_time = str(body.get('birthTime') or '').strip() + local_datetime = None + if birth_time and not birth_date: + raise ValueError('birthDate is required when birthTime is supplied') + if birth_date: + if not re.fullmatch(r'\d{4}-\d{2}-\d{2}', birth_date): + raise ValueError('birthDate must be YYYY-MM-DD') + if birth_time: + if not re.fullmatch(r'(?:[01]\d|2[0-3]):[0-5]\d', birth_time): + raise ValueError('birthTime must be HH:MM') + local_datetime = datetime.fromisoformat(f'{birth_date}T{birth_time}:00') + else: + datetime.fromisoformat(birth_date) + context = calculation_service.resolve_timezone_context( + lat=lat, + lon=lon, + local_datetime=local_datetime, + ) + return { + 'available': True, + 'timezoneId': context['timezone_id'], + 'timezoneOffset': context['timezone_offset'], + 'timezoneSource': 'iana_historical', + 'localTimeStatus': context['local_time_status'], + } + +_VEDASTRO_RECTIFICATION_DOMAIN_MAP = { + 'career': ('career', 'direct'), + 'finance': ('wealth', 'direct'), + 'relationship': ('marriage', 'direct'), + 'education': ('career', 'proxy'), + 'relocation': ('career', 'proxy'), +} + + +def _rectification_event_date_range(event): + value = str(event.get('date') or '') + precision = str(event.get('precision') or '') + if precision == 'day': + return value, value + if precision == 'month': + start = datetime.strptime(value, '%Y-%m') + next_month = ( + datetime(start.year + 1, 1, 1) + if start.month == 12 + else datetime(start.year, start.month + 1, 1) + ) + return start.strftime('%Y-%m-%d'), (next_month - timedelta(days=1)).strftime('%Y-%m-%d') + if precision == 'year': + year = int(value) + return f'{year:04d}-01-01', f'{year:04d}-12-31' + raise ValueError(f'Unsupported event precision: {precision}') + + +def _rectification_event_representative_date(start_date, end_date): + start = datetime.strptime(start_date, '%Y-%m-%d') + end = datetime.strptime(end_date, '%Y-%m-%d') + return (start + (end - start) / 2).strftime('%Y-%m-%d') + + +def _select_vedastro_rectification_events(events): + precision_rank = {'year': 1, 'month': 2, 'day': 3} + selected_by_domain = {} + eligible_event_count = 0 + unsupported_events = [] + for event in events: + mapping = _VEDASTRO_RECTIFICATION_DOMAIN_MAP.get(event['domain']) + if mapping is None: + unsupported_events.append({ + 'event_id': event['id'], + 'ui_domain': event['domain'], + 'status': 'unsupported_range_scan_domain', + 'reason': 'VedAstro SearchEvents does not expose a supported health-pressure range-scan domain.', + }) + continue + eligible_event_count += 1 + adapter_domain, mapping_mode = mapping + start_date, end_date = _rectification_event_date_range(event) + rank = ( + 1 if mapping_mode == 'direct' else 0, + precision_rank.get(str(event.get('precision') or ''), 0), + end_date, + ) + current = selected_by_domain.get(adapter_domain) + if current is None or rank > current[0]: + representative_date = _rectification_event_representative_date(start_date, end_date) + selected_by_domain[adapter_domain] = ( + rank, + event, + adapter_domain, + mapping_mode, + representative_date, + representative_date, + ) + selected_events = [ + selected_by_domain[domain][1:] + for domain in sorted(selected_by_domain) + ] + return selected_events, eligible_event_count, unsupported_events + + +def _safe_vedastro_range_scan_summary(event, adapter_domain, mapping_mode, start_date, end_date, report): + ledger = report.get('evidence_ledger') if isinstance(report.get('evidence_ledger'), list) else [] + signal_lift = round(sum( + float(item.get('signal_lift') or 0) + for item in ledger + if isinstance(item, dict) and isinstance(item.get('signal_lift'), (int, float)) + ), 6) + event_count = int(report.get('event_count') or 0) + return { + 'event_id': str(event.get('id') or ''), + 'ui_domain': str(event.get('domain') or ''), + 'adapter_domain': adapter_domain, + 'mapping_mode': mapping_mode, + 'start_date': start_date, + 'end_date': end_date, + 'status': str(report.get('status') or 'unknown'), + 'available': bool(report.get('available')), + 'event_count': event_count, + 'matched': event_count > 0, + 'signal_lift': signal_lift, + 'top_event_id': str((report.get('top_event') or {}).get('event_id') or ''), + } + + +def _vedastro_candidate_metric(event_scans): + successful = [item for item in event_scans if item.get('status') == 'ok' and item.get('available')] + return { + 'requested_event_count': len(event_scans), + 'successful_event_count': len(successful), + 'matched_event_count': sum(1 for item in successful if item.get('matched')), + 'event_hit_count': sum(int(item.get('event_count') or 0) for item in successful), + 'signal_lift': round(sum(float(item.get('signal_lift') or 0) for item in successful), 6), + } + + +def _vedastro_metric_key(metric): + return ( + int(metric.get('matched_event_count') or 0), + float(metric.get('signal_lift') or 0), + int(metric.get('event_hit_count') or 0), + ) + + +_VEDASTRO_MINUTE_SENSITIVE_LAYERS = ( + 'ascendant_house_boundaries', + 'D9', + 'D10', + 'dasha_boundaries', +) + + +def _safe_vedastro_minute_snapshot_summary(candidate_time, report): + layers = report.get('layers') if isinstance(report.get('layers'), dict) else {} + safe_layers = {} + for name in (*_VEDASTRO_MINUTE_SENSITIVE_LAYERS, 'kp_cusp_sub_lord'): + layer = layers.get(name) if isinstance(layers.get(name), dict) else {} + safe_layer = { + 'status': str(layer.get('status') or 'missing'), + } + if name in _VEDASTRO_MINUTE_SENSITIVE_LAYERS: + safe_layer['fingerprint'] = layer.get('fingerprint') + if name == 'ascendant_house_boundaries': + safe_layer['ascendant'] = layer.get('ascendant') + safe_layer['house_count'] = len(layer.get('houses') or {}) + elif name in {'D9', 'D10'}: + safe_layer['house_count'] = len(layer.get('houses') or {}) + safe_layer['planet_count'] = len(layer.get('planets') or {}) + elif name == 'dasha_boundaries': + safe_layer['boundary_count'] = int(layer.get('boundary_count') or 0) + elif name == 'kp_cusp_sub_lord': + safe_layer['reason'] = str(layer.get('reason') or '') + safe_layers[name] = safe_layer + return { + 'candidate_time': candidate_time, + 'status': str(report.get('status') or 'blocked'), + 'available': bool(report.get('available')), + 'source': str(report.get('source') or 'vedastro_official'), + 'layers': safe_layers, + } + + +def _compare_vedastro_minute_snapshots(candidate_snapshots): + comparison_ready = len(candidate_snapshots) == 2 and all( + item.get('available') for item in candidate_snapshots + ) + differences = {} + discriminated_layers = [] + for layer_name in _VEDASTRO_MINUTE_SENSITIVE_LAYERS: + left = (candidate_snapshots[0].get('layers') or {}).get(layer_name, {}) if candidate_snapshots else {} + right = (candidate_snapshots[1].get('layers') or {}).get(layer_name, {}) if len(candidate_snapshots) > 1 else {} + both_available = left.get('status') == 'ok' and right.get('status') == 'ok' + differs = bool( + both_available + and left.get('fingerprint') + and right.get('fingerprint') + and left.get('fingerprint') != right.get('fingerprint') + ) + differences[layer_name] = { + 'status': 'different' if differs else 'same' if both_available else 'unavailable', + 'discriminated': differs, + } + if differs: + discriminated_layers.append(layer_name) + return { + 'comparison_ready': comparison_ready, + 'discriminated': comparison_ready and bool(discriminated_layers), + 'discriminated_layers': discriminated_layers, + 'differences': differences, + } + + +def _rectification_candidate_ready_for_external_validation(result): + """Allow external validation once local scoring has a narrow, auditable lead.""" + segment = result.get('winning_segment') + ranking = result.get('candidate_ranking_summary') + if not isinstance(segment, dict) or not isinstance(ranking, list) or len(ranking) < 2: + return False + try: + width_minutes = int(segment.get('width_minutes') or 0) + top_score = float(result.get('top_score')) + second_score = float(result.get('second_score')) + except (TypeError, ValueError): + return False + return ( + int(result.get('event_count') or 0) >= 3 + and int(result.get('domain_count') or 0) >= 2 + and 1 <= width_minutes <= 15 + and bool(segment.get('representative_time')) + and top_score > second_score + and 'missing_mandatory_layers' not in (result.get('reasons') or []) + ) + + def build_evidence_packet_view(job_record: dict | None) -> dict: """Public, token-protected job view. Excludes prompt internals and raw input.""" job_record = job_record or {} @@ -1693,6 +1933,11 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): else: lat, lon, tz = CITY_DB[matched] self._json({'status': 'local_city_match', 'city': matched, 'lat': lat, 'lon': lon, 'tz': tz}) + elif path == '/api/location/timezone': + try: + self._json(resolve_location_timezone_payload(body)) + except (KeyError, TypeError, ValueError) as exc: + self._error_json(str(exc), 400, 'ERR_INVALID_LOCATION_TIMEZONE_REQUEST') elif path == '/api/chart': result = self._compute_chart(body) self._json(result) @@ -6938,8 +7183,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): lon = self._get_float(body, 'lon', 0, -180, 180) tz = self._get_float(body, 'tz', 0, -14, 14) events = body.get('events') - if not isinstance(events, list) or not 3 <= len(events) <= 8: - raise BadRequest('events must contain between 3 and 8 items') + if not isinstance(events, list) or len(events) < 3: + raise BadRequest('events must contain at least 3 items') normalized_events = [] allowed_domains = {'education', 'relocation', 'relationship', 'career', 'finance', 'health_pressure'} formats = {'year': '%Y', 'month': '%Y-%m', 'day': '%Y-%m-%d'} @@ -6986,35 +7231,220 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'tz': tz, 'events': normalized_events, }) + external_validation = { + 'status': 'not_evaluated', + 'reason': 'local_candidate_not_ready_for_external_validation', + 'vedastro_status': 'not_evaluated', + 'vedastro_reason': 'official_vedastro_runs_after_local_scoring_produces_a_narrow_candidate', + 'blockers': [], + } + local_candidate_ready = _rectification_candidate_ready_for_external_validation(result) + if high_rigor and local_candidate_ready: + from scripts.rectification_three_engine_packet import build_packet + + representative_time = result['winning_segment']['representative_time'] + representative_hour, representative_minute = representative_time.split(':', 1) + engine_case = { + 'year': parsed_birth_date.year, + 'month': parsed_birth_date.month, + 'day': parsed_birth_date.day, + 'hour': int(representative_hour), + 'minute': int(representative_minute), + 'lat': lat, + 'lon': lon, + 'tz': tz, + } + three_engine_packet = build_packet(engine_case) + vedastro_events = [{ + 'date': event['date'], + 'domain': event['domain'], + 'summary': event.get('summary', ''), + } for event in normalized_events] + vedastro_result = self._compute_vedastro_gateway_run({ + **engine_case, + 'question': ( + 'Validate this rectified birth-minute candidate against the dated life events ' + 'and minute-sensitive chart boundaries. Events: ' + + json.dumps(vedastro_events, ensure_ascii=False, sort_keys=True) + ), + 'theme': sorted({ + _VEDASTRO_RECTIFICATION_DOMAIN_MAP[event['domain']][0] + for event in normalized_events + if event['domain'] in _VEDASTRO_RECTIFICATION_DOMAIN_MAP + }), + 'reference_date': datetime.now().strftime('%Y-%m-%d'), + }) + vedastro_status = str( + vedastro_result.get('official_closure_state') + or vedastro_result.get('status') + or 'blocked' + ) + parity_passed = ( + all(status == 'ok' for status in three_engine_packet.get('engine_status', {}).values()) + and three_engine_packet.get('mismatch_count') == 0 + ) + official_response_present = vedastro_status == 'official_verified' + ranking = result.get('candidate_ranking_summary') or [] + candidate_times = [ + str(item.get('time') or '') + for item in ranking[:2] + if isinstance(item, dict) and item.get('time') + ] + supported_events, eligible_event_count, unsupported_events = ( + _select_vedastro_rectification_events(normalized_events) + ) + + vedastro_adapter = _load_local_module('vedastro_service_adapter') + candidate_validations = [] + minute_candidate_snapshots = [] + range_scan_cache = {} + for candidate_time in candidate_times: + candidate_hour, candidate_minute = candidate_time.split(':', 1) + candidate_case = { + **engine_case, + 'hour': int(candidate_hour), + 'minute': int(candidate_minute), + } + minute_snapshot = vedastro_adapter.run_rectification_minute_snapshot_for_case( + candidate_case, + case_id=f'user_rectification_{candidate_time.replace(":", "")}', + ) + minute_candidate_snapshots.append( + _safe_vedastro_minute_snapshot_summary(candidate_time, minute_snapshot) + ) + event_scans = [] + for event, adapter_domain, mapping_mode, event_start, event_end in supported_events: + cache_key = (candidate_time, adapter_domain, event_start, event_end) + if cache_key not in range_scan_cache: + range_scan_cache[cache_key] = vedastro_adapter.run_range_scan_for_case( + candidate_case, + adapter_domain, + event_start, + event_end, + case_id=f'user_rectification_{candidate_time.replace(":", "")}', + ) + event_scans.append(_safe_vedastro_range_scan_summary( + event, + adapter_domain, + mapping_mode, + event_start, + event_end, + range_scan_cache[cache_key], + )) + candidate_validations.append({ + 'candidate_time': candidate_time, + 'metric': _vedastro_candidate_metric(event_scans), + 'events': event_scans, + }) + + minute_comparison = _compare_vedastro_minute_snapshots(minute_candidate_snapshots) + minute_snapshots_verified = bool( + minute_comparison['comparison_ready'] + and all(item.get('source') == 'vedastro_official' for item in minute_candidate_snapshots) + ) + official_response_present = official_response_present or minute_snapshots_verified + if minute_snapshots_verified: + vedastro_status = 'official_verified' + background_comparison_ready = len(candidate_validations) == 2 and bool(supported_events) + scans_succeeded = background_comparison_ready and all( + item['metric']['successful_event_count'] == item['metric']['requested_event_count'] + for item in candidate_validations + ) + semantic_validation_passed = ( + official_response_present + and minute_snapshots_verified + and minute_comparison['discriminated'] + ) + external_blockers = [] + if not parity_passed: + external_blockers.append('three_engine_parity_not_passed') + if not official_response_present: + external_blockers.append('vedastro_official_response_missing') + if len(candidate_times) < 2: + external_blockers.append('vedastro_runner_up_candidate_missing') + if len(candidate_times) >= 2 and not minute_snapshots_verified: + external_blockers.append('vedastro_minute_snapshot_failed') + if minute_snapshots_verified and not minute_comparison['discriminated']: + external_blockers.append('vedastro_minute_sensitive_layers_not_discriminated') + background_status = ( + 'pass' + if scans_succeeded + else 'partial' + if candidate_validations + else 'not_evaluated' + ) + external_validation = { + 'status': 'pass' if parity_passed and semantic_validation_passed else 'fail', + 'reason': ( + 'winner_must_pass_three_engine_parity_and_official_vedastro_minute_sensitive_candidate_identity_checks; ' + 'SearchEvents_is_background_only' + ), + 'vedastro_status': vedastro_status, + 'vedastro_reason': str( + vedastro_result.get('official_closure_reason') + or 'official_minute_snapshot_required' + ), + 'blockers': external_blockers, + 'candidate_time': representative_time, + 'engine_status': three_engine_packet.get('engine_status', {}), + 'match_count': three_engine_packet.get('match_count', 0), + 'mismatch_count': three_engine_packet.get('mismatch_count', 0), + 'minute_sensitive_validation': { + 'status': 'pass' if semantic_validation_passed else 'fail', + 'comparison_contract': ( + 'official_ascendant_house_boundaries_plus_D9_plus_D10_plus_dasha_boundary_identity; ' + 'local_event_scoring_keeps_candidate_order' + ), + 'candidate_order_source': 'local_dated_event_scoring', + 'candidates': minute_candidate_snapshots, + **minute_comparison, + 'kp_cusp_sub_lord': { + 'status': 'unsupported_by_verified_official_interface', + 'used_for_decision': False, + }, + }, + 'event_background_validation': { + 'status': background_status, + 'used_for_decision': False, + 'comparison_contract': 'SearchEvents provides background event context and never selects the final minute', + 'eligible_event_count': eligible_event_count, + 'supported_event_count': len(supported_events), + 'selection_policy': ( + 'one_strongest_event_per_native_adapter_domain; ' + 'direct_mapping_before_proxy; day_before_month_before_year; ' + 'newest_on_equal_precision; month_or_year_uses_midpoint_day' + ), + 'unsupported_events': unsupported_events, + 'candidates': candidate_validations, + }, + } + result['three_engine_packet'] = { + **three_engine_packet, + 'vedastro': { + 'status': vedastro_status, + 'official_closure_reason': vedastro_result.get('official_closure_reason'), + 'minute_sensitive_layers': list(_VEDASTRO_MINUTE_SENSITIVE_LAYERS), + 'search_events_role': 'background_only', + }, + 'can_confirm': parity_passed and semantic_validation_passed, + } + from scripts.rectification_technique_contract import build_rectification_technique_contract result['technique_contract'] = build_rectification_technique_contract( event_count=result.get('event_count', 0), domain_count=result.get('domain_count', 0), high_rigor=high_rigor, + local_candidate_ready=local_candidate_ready, stability_diagnostics=result.get('stability_diagnostics'), required_layers_complete='missing_mandatory_layers' not in result.get('reasons', []), canonical_input_hash=result.get('canonical_input_hash', ''), missing_required_layers=result.get('missing_layers', []), + external_validation=external_validation, ) result['can_apply'] = bool(result['technique_contract']['confirmation_allowed']) - if high_rigor: - from scripts.rectification_three_engine_packet import build_packet - result['three_engine_packet'] = build_packet({ - 'year': parsed_birth_date.year, - 'month': parsed_birth_date.month, - 'day': parsed_birth_date.day, - 'hour': int(start_time.split(':', 1)[0]), - 'minute': int(start_time.split(':', 1)[1]), - 'lat': lat, - 'lon': lon, - 'tz': tz, - }, - enqueue_vedastro_gateway=True, - vedastro_question='High-rigor birth-time rectification evidence packet', - vedastro_reference_date=datetime.now().strftime('%Y-%m-%d'), - ) - result['can_apply'] = False - result.setdefault('reasons', []).append('three_engine_parity_not_passed') + result['reasons'] = list(dict.fromkeys( + result.get('reasons', []) + result['technique_contract']['hard_blockers'] + )) return { 'success': True, 'endpoint': 'active_rectification_events', @@ -7147,7 +7577,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): except (KeyError, TypeError, ValueError) as exc: raise BadRequest(str(exc)) from exc result['can_apply'] = False - result.setdefault('reasons', []).append('minute_holdout_not_ready') + result.setdefault('reasons', []).append('vedastro_validation_required') return {'success': True, 'endpoint': 'dynamic_rectification_score', **result} def _compute_case_validation(self, body): diff --git a/scripts/rectification_technique_contract.py b/scripts/rectification_technique_contract.py index 33fc8ab8..31701ebb 100644 --- a/scripts/rectification_technique_contract.py +++ b/scripts/rectification_technique_contract.py @@ -13,35 +13,57 @@ def build_rectification_technique_contract( event_count: int, domain_count: int, high_rigor: bool = False, + local_candidate_ready: bool = False, stability_diagnostics: dict[str, Any] | None = None, required_layers_complete: bool = False, canonical_input_hash: str = "", missing_required_layers: list[str] | None = None, + external_validation: dict[str, Any] | None = None, ) -> dict[str, Any]: blockers: list[str] = [] if event_count < 3: blockers.append("insufficient_events") if domain_count < 2: blockers.append("insufficient_domains") - if high_rigor: - blockers.append("three_engine_parity_not_passed") neighbor = (stability_diagnostics or {}).get("neighbor_stability") or {} leave_one_out = (stability_diagnostics or {}).get("leave_one_event_out") or {} - if not neighbor.get("all_required_passed"): - blockers.append("neighbor_stability_not_passed") - if leave_one_out.get("status") != "pass": - blockers.append("leave_one_event_out_not_passed") + external = external_validation or {} + external_status = str(external.get("status") or "not_evaluated") + if not local_candidate_ready: + blockers.append("local_candidate_not_ready") if not required_layers_complete: blockers.append("required_layers_incomplete") - blockers.append("minute_holdout_not_ready") + if not high_rigor: + blockers.append("vedastro_validation_required") + elif external_status != "pass": + blockers.extend(external.get("blockers") or ["vedastro_validation_not_passed"]) + confirmation_allowed = ( + event_count >= 3 + and domain_count >= 2 + and local_candidate_ready + and required_layers_complete + and high_rigor + and external_status == "pass" + ) gates = { "event_quality": _gate("pass" if event_count >= 3 else "fail", "requires_at_least_three_dated_events"), "cross_domain_coverage": _gate("pass" if domain_count >= 2 else "fail", "requires_at_least_two_event_domains"), + "local_candidate": _gate("pass" if local_candidate_ready else "fail", "requires_a_unique_leading_candidate_range_no_wider_than_fifteen_minutes"), "required_layers": _gate("pass" if required_layers_complete else "fail", "all_event_required_layers_must_compute"), - "neighbor_stability": _gate("pass" if neighbor.get("all_required_passed") else "fail", "requires_unique_lead_at_plus_minus_1_2_5_minutes"), - "leave_one_event_out": _gate("pass" if leave_one_out.get("status") == "pass" else "fail", "leader_must_survive_removing_each_event"), - "three_engine_input_parity": _gate("fail" if high_rigor else "not_evaluated", "same_normalized_input_and_domain_parity_required"), - "public_holdout_release": _gate("blocked", "frozen_public_AA_minute_holdout_is_below_20_cases"), + "neighbor_stability": _gate("pass" if neighbor.get("all_required_passed") else "diagnostic_fail", "diagnostic_only_unique_lead_at_plus_minus_1_2_5_minutes"), + "leave_one_event_out": _gate("pass" if leave_one_out.get("status") == "pass" else "diagnostic_fail", "diagnostic_only_leader_survival_after_removing_each_event"), + "three_engine_input_parity": _gate( + "pass" if external.get("mismatch_count") == 0 and external.get("engine_status") else "fail" if high_rigor and external_status != "not_evaluated" else "not_evaluated", + "local_pyjhora_and_jyotishganit_must_match_the_same_candidate_input", + ), + "vedastro_official_response": _gate( + "pass" if external.get("vedastro_status") == "official_verified" else "fail" if high_rigor and external_status != "not_evaluated" else "not_evaluated", + str(external.get("vedastro_reason") or "official_vedastro_response_required_before_minute_sensitive_validation"), + ), + "vedastro_minute_sensitive_validation": _gate( + "pass" if (external.get("minute_sensitive_validation") or {}).get("status") == "pass" else "fail" if high_rigor and external_status != "not_evaluated" else "not_evaluated", + "official_ascendant_house_D9_D10_and_dasha_candidate_identity_must_discriminate_the_local_winner_from_runner_up", + ), } reported_missing_layers = list(dict.fromkeys([ *(missing_required_layers or []), @@ -56,12 +78,20 @@ def build_rectification_technique_contract( "missing_layers": reported_missing_layers, "partial_layers": ["shadbala_sthana_drik_naisargika"], "auxiliary_layers": ["functional_benefic_malefic", "controlled_transit", "ashtakavarga", "shadbala_verified_components"], - "external_engines": {"status": "required_not_run" if high_rigor else "not_run", "providers": ["pyjhora", "jyotishganit", "vedastro"]}, + "external_engines": { + "status": external_status, + "providers": ["pyjhora", "jyotishganit", "vedastro"], + "validation": external, + }, "canonical_input_hash": canonical_input_hash, "gates": gates, "hard_blockers": list(dict.fromkeys(blockers)), - "decision": "continue_rectification", - "confirmation_allowed": False, - "can_narrow_to_minute": False, - "boundary": "A candidate range is not a confirmed birth minute. Minute confirmation is disabled until the frozen public AA holdout release gate passes.", + "decision": "confirm_minute" if confirmation_allowed else "continue_rectification", + "confirmation_allowed": confirmation_allowed, + "can_narrow_to_minute": confirmation_allowed, + "boundary": ( + "The narrow local candidate passed required-layer, three-engine, and official VedAstro minute-sensitive identity validation. SearchEvents is background evidence only. Neighbor and leave-one-event-out stability remain diagnostic confidence indicators. Explicit user confirmation is still required before replacing the stored birth time." + if confirmation_allowed + else "A candidate range is not a confirmed birth minute. Continue until local candidate calculation, required layers, three-engine parity, and official VedAstro minute-sensitive identity validation pass. SearchEvents remains background evidence only; neighbor and leave-one-event-out results are diagnostic rather than hard blockers." + ), } diff --git a/scripts/rectification_three_engine_packet.py b/scripts/rectification_three_engine_packet.py index b8579a70..c5d0a8fa 100644 --- a/scripts/rectification_three_engine_packet.py +++ b/scripts/rectification_three_engine_packet.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib +import os import sys from datetime import datetime from pathlib import Path @@ -24,6 +25,7 @@ except ModuleNotFoundError: # pragma: no cover - direct script execution ROOT = Path(__file__).resolve().parents[1] JYOTISHGANIT_ROOT = ROOT / "references" / "open_source_sources" / "jyotishganit" +JYOTISHGANIT_DATA_DIR = ROOT / ".cache" / "jyotishganit" PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces") @@ -54,7 +56,12 @@ def _pyjhora_d1(case: dict[str, Any]) -> dict[str, str]: return {index_to_planet[body]: SIGNS[int(position[0])] for body, position in charts.rasi_chart(jd, place) if body in index_to_planet} +def _ensure_jyotishganit_data_dir() -> str: + return os.environ.setdefault("JYOTISHGANIT_DATA_DIR", str(JYOTISHGANIT_DATA_DIR)) + + def _jyotishganit_d1(case: dict[str, Any]) -> dict[str, str]: + _ensure_jyotishganit_data_dir() sys.path.insert(0, str(JYOTISHGANIT_ROOT)) try: from jyotishganit import calculate_birth_chart, get_birth_chart_json diff --git a/scripts/vedastro_service_adapter.py b/scripts/vedastro_service_adapter.py index c9947d8a..22f5b309 100644 --- a/scripts/vedastro_service_adapter.py +++ b/scripts/vedastro_service_adapter.py @@ -2748,6 +2748,126 @@ def run_official_full_snapshot_for_case( return _store_official_full_snapshot_semantic_cache(case, case_id, result) +def _rectification_position(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict) or not value.get("sign"): + return None + position = {"sign": str(value["sign"])} + degree = value.get("degree_in_sign") + if isinstance(degree, (int, float)): + position["degree_in_sign"] = round(float(degree), 6) + return position + + +def _rectification_varga_positions( + chart: dict[str, Any], + varga: str, +) -> tuple[dict[str, Any], dict[str, Any]]: + houses = chart.get("houses") if isinstance(chart.get("houses"), dict) else {} + planets = chart.get("planets") if isinstance(chart.get("planets"), dict) else {} + house_positions = { + name: position + for name, item in sorted(houses.items()) + if isinstance(item, dict) + and (position := _rectification_position((item.get("vargas") or {}).get(varga))) + } + planet_positions = { + name: position + for name, item in sorted(planets.items()) + if isinstance(item, dict) + and (position := _rectification_position((item.get("vargas") or {}).get(varga))) + } + return house_positions, planet_positions + + +def _rectification_timeline_item_count(value: Any) -> int: + if isinstance(value, list): + return len(value) + if isinstance(value, dict): + return max( + (_rectification_timeline_item_count(item) for item in value.values()), + default=0, + ) + return 0 + + +def run_rectification_minute_snapshot_for_case( + case: dict[str, Any], + *, + case_id: str = "user_chart", +) -> dict[str, Any]: + """Return a safe official fingerprint for minute-candidate comparison. + + The summary intentionally excludes raw official responses. SearchEvents is + not part of this fingerprint; it remains a separate background check. + """ + snapshot = run_official_full_snapshot_for_case(case, case_id=case_id) + chart = snapshot.get("official_chart") if isinstance(snapshot.get("official_chart"), dict) else {} + houses = chart.get("houses") if isinstance(chart.get("houses"), dict) else {} + ascendant = _rectification_position(chart.get("ascendant")) + house_positions = { + name: position + for name, item in sorted(houses.items()) + if (position := _rectification_position(item)) + } + d9_houses, d9_planets = _rectification_varga_positions(chart, "D9") + d10_houses, d10_planets = _rectification_varga_positions(chart, "D10") + sections = snapshot.get("snapshot_sections") if isinstance(snapshot.get("snapshot_sections"), dict) else {} + section_statuses = snapshot.get("section_statuses") if isinstance(snapshot.get("section_statuses"), dict) else {} + dasha_payload = sections.get("dasha_all") + + ascendant_house_payload = { + "ascendant": ascendant, + "houses": house_positions, + } + d9_payload = {"houses": d9_houses, "planets": d9_planets} + d10_payload = {"houses": d10_houses, "planets": d10_planets} + dasha_available = section_statuses.get("dasha_all") == "ok" and isinstance(dasha_payload, dict) + layers = { + "ascendant_house_boundaries": { + "status": "ok" if ascendant and house_positions else "missing", + **ascendant_house_payload, + "fingerprint": _hash_payload(ascendant_house_payload) if ascendant and house_positions else None, + }, + "D9": { + "status": "ok" if d9_houses or d9_planets else "missing", + **d9_payload, + "fingerprint": _hash_payload(d9_payload) if d9_houses or d9_planets else None, + }, + "D10": { + "status": "ok" if d10_houses or d10_planets else "missing", + **d10_payload, + "fingerprint": _hash_payload(d10_payload) if d10_houses or d10_planets else None, + }, + "dasha_boundaries": { + "status": "ok" if dasha_available else "missing", + "boundary_count": _rectification_timeline_item_count(dasha_payload) if dasha_available else 0, + "fingerprint": _hash_payload(dasha_payload) if dasha_available else None, + }, + "kp_cusp_sub_lord": { + "status": "unsupported_by_verified_official_interface", + "reason": "The verified VedAstro interface exposes KP house-membership helpers, not an auditable cusp/sub-lord result.", + }, + } + required_statuses = [layers[name]["status"] for name in ( + "ascendant_house_boundaries", "D9", "D10", "dasha_boundaries" + )] + available = bool(snapshot.get("available")) and all(status == "ok" for status in required_statuses) + source_metadata = snapshot.get("source_metadata") if isinstance(snapshot.get("source_metadata"), dict) else {} + return { + "available": available, + "status": "ok" if available else "partial" if any(status == "ok" for status in required_statuses) else "blocked", + "source": "vedastro_official", + "operation": "rectification_minute_snapshot", + "candidate_time": f'{int(case.get("hour") or 0):02d}:{int(case.get("minute") or 0):02d}', + "layers": layers, + "kp_cusp_sub_lord": layers["kp_cusp_sub_lord"], + "source_metadata": { + "response_hash": source_metadata.get("response_hash"), + "official_snapshot_status": snapshot.get("status"), + }, + } + + def _run_range_scan_case(case: dict[str, Any], domain: str, start_date: str, end_date: str) -> dict[str, Any]: if domain not in SUPPORTED_RANGE_SCAN_DOMAINS: return { @@ -2776,17 +2896,6 @@ def _run_range_scan_case(case: dict[str, Any], domain: str, start_date: str, end "source_metadata": _source_metadata(endpoint), } - range_scan_enabled = os.environ.get("VEDASTRO_RANGE_SCAN_NETWORK_ENABLED", "1").strip().lower() in {"1", "true", "yes", "on"} - if not range_scan_enabled: - return { - "backend": "vedastro_service_adapter_candidate", - "available": False, - "status": "network_execution_disabled", - "reason": "VEDASTRO_RANGE_SCAN_NETWORK_ENABLED is disabled for the interactive chat path.", - "request_preview": request_preview, - "source_metadata": _source_metadata(endpoint), - } - sample_dates = _iter_sample_dates(start_date, end_date) reports: list[dict[str, Any]] = [] for sample_date in sample_dates: diff --git a/tests/test_active_rectification_api.py b/tests/test_active_rectification_api.py index de56206a..3f8eda0f 100644 --- a/tests/test_active_rectification_api.py +++ b/tests/test_active_rectification_api.py @@ -9,6 +9,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" if str(SCRIPTS) not in sys.path: sys.path.insert(0, str(SCRIPTS)) +import jyotish_api_server as api_server # noqa: E402 from jyotish_api_server import BadRequest, JyotishAPIHandler # noqa: E402 @@ -406,19 +407,115 @@ def test_gochara_conflict_downgrades_without_verified_timing_claim_red() -> None assert scored["timing_claim_status"] == "exploratory_unvalidated" -def test_high_rigor_event_rectification_queues_vedastro_packet(monkeypatch) -> None: +def test_high_rigor_event_rectification_requires_real_vedastro_candidate_discrimination(monkeypatch) -> None: + original_loader = api_server._load_local_module + + class LocalScorer: + @staticmethod + def score_life_events(request): + return { + "result_id": "local-result", + "confidence": "high", + "can_apply": True, + "winning_segment": { + "start_time": "14:30", + "end_time": "14:30", + "representative_time": "14:30", + "width_minutes": 1, + }, + "event_count": len(request["events"]), + "domain_count": len({event["domain"] for event in request["events"]}), + "top_score": 30, + "second_score": 20, + "margin_percent": 33.33, + "reasons": [], + "evidence": [], + "algorithm_version": "fixture", + "canonical_input_hash": "canonical-fixture", + "calculation_contract": {"events": request["events"]}, + "stability_diagnostics": { + "neighbor_stability": {"all_required_passed": True}, + "leave_one_event_out": {"status": "pass"}, + }, + "missing_layers": [], + "candidate_ranking_summary": [ + {"rank": 1, "time": "14:30", "score": 30, "tied_minute_count": 1}, + {"rank": 2, "time": "14:31", "score": 20, "tied_minute_count": 1}, + ], + } + + class VedAstroAdapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + minute = case["minute"] + return { + "available": True, + "status": "ok", + "source": "vedastro_official", + "layers": { + "ascendant_house_boundaries": { + "status": "ok", + "fingerprint": f"asc-{minute}", + "ascendant": {"sign": "Leo", "degree_in_sign": minute / 10}, + "houses": {"House1": {}}, + }, + "D9": { + "status": "ok", + "fingerprint": f"d9-{minute}", + "houses": {"House1": {}}, + "planets": {}, + }, + "D10": { + "status": "ok", + "fingerprint": "d10-same", + "houses": {"House1": {}}, + "planets": {}, + }, + "dasha_boundaries": { + "status": "ok", + "fingerprint": f"dasha-{minute}", + "boundary_count": 3, + }, + "kp_cusp_sub_lord": { + "status": "unsupported_by_verified_official_interface", + "reason": "not supported by verified official interface", + }, + }, + "raw_response": {"must_not": "leak"}, + } + + @staticmethod + def run_range_scan_for_case(case, _domain, _start, _end, case_id="user_chart"): + return { + "available": True, + "status": "ok", + "event_count": 1, + "top_event": {"event_id": f"event-{case_id}"}, + "evidence_ledger": [{"signal_lift": 1}], + "raw_response": {"must_not": "leak"}, + } + monkeypatch.setattr( - "scripts.rectification_three_engine_packet._enqueue_vedastro_gateway_job", + api_server, + "_load_local_module", + lambda name: LocalScorer if name == "active_rectification_events" else VedAstroAdapter if name == "vedastro_service_adapter" else original_loader(name), + ) + monkeypatch.setattr( + "scripts.rectification_three_engine_packet.build_packet", + lambda _case: { + "engine_status": {"local": "ok", "pyjhora": "ok", "jyotishganit": "ok"}, + "match_count": 3, + "mismatch_count": 0, + }, + ) + monkeypatch.setattr( + JyotishAPIHandler, + "_compute_vedastro_gateway_run", lambda *_args, **_kwargs: { - "scope": "vedastro_gateway_job_receipt", - "status": "queued", - "job_id": "vgw_rectification", - "poll_path": "/api/vedastro_gateway/jobs/vgw_rectification", - "raw_response_archive": { - "status": "pending", - "official_raw_response_available": False, - }, - "boundary": "VedAstro raw response remains server-side; this receipt never returns request data or raw evidence.", + "status": "ok", + "official_closure_state": "official_verified", + "official_closure_reason": "official_raw_response_present", + "official_raw_response": {"must_not": "leak"}, }, ) result = _handler()._compute_active_rectification_events( @@ -444,30 +541,290 @@ def test_high_rigor_event_rectification_queues_vedastro_packet(monkeypatch) -> N } ) receipt = result["three_engine_packet"]["vedastro"] - assert receipt["status"] == "queued" - assert receipt["job_id"] == "vgw_rectification" - assert "1993" not in str(receipt) + assert receipt["status"] == "official_verified" contract = result["technique_contract"] - assert result["can_apply"] is False - assert contract["confirmation_allowed"] is False - assert contract["decision"] == "continue_rectification" + validation = contract["external_engines"]["validation"] + assert result["can_apply"] is True + assert contract["confirmation_allowed"] is True + assert contract["decision"] == "confirm_minute" assert contract["canonical_input_hash"] - assert contract["gates"]["public_holdout_release"]["status"] == "blocked" + assert contract["gates"]["vedastro_minute_sensitive_validation"]["status"] == "pass" + assert validation["minute_sensitive_validation"]["discriminated"] is True + assert validation["minute_sensitive_validation"]["discriminated_layers"] + assert validation["event_background_validation"]["used_for_decision"] is False + assert validation["event_background_validation"]["candidates"][0]["metric"] == validation["event_background_validation"]["candidates"][1]["metric"] + assert "must_not" not in str(validation) assert result["calculation_contract"]["events"][0]["summary"] == "2011 年 9 月离开家乡开始大学生活" -def test_active_rectification_events_accepts_product_limit_of_eight() -> None: - events = [ +def test_long_real_conversation_reaches_vedastro_after_local_range_is_narrow(monkeypatch) -> None: + original_loader = api_server._load_local_module + vedastro_calls: list[tuple[str, str, str, str]] = [] + + class VedAstroAdapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(case, case_id="user_chart"): + candidate_time = f'{case["hour"]:02d}:{case["minute"]:02d}' + return { + "available": True, + "status": "ok", + "source": "vedastro_official", + "layers": { + "ascendant_house_boundaries": { + "status": "ok", + "fingerprint": f"asc-{candidate_time}", + "ascendant": {"sign": "Leo", "degree_in_sign": case["minute"] / 10}, + "houses": {"House1": {}}, + }, + "D9": { + "status": "ok", + "fingerprint": f"d9-{candidate_time}", + "houses": {"House1": {}}, + "planets": {}, + }, + "D10": { + "status": "ok", + "fingerprint": f"d10-{candidate_time}", + "houses": {"House1": {}}, + "planets": {}, + }, + "dasha_boundaries": { + "status": "ok", + "fingerprint": f"dasha-{candidate_time}", + "boundary_count": 3, + }, + "kp_cusp_sub_lord": { + "status": "unsupported_by_verified_official_interface", + "reason": "not supported by verified official interface", + }, + }, + } + + @staticmethod + def run_range_scan_for_case(case, domain, start, end, case_id="user_chart"): + candidate_time = f'{case["hour"]:02d}:{case["minute"]:02d}' + vedastro_calls.append((candidate_time, domain, start, end)) + return { + "available": True, + "status": "ok", + "event_count": 1, + "top_event": {"event_id": f"event-{case_id}"}, + "evidence_ledger": [{"signal_lift": 1}], + } + + monkeypatch.setattr( + api_server, + "_load_local_module", + lambda name: VedAstroAdapter if name == "vedastro_service_adapter" else original_loader(name), + ) + monkeypatch.setattr( + "scripts.rectification_three_engine_packet.build_packet", + lambda _case: { + "engine_status": {"local": "ok", "pyjhora": "ok", "jyotishganit": "ok"}, + "match_count": 3, + "mismatch_count": 0, + }, + ) + monkeypatch.setattr( + JyotishAPIHandler, + "_compute_vedastro_gateway_run", + lambda *_args, **_kwargs: { + "status": "ok", + "official_closure_state": "official_verified", + "official_closure_reason": "official_raw_response_present", + "official_raw_response": {"status": "ok"}, + }, + ) + + result = _handler()._compute_active_rectification_events( { - "id": f"00000000-0000-4000-a000-{index:012d}", - "domain": "education" if index % 2 == 0 else "career", - "date": f"{2011 + index}", - "precision": "year", - "summary": f"event {index}", + "birth_date": "1997-08-08", + "start_time": "04:00", + "end_time": "07:59", + "lat": 36.420487, + "lon": 114.209936, + "tz": 8, + "high_rigor": True, + "events": [ + {"id": "00000000-0000-4000-8000-000000000001", "domain": "education", "date": "2016-09", "precision": "month", "summary": "离家去外地上大学"}, + {"id": "00000000-0000-4000-8000-000000000002", "domain": "career", "date": "2020-04", "precision": "month", "summary": "去石油化工研究院实习做研究员"}, + {"id": "00000000-0000-4000-8000-000000000003", "domain": "career", "date": "2020-10", "precision": "month", "summary": "从研究院辞职"}, + {"id": "00000000-0000-4000-8000-000000000004", "domain": "education", "date": "2020-12", "precision": "month", "summary": "参加研究生考试结果不理想"}, + {"id": "00000000-0000-4000-8000-000000000005", "domain": "relocation", "date": "2021-01", "precision": "month", "summary": "回家备考并长期在家"}, + {"id": "00000000-0000-4000-8000-000000000006", "domain": "education", "date": "2022-12", "precision": "month", "summary": "考研结束后转向自学前端"}, + {"id": "00000000-0000-4000-8000-000000000007", "domain": "career", "date": "2023-04", "precision": "month", "summary": "去北京入职医疗器械公司"}, + {"id": "00000000-0000-4000-8000-000000000008", "domain": "relationship", "date": "2024-08-08", "precision": "day", "summary": "恋爱关系发生重大转折"}, + {"id": "00000000-0000-4000-8000-000000000009", "domain": "relationship", "date": "2024-10", "precision": "month", "summary": "短暂复联后主动断联"}, + {"id": "00000000-0000-4000-8000-000000000010", "domain": "finance", "date": "2026-01", "precision": "month", "summary": "公司无法正常发放工资"}, + {"id": "00000000-0000-4000-8000-000000000011", "domain": "career", "date": "2026-07-10", "precision": "day", "summary": "与朋友正式决定创业"}, + {"id": "00000000-0000-4000-8000-000000000012", "domain": "career", "date": "2026-07-21", "precision": "day", "summary": "提交公司注册材料"}, + ], } - for index in range(8) + ) + + assert result["winning_segment"] == { + "start_time": "05:07", + "end_time": "05:08", + "representative_time": "05:07", + "width_minutes": 2, + } + assert result["stability_diagnostics"]["neighbor_stability"]["all_required_passed"] is False + assert result["stability_diagnostics"]["leave_one_event_out"]["status"] != "pass" + assert len(vedastro_calls) == 6 + assert {call[0] for call in vedastro_calls} == {"05:07", "05:21"} + assert {call[1] for call in vedastro_calls} == {"career", "wealth", "marriage"} + assert all(start == end for _, _, start, end in vedastro_calls) + assert { + (domain, start) + for candidate, domain, start, _ in vedastro_calls + if candidate == "05:07" + } == { + ("career", "2026-07-21"), + ("wealth", "2026-01-16"), + ("marriage", "2024-08-08"), + } + assert { + candidate + for candidate, _, _, _ in vedastro_calls + } == { + item["time"] + for item in result["candidate_ranking_summary"][:2] + } + validation = result["technique_contract"]["external_engines"]["validation"] + event_validation = validation["event_background_validation"] + assert event_validation["eligible_event_count"] == 12 + assert event_validation["supported_event_count"] == 3 + assert event_validation["used_for_decision"] is False + assert event_validation["candidates"][0]["metric"] == event_validation["candidates"][1]["metric"] + assert "one_strongest_event_per_native_adapter_domain" in event_validation["selection_policy"] + assert result["three_engine_packet"]["vedastro"]["status"] == "official_verified" + assert result["three_engine_packet"]["vedastro"]["search_events_role"] == "background_only" + assert result["technique_contract"]["gates"]["vedastro_minute_sensitive_validation"]["status"] == "pass" + assert validation["minute_sensitive_validation"]["discriminated"] is True + assert result["technique_contract"]["confirmation_allowed"] is True + assert result["technique_contract"]["decision"] == "confirm_minute" + assert result["can_apply"] is True + assert "neighbor_stability_not_passed" not in result["technique_contract"]["hard_blockers"] + assert "leave_one_event_out_not_passed" not in result["technique_contract"]["hard_blockers"] + + +def test_identical_vedastro_minute_sensitive_snapshots_do_not_discriminate_candidates() -> None: + layers = { + name: {"status": "ok", "fingerprint": f"same-{name}"} + for name in api_server._VEDASTRO_MINUTE_SENSITIVE_LAYERS + } + snapshots = [ + {"candidate_time": "05:07", "available": True, "layers": layers}, + {"candidate_time": "05:21", "available": True, "layers": layers}, ] + comparison = api_server._compare_vedastro_minute_snapshots(snapshots) + + assert comparison["comparison_ready"] is True + assert comparison["discriminated"] is False + assert comparison["discriminated_layers"] == [] + assert all(item["status"] == "same" for item in comparison["differences"].values()) + + +def test_search_events_difference_cannot_override_identical_minute_snapshots(monkeypatch) -> None: + original_loader = api_server._load_local_module + + class LocalScorer: + @staticmethod + def score_life_events(request): + return { + "result_id": "local-result", + "confidence": "high", + "can_apply": True, + "winning_segment": { + "start_time": "14:30", + "end_time": "14:30", + "representative_time": "14:30", + "width_minutes": 1, + }, + "event_count": len(request["events"]), + "domain_count": len({event["domain"] for event in request["events"]}), + "top_score": 30, + "second_score": 20, + "margin_percent": 33.33, + "reasons": [], + "evidence": [], + "algorithm_version": "fixture", + "canonical_input_hash": "canonical-fixture", + "calculation_contract": {"events": request["events"]}, + "stability_diagnostics": { + "neighbor_stability": {"all_required_passed": True}, + "leave_one_event_out": {"status": "pass"}, + }, + "missing_layers": [], + "candidate_ranking_summary": [ + {"rank": 1, "time": "14:30", "score": 30, "tied_minute_count": 1}, + {"rank": 2, "time": "14:31", "score": 20, "tied_minute_count": 1}, + ], + } + + class VedAstroAdapter: + @staticmethod + def run_rectification_minute_snapshot_for_case(_case, case_id="user_chart"): + layers = { + name: { + "status": "ok", + "fingerprint": f"same-{name}", + "houses": {"House1": {}}, + "planets": {}, + "boundary_count": 3, + } + for name in api_server._VEDASTRO_MINUTE_SENSITIVE_LAYERS + } + layers["ascendant_house_boundaries"]["ascendant"] = { + "sign": "Leo", + "degree_in_sign": 12.5, + } + layers["kp_cusp_sub_lord"] = { + "status": "unsupported_by_verified_official_interface", + "reason": "not supported by verified official interface", + } + return { + "available": True, + "status": "ok", + "source": "vedastro_official", + "layers": layers, + } + + @staticmethod + def run_range_scan_for_case(case, _domain, _start, _end, case_id="user_chart"): + event_count = 10 if case["minute"] == 30 else 1 + return { + "available": True, + "status": "ok", + "event_count": event_count, + "top_event": {"event_id": f"event-{case_id}"}, + "evidence_ledger": [{"signal_lift": event_count}], + } + + monkeypatch.setattr( + api_server, + "_load_local_module", + lambda name: LocalScorer if name == "active_rectification_events" else VedAstroAdapter if name == "vedastro_service_adapter" else original_loader(name), + ) + monkeypatch.setattr( + "scripts.rectification_three_engine_packet.build_packet", + lambda _case: { + "engine_status": {"local": "ok", "pyjhora": "ok", "jyotishganit": "ok"}, + "match_count": 3, + "mismatch_count": 0, + }, + ) + monkeypatch.setattr( + JyotishAPIHandler, + "_compute_vedastro_gateway_run", + lambda *_args, **_kwargs: { + "status": "ok", + "official_closure_state": "official_verified", + "official_closure_reason": "official_raw_response_present", + }, + ) + result = _handler()._compute_active_rectification_events( { "birth_date": "1993-04-17", @@ -476,9 +833,21 @@ def test_active_rectification_events_accepts_product_limit_of_eight() -> None: "lat": 36.683333, "lon": 114.35, "tz": 8, - "events": events, + "high_rigor": True, + "events": [ + {"id": "5cb071d6-6d99-46be-85dc-a9bf59ef6ac5", "domain": "education", "date": "2011-09", "precision": "month"}, + {"id": "0790866c-ad5e-4a45-b2b4-a5c73f6be6ea", "domain": "career", "date": "2019-07-01", "precision": "day"}, + {"id": "0ef52e51-ab5f-453b-81e5-adb44a929224", "domain": "relationship", "date": "2021", "precision": "year"}, + ], } ) - assert result["success"] is True - assert result["event_count"] == 8 + validation = result["technique_contract"]["external_engines"]["validation"] + background_candidates = validation["event_background_validation"]["candidates"] + assert background_candidates[0]["metric"] != background_candidates[1]["metric"] + assert validation["event_background_validation"]["used_for_decision"] is False + assert validation["minute_sensitive_validation"]["discriminated"] is False + assert result["can_apply"] is False + assert result["technique_contract"]["confirmation_allowed"] is False + assert "vedastro_minute_sensitive_layers_not_discriminated" in result["reasons"] + assert "vedastro_candidate_not_discriminated" not in result["reasons"] diff --git a/tests/test_active_rectification_events.py b/tests/test_active_rectification_events.py index 8caa2679..70199002 100644 --- a/tests/test_active_rectification_events.py +++ b/tests/test_active_rectification_events.py @@ -20,32 +20,29 @@ def _row(time: str, score: float) -> CandidateScoreRow: } -def test_high_evidence_candidate_remains_blocked_until_public_holdout_release() -> None: +def test_locally_stable_high_evidence_candidate_can_enter_external_validation() -> None: result = adjudicate_candidate_rows( - [ - _row("14:22", 16), - _row("14:23", 16), - _row("14:24", 16), - _row("14:25", 16), - _row("14:26", 16), - _row("14:27", 10), - ], + [_row(f"14:{minute:02d}", 20 if minute == 25 else 10) for minute in range(20, 31)], event_count=4, domain_count=3, request_fingerprint="high-fixture", + leave_one_event_out={"status": "pass", "runs": []}, ) assert result["confidence"] == "high" - assert result["can_apply"] is False - assert "minute_holdout_not_ready" in result["reasons"] + assert result["can_apply"] is True assert result["winning_segment"] == { - "start_time": "14:22", - "end_time": "14:26", - "representative_time": "14:24", - "width_minutes": 5, + "start_time": "14:25", + "end_time": "14:25", + "representative_time": "14:25", + "width_minutes": 1, } - assert result["margin_percent"] == 37.5 - assert result["stability_diagnostics"]["neighbor_stability"]["all_required_passed"] is False + assert result["margin_percent"] == 50.0 + assert result["stability_diagnostics"]["neighbor_stability"]["all_required_passed"] is True + assert result["candidate_ranking_summary"][:2] == [ + {"rank": 1, "time": "14:25", "score": 20, "tied_minute_count": 1}, + {"rank": 2, "time": "14:24", "score": 10, "tied_minute_count": 10}, + ] def test_tied_disjoint_candidates_abstain() -> None: @@ -213,7 +210,7 @@ def test_event_summary_is_fingerprinted_without_unlocking_minute_application() - assert changed_hash != input_hash assert result["canonical_input_hash"] == input_hash assert result["can_apply"] is False - assert "minute_holdout_not_ready" in result["reasons"] + assert "insufficient_events" in result["reasons"] def test_finance_events_use_d2_d11_and_recompute_both_dashas_per_minute(monkeypatch) -> None: diff --git a/tests/test_commercial_domain_calculation_contract.py b/tests/test_commercial_domain_calculation_contract.py index 6441a739..cd1f8c5d 100644 --- a/tests/test_commercial_domain_calculation_contract.py +++ b/tests/test_commercial_domain_calculation_contract.py @@ -36,6 +36,65 @@ def test_domain_chart_exposes_effective_parameters_and_result_hash() -> None: assert result["calculation_contract"]["effective"]["ayanamsa"] == "lahiri" assert result["result_hash"] + +def test_historical_timezone_resolution_distinguishes_new_york_dst() -> None: + winter = calculation_service.resolve_timezone_context( + lat=40.7128, + lon=-74.006, + local_datetime=datetime(1990, 1, 15, 12, 0), + ) + summer = calculation_service.resolve_timezone_context( + lat=40.7128, + lon=-74.006, + local_datetime=datetime(1990, 7, 15, 12, 0), + ) + + assert winter == { + "timezone_id": "America/New_York", + "timezone_offset": -5.0, + "local_time_status": "resolved", + } + assert summer["timezone_id"] == "America/New_York" + assert summer["timezone_offset"] == -4.0 + + +def test_timezone_resolution_refuses_dst_fold_and_gap_offsets() -> None: + folded = calculation_service.resolve_timezone_context( + lat=40.7128, + lon=-74.006, + local_datetime=datetime(2020, 11, 1, 1, 30), + ) + gap = calculation_service.resolve_timezone_context( + lat=40.7128, + lon=-74.006, + local_datetime=datetime(2020, 3, 8, 2, 30), + ) + + assert folded["timezone_offset"] is None + assert folded["local_time_status"] == "ambiguous" + assert gap["timezone_offset"] is None + assert gap["local_time_status"] == "nonexistent" + + +def test_domain_chart_exposes_inferred_timezone_id() -> None: + payload = {key: value for key, value in BIRTH.items() if key != "tz"} + result = calculation_service.compute_chart(payload) + + assert result["calculation_contract"]["effective"]["timezone_id"] == "Asia/Kolkata" + assert result["calculation_contract"]["effective"]["timezone_source"] == "iana_inferred" + + +def test_location_timezone_api_contract_preserves_unknown_local_time() -> None: + result = jyotish_api_server.resolve_location_timezone_payload({ + "latitude": 51.5074, + "longitude": -0.1278, + "birthDate": "1990-01-01", + }) + + assert result["timezoneId"] == "Europe/London" + assert result["timezoneOffset"] is None + assert result["localTimeStatus"] == "not_provided" + def test_api_chart_uses_same_domain_contract_and_preserves_shape( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -159,4 +218,3 @@ def test_api_dasha_boundary_comes_from_domain_service(monkeypatch: pytest.Monkey ) assert rest["dasha"]["start_date"] == expected["periods"][0]["start"] assert rest["dasha"]["result_hash"] == expected["result_hash"] - diff --git a/tests/test_dynamic_rectification_scoring.py b/tests/test_dynamic_rectification_scoring.py index b38c71bb..ea826972 100644 --- a/tests/test_dynamic_rectification_scoring.py +++ b/tests/test_dynamic_rectification_scoring.py @@ -138,7 +138,7 @@ def test_authenticated_dynamic_questionnaire_cannot_open_minute_confirmation(mon assert result["confidence"] == "high" assert result["can_apply"] is False - assert "minute_holdout_not_ready" in result["reasons"] + assert "vedastro_validation_required" in result["reasons"] def test_opportunity_route_defaults_legacy_missing_events_to_empty(monkeypatch) -> None: diff --git a/tests/test_pyjhora_adapter_diagnostics.py b/tests/test_pyjhora_adapter_diagnostics.py index c28ca8f5..343b185e 100644 --- a/tests/test_pyjhora_adapter_diagnostics.py +++ b/tests/test_pyjhora_adapter_diagnostics.py @@ -29,7 +29,9 @@ def test_pyjhora_adapter_diagnostics_reports_current_status() -> None: assert report["scope"] == "pyjhora_adapter_diagnostics" assert report["adapter_command"] == "python3 benchmarks/jyotish/scripts/run_pyjhora_compare.py" - assert report["status"] in {"available", "missing_dependency"} + assert report["status"] in {"available", "missing_dependency", "dependency_import_failed"} + if report["status"] == "available": + assert report["dependency_import_error"] is None def test_pyjhora_adapter_diagnostics_reports_missing_dependency() -> None: @@ -43,6 +45,6 @@ def test_pyjhora_adapter_diagnostics_reports_actionable_external_boundary() -> N report = _run_diag({"PYJHORA_MODULE_NAME": "__definitely_missing_pyjhora_module__"}) assert report["install_hint"]["package"] == "PyJHora" - assert "pip install PyJHora" in report["install_hint"]["commands"] + assert "requirements-reference-engines.txt" in report["install_hint"]["commands"][0] assert report["license_boundary"] == "AGPL external benchmark only; do not vendor or make it a runtime dependency." assert report["ephemeris_data_note"] diff --git a/tests/test_rectification_technique_contract.py b/tests/test_rectification_technique_contract.py index b525e97d..969e7cd2 100644 --- a/tests/test_rectification_technique_contract.py +++ b/tests/test_rectification_technique_contract.py @@ -21,20 +21,21 @@ def test_contract_discloses_used_and_missing_layers() -> None: assert "ashtakavarga" in contract["auxiliary_layers"] assert "shadbala_verified_components" in contract["auxiliary_layers"] assert "shadbala_sthana_drik_naisargika" in contract["partial_layers"] - assert contract["external_engines"]["status"] == "not_run" + assert contract["external_engines"]["status"] == "not_evaluated" def test_high_rigor_requires_real_three_engine_evidence() -> None: contract = build_rectification_technique_contract(event_count=4, domain_count=3, high_rigor=True) - assert contract["external_engines"]["status"] == "required_not_run" - assert "three_engine_parity_not_passed" in contract["hard_blockers"] + assert contract["external_engines"]["status"] == "not_evaluated" + assert "vedastro_validation_not_passed" in contract["hard_blockers"] assert contract["can_narrow_to_minute"] is False -def test_public_holdout_blocks_confirmation_even_when_other_gates_pass() -> None: +def test_vedastro_minute_sensitive_validation_is_required_after_local_gates_pass() -> None: contract = build_rectification_technique_contract( event_count=4, domain_count=3, + local_candidate_ready=True, required_layers_complete=True, canonical_input_hash="canonical-fixture", stability_diagnostics={ @@ -46,11 +47,64 @@ def test_public_holdout_blocks_confirmation_even_when_other_gates_pass() -> None assert contract["canonical_input_hash"] == "canonical-fixture" assert contract["gates"]["neighbor_stability"]["status"] == "pass" assert contract["gates"]["leave_one_event_out"]["status"] == "pass" - assert contract["gates"]["public_holdout_release"]["status"] == "blocked" + assert contract["gates"]["vedastro_minute_sensitive_validation"]["status"] == "not_evaluated" assert contract["confirmation_allowed"] is False assert contract["decision"] == "continue_rectification" +def test_semantic_vedastro_validation_allows_minute_confirmation() -> None: + contract = build_rectification_technique_contract( + event_count=5, + domain_count=3, + high_rigor=True, + local_candidate_ready=True, + required_layers_complete=True, + stability_diagnostics={ + "neighbor_stability": {"all_required_passed": True}, + "leave_one_event_out": {"status": "pass"}, + }, + external_validation={ + "status": "pass", + "vedastro_status": "official_verified", + "mismatch_count": 0, + "engine_status": {"local": "ok", "pyjhora": "ok", "jyotishganit": "ok"}, + "minute_sensitive_validation": {"status": "pass"}, + }, + ) + + assert contract["gates"]["vedastro_official_response"]["status"] == "pass" + assert contract["gates"]["vedastro_minute_sensitive_validation"]["status"] == "pass" + assert contract["confirmation_allowed"] is True + assert contract["decision"] == "confirm_minute" + + +def test_neighbor_and_leave_one_out_failures_remain_diagnostic_after_external_validation() -> None: + contract = build_rectification_technique_contract( + event_count=12, + domain_count=5, + high_rigor=True, + local_candidate_ready=True, + required_layers_complete=True, + stability_diagnostics={ + "neighbor_stability": {"all_required_passed": False}, + "leave_one_event_out": {"status": "fail"}, + }, + external_validation={ + "status": "pass", + "vedastro_status": "official_verified", + "mismatch_count": 0, + "engine_status": {"local": "ok", "pyjhora": "ok", "jyotishganit": "ok"}, + "minute_sensitive_validation": {"status": "pass"}, + }, + ) + + assert contract["gates"]["neighbor_stability"]["status"] == "diagnostic_fail" + assert contract["gates"]["leave_one_event_out"]["status"] == "diagnostic_fail" + assert "neighbor_stability_not_passed" not in contract["hard_blockers"] + assert "leave_one_event_out_not_passed" not in contract["hard_blockers"] + assert contract["confirmation_allowed"] is True + + def test_contract_reports_the_actual_missing_dasha_layer() -> None: contract = build_rectification_technique_contract( event_count=3, diff --git a/tests/test_rectification_three_engine_packet.py b/tests/test_rectification_three_engine_packet.py index ff2e295d..5a78315c 100644 --- a/tests/test_rectification_three_engine_packet.py +++ b/tests/test_rectification_three_engine_packet.py @@ -1,4 +1,9 @@ -from scripts.rectification_three_engine_packet import build_packet, case_hash +from scripts.rectification_three_engine_packet import ( + JYOTISHGANIT_DATA_DIR, + _ensure_jyotishganit_data_dir, + build_packet, + case_hash, +) CASE = {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 0.0, "lon": 0.0, "tz": 0.0} @@ -28,6 +33,13 @@ def test_case_hash_uses_only_normalized_calculation_input() -> None: assert case_hash(equivalent) == case_hash({**CASE, "ayanamsa": "lahiri", "node_mode": "mean"}) +def test_jyotishganit_uses_project_writable_cache(monkeypatch) -> None: + monkeypatch.delenv("JYOTISHGANIT_DATA_DIR", raising=False) + + assert _ensure_jyotishganit_data_dir() == str(JYOTISHGANIT_DATA_DIR) + assert __import__("os").environ["JYOTISHGANIT_DATA_DIR"] == str(JYOTISHGANIT_DATA_DIR) + + def test_high_rigor_packet_queues_safe_vedastro_receipt_without_raw(monkeypatch) -> None: monkeypatch.setattr("scripts.rectification_three_engine_packet._local_d1", lambda _: {"Sun": "Aries"}) monkeypatch.setattr("scripts.rectification_three_engine_packet._pyjhora_d1", lambda _: {"Sun": "Aries"}) diff --git a/tests/test_vedastro_chat_runtime_config.py b/tests/test_vedastro_chat_runtime_config.py index e596651e..b5d49e43 100644 --- a/tests/test_vedastro_chat_runtime_config.py +++ b/tests/test_vedastro_chat_runtime_config.py @@ -1,6 +1,10 @@ from __future__ import annotations import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] def _birth_case() -> dict[str, object]: @@ -59,24 +63,35 @@ def test_fast_snapshot_executes_only_five_scalar_methods(monkeypatch) -> None: } -def test_disabled_range_scan_never_calls_network(monkeypatch) -> None: +def test_rectification_range_scan_cannot_be_disabled_separately_from_official_network(monkeypatch) -> None: from scripts import vedastro_service_adapter monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://api.vedastro.org/api") monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") monkeypatch.setenv("VEDASTRO_RANGE_SCAN_NETWORK_ENABLED", "0") + calls: list[dict[str, object]] = [] - def fail_if_called(*_args, **_kwargs): - raise AssertionError("range scan network request should not run in chat mode") + def fake_post(_endpoint: str, request_preview: dict[str, object]): + calls.append(request_preview) + return {"Status": "Pass", "Payload": []}, 1, [] - monkeypatch.setattr(vedastro_service_adapter, "_post_json_with_retry", fail_if_called) + monkeypatch.setattr(vedastro_service_adapter, "_post_json_with_retry", fake_post) result = vedastro_service_adapter.run_range_scan_for_case( _birth_case(), "career", "2026-07-14", - "2026-08-14", + "2026-07-14", ) - assert result["status"] == "network_execution_disabled" - assert "interactive chat path" in result["reason"] + assert len(calls) == 1 + assert result["status"] == "ok" + + +def test_official_env_example_enables_official_gateway_and_range_scan() -> None: + example = (ROOT / ".env.official.example").read_text(encoding="utf-8") + + assert "VEDASTRO_GATEWAY_MODE=official_first" in example + assert "VEDASTRO_API_ENDPOINT=https://api.vedastro.org/api" in example + assert "VEDASTRO_ENABLE_NETWORK=1" in example + assert "VEDASTRO_RANGE_SCAN_NETWORK_ENABLED=1" in example diff --git a/tests/test_vedastro_official_full_snapshot.py b/tests/test_vedastro_official_full_snapshot.py index c2a6bca9..921579b8 100644 --- a/tests/test_vedastro_official_full_snapshot.py +++ b/tests/test_vedastro_official_full_snapshot.py @@ -196,6 +196,22 @@ def test_official_full_snapshot_marks_semantic_rate_limit_payloads(monkeypatch) monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://example.invalid/api") monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") monkeypatch.setenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED", "1") + monkeypatch.setattr( + adapter, + "_try_official_capability_runner_snapshot_bundle", + lambda _case: {"available": False, "status": "blocked", "snapshot_sections": {}, "section_statuses": {}}, + ) + monkeypatch.setattr( + adapter, + "_try_official_python_bridge_snapshot_bundle", + lambda _case: {"available": False, "status": "blocked", "snapshot_sections": {}, "section_statuses": {}}, + ) + monkeypatch.setattr( + adapter, + "_try_official_full_capability_catalog_bundle", + lambda _case: {"available": False, "status": "blocked", "summary": {}, "coverage": {}}, + ) monkeypatch.setattr(adapter, "_post_official_snapshot_section", fake_post) result = adapter.run_official_full_snapshot_for_case( @@ -522,6 +538,23 @@ def test_official_full_snapshot_extracts_official_chart_and_varga_from_pass_payl monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://example.invalid/api") monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") + monkeypatch.setenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED", "1") + monkeypatch.setattr( + adapter, + "_try_official_capability_runner_snapshot_bundle", + lambda _case: {"available": False, "status": "blocked", "snapshot_sections": {}, "section_statuses": {}}, + ) + monkeypatch.setattr( + adapter, + "_try_official_python_bridge_snapshot_bundle", + lambda _case: {"available": False, "status": "blocked", "snapshot_sections": {}, "section_statuses": {}}, + ) + monkeypatch.setattr( + adapter, + "_try_official_full_capability_catalog_bundle", + lambda _case: {"available": False, "status": "blocked", "summary": {}, "coverage": {}}, + ) monkeypatch.setattr(adapter, "_post_official_snapshot_section", fake_post) result = adapter.run_official_full_snapshot_for_case( @@ -663,6 +696,77 @@ def test_official_full_snapshot_can_use_python_bridge_bundle_without_rest_endpoi assert result["snapshot_sections"]["ashtakavarga"]["Payload"]["AshtakvargaLifeMap"]["TotalBindus"] == 337 +def test_rectification_minute_snapshot_exposes_safe_official_layer_fingerprints(monkeypatch) -> None: + from scripts import vedastro_service_adapter as adapter + + monkeypatch.setattr( + adapter, + "run_official_full_snapshot_for_case", + lambda _case, case_id="user_chart": { + "available": True, + "status": "ok", + "official_chart": { + "ascendant": {"sign": "Leo", "degree_in_sign": 13.0, "raw_response": "secret"}, + "houses": { + "House1": { + "sign": "Leo", + "degree_in_sign": 13.0, + "vargas": { + "D9": {"sign": "Cancer", "degree_in_sign": 27.7}, + "D10": {"sign": "Sagittarius", "degree_in_sign": 10.8}, + }, + } + }, + "planets": { + "Sun": { + "sign": "Aries", + "degree_in_sign": 3.5, + "vargas": { + "D9": {"sign": "Taurus", "degree_in_sign": 1.5}, + "D10": {"sign": "Taurus", "degree_in_sign": 5.0}, + }, + } + }, + }, + "snapshot_sections": { + "dasha_all": {"Payload": {"DasaAtRange": [{"start": "2020-01-01"}, {"start": "2021-01-01"}]}}, + "raw_response": {"must_not": "leak"}, + }, + "section_statuses": {"dasha_all": "ok"}, + "source_metadata": {"response_hash": "official-response-hash", "api_key": "must-not-leak"}, + }, + ) + + result = adapter.run_rectification_minute_snapshot_for_case( + { + "year": 1955, + "month": 2, + "day": 24, + "hour": 19, + "minute": 15, + "lat": 37.7749, + "lon": -122.4194, + "tz": -8, + }, + case_id="minute_snapshot_unit", + ) + + assert result["available"] is True + assert result["status"] == "ok" + assert result["candidate_time"] == "19:15" + assert all( + result["layers"][layer]["status"] == "ok" + for layer in ("ascendant_house_boundaries", "D9", "D10", "dasha_boundaries") + ) + assert all( + result["layers"][layer]["fingerprint"] + for layer in ("ascendant_house_boundaries", "D9", "D10", "dasha_boundaries") + ) + assert result["layers"]["kp_cusp_sub_lord"]["status"] == "unsupported_by_verified_official_interface" + assert "raw_response" not in str(result) + assert "must-not-leak" not in str(result) + + def test_official_full_snapshot_prefers_official_capability_runner_bundle(monkeypatch) -> None: from scripts import vedastro_service_adapter as adapter @@ -1015,6 +1119,12 @@ def test_official_full_snapshot_skips_rest_sections_already_filled_by_python_bun monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://example.invalid/api") monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1") + monkeypatch.setenv("VEDASTRO_FULL_SNAPSHOT_FANOUT_ENABLED", "1") + monkeypatch.setattr( + adapter, + "_try_official_full_capability_catalog_bundle", + lambda _case: {"available": False, "status": "blocked", "summary": {}, "coverage": {}}, + ) monkeypatch.setattr(adapter, "_try_official_python_bridge_snapshot_bundle", fake_bundle) monkeypatch.setattr(adapter, "_post_official_snapshot_section", fake_post)