From f51e494c5abaffe2972bb115078d962edef31393 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 15 Sep 2026 17:02:58 +0800 Subject: [PATCH] fix(chat): keep rectification sessions findable and keep the delivery card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rectification answers now advance chat_sessions.updated_at (BUG-704). A ?c= id missing from the loaded page is fetched before anyone may call it deleted (BUG-705). The range card no longer has a post-card tie-break button; live questions leave the card visible with adopt locked (BUG-706/708). Spoken copy bans 相对支持度 (BUG-709). Task docs assigned 700-704; qizheng already took 700-703. --- CHANGELOG.md | 8 ++ docs/BUG_HISTORY.md | 90 +++++++++++++- ...fication-open-retitles-session-20260915.md | 49 ++++---- ...ctification-tiebreak-card-loss-20260915.md | 36 ++++++ docs/tasks/README.md | 6 +- .../rectification-open-identity-20260915.md | 17 ++- ...ctification-tiebreak-card-loss-20260915.md | 35 ++++++ frontend/DESIGN.md | 6 +- frontend/docs/VOICE.md | 1 + .../repair-rectification-session-titles.mjs | 57 +++++++-- frontend/src/app/globals.css | 18 --- frontend/src/app/page.tsx | 15 ++- .../components/rectification-agentic-chat.tsx | 53 ++------ .../rectification-range-delivery.tsx | 19 ++- frontend/src/hooks/use-session-management.ts | 27 ++++- frontend/src/lib/chat-session-url.ts | 48 +++++++- frontend/src/lib/home-bootstrap.ts | 8 +- frontend/src/lib/home-cloud-sync.ts | 69 ++++++++++- .../lib/rectification-agentic/user-copy.ts | 2 + .../v9/adopt-narration-agent.ts | 4 +- .../v9/adopt-narration.ts | 3 +- .../v9/agent-voice-lexicon.ts | 1 + .../rectification-agentic/v9/answer-choice.ts | 16 +++ .../v9/method-followup.ts | 9 ++ .../v9/probe-question-contract.ts | 16 +++ .../rectification-agentic/v9/turn-question.ts | 9 +- .../src/lib/rectification-surface-state.ts | 3 +- ...10000_rectification_touch_chat_session.sql | 51 ++++++++ .../tests/agent-voice-copy-contract.test.ts | 7 +- frontend/tests/chat-session-url.test.ts | 14 ++- .../tests/database-local-business.test.ts | 1 + ...ctification-candidate-offer-anchor.test.ts | 18 ++- ...tification-range-delivery-20260907.test.ts | 26 ++-- ...ification-tie-break-entry-20260913.test.ts | 15 ++- ...cation-tiebreak-card-loss-20260915.test.ts | 113 ++++++++++++++++++ .../tests/rectification-v9-migration.test.ts | 19 +++ .../tests/session-lookup-unlisted.test.ts | 87 ++++++++++++++ 37 files changed, 799 insertions(+), 177 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-tiebreak-card-loss-20260915.md create mode 100644 docs/testing/rectification-tiebreak-card-loss-20260915.md create mode 100644 frontend/supabase/migrations/20260915010000_rectification_touch_chat_session.sql create mode 100644 frontend/tests/rectification-tiebreak-card-loss-20260915.test.ts create mode 100644 frontend/tests/session-lookup-unlisted.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8636d29e..efbbbe90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-09-15 — 校正会话按答题时间排序;链接打不开时先去库里找 + +在生时校正里答题、点选、采用或停止,这条会话会回到「今天」最上面。只是打开或刷新,位置不变。地址栏带着一条不在当前列表里的对话时,会先问服务器:还在就打开,确认没有才说「已被删除」。已经被写成今天日期的历史校正标题,按创建当天修回;自己改过名字的不动。Skill 版本不变。 + +## 2026-09-15 — 范围卡上不再有「再答两道参考题」;有题时卡也不会消失 + +风格参考题只在给出范围卡之前问,而且必须是带选项的选择题。建不出选项就跳过,不会变成一行裸题。范围卡已经出来之后,上面不再有「再答两道参考题」按钮。如果这时还有一道没答完的题,范围卡留着,「更像这个」先不能点。旁白不再写「相对支持度」这种内部词;卡上三列的「相对可能性」不变。Skill 版本不变。 + ## 2026-09-15 — 新增只读星历页:五要素、当日行运、九十天事件 登录后可直接打开 `/ephemeris`,看当天的五要素、九曜行运(含相对本命宫位)和未来九十天的换座与停滞。不扣点、不调模型。日期可前后切换。这一页只摆天象,不写对你的判断;底部「带这天去提问」会把该日期带进对话。五要素按 Lahiri 计算,和星盘页的岁差可能不是同一套,页上有说明。换座与停滞若后端尚未上线,该段显示静态说明,其余部分照常。Skill 版本未 bump。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 4b812515..024e599e 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -8560,8 +8560,8 @@ - 根因:`visibleSessions` 只按 `pinned` 排,开着页面时 `updatedAt` 变化不改位置。元数据 PATCH 和服务端 `metadataUpdateValues` 一律写 `updated_at = now()`,非对话操作也会把会话顶到列表头。真正该 bump 的是发问 / 回答落库。 - 修复:`sortSessions` 置顶优先、组内 `updatedAt` 倒序且稳定。元数据 PATCH 不再写 `updated_at`;客户端改名 / 收藏 / 归档 / 换模型 / 换资料不再 `timestamp()`。列表改为游标分页,历史按本地日期分组,侧栏标题去掉资料名前缀。 - 验证:`frontend/tests/session-groups.test.ts`、`session-cursor.test.ts`、`chat-session-write.test.ts`(`metadataUpdateValues` 不含 `updated_at`)、`sidebar-state.test.ts`(改名不 bump `updatedAt`)、`sidebar-contract.test.ts`、`chart-library-session.test.ts`。 -- 防复发:元数据 PATCH 不得写 `updated_at`。`updatedAt` 只由对话活动推进。侧栏历史排序必须置顶 + `updatedAt` 倒序,不得只按置顶。打开已有会话不得改 `title` / `updatedAt`,由 BUG-699 与 `frontend/tests/session-open-preserves-identity.test.ts` 执行。 -- 相关记录:BUG-024、BUG-699 +- 防复发:元数据 PATCH 不得写 `updated_at`。`updatedAt` 只由对话活动推进,校正答题也算(BUG-704)。侧栏历史排序必须置顶 + `updatedAt` 倒序,不得只按置顶。打开已有会话不得改 `title` / `updatedAt`,由 BUG-699 与 `frontend/tests/session-open-preserves-identity.test.ts` 执行。`?c=` 不在已加载页时先问服务端(BUG-705)。 +- 相关记录:BUG-024、BUG-699、BUG-704、BUG-705 - 复发自:无 - 修复版本:`a1956deb` @@ -10689,8 +10689,8 @@ - 根因:性格题被设计成出卡后的可选入口。另:`varga_style` 被错误要求该领域账本有带年月事件作锚,答「没有」会连带关掉风格题。 - 修复:产品拍板放宽版 A(2026-09-14)。出卡前只要参考题可用且没用过就先连问最多两道 `varga_style`(不看分差;仍只 ±1、不淘汰)。删除风格题的事件锚点丢弃;分盘上升星座少于两种时该题仍不出现。仍平时卡上写「这两分钟按现有信息分不开,参考题已经用过。」用过之后不再显示入口按钮。 - 验证:`frontend/tests/rectification-tiebreak-before-card-20260914.test.ts`:16/16 与 lead=2 都先问参考题;无事件锚点时风格题仍出现;两道用完才交付。 -- 防复发:不得把性格题改成淘汰候选。不得为凑满两道而放宽「该分盘至少两种上升星座」。自动进入参考题时不得再写第二条交付消息。 -- 相关记录:BUG-663、BUG-667、BUG-629、BUG-597 +- 防复发:不得把性格题改成淘汰候选。不得为凑满两道而放宽「该分盘至少两种上升星座」。自动进入参考题时不得再写第二条交付消息。卡上入口已于 BUG-706 删除,前置收集是唯一路径。 +- 相关记录:BUG-663、BUG-667、BUG-629、BUG-597、BUG-706、BUG-708 - 复发自:BUG-663(入口做成出卡后可选) - 修复版本:待发布 @@ -10866,7 +10866,7 @@ - 修复:已有会话的 `title` / `updatedAt` 继承 `existing`,只有新建才 `resolveSessionTitle` + `timestamp()`。错日期标题按 `created_at` 的 Asia/Shanghai 月日修回;对不上正则的手改标题不动。 - 验证:`frontend/tests/session-open-preserves-identity.test.ts`、`rectification-session-title-repair.test.ts`。浏览器级验收见 `docs/testing/rectification-open-identity-20260915.md`。staging 标题修回行数见本单 PROGRESS。 - 防复发:构造可能作用于已有会话的 `ChatSession` 时,不得无条件写 `updatedAt: timestamp()`。`resolveSessionTitle` 的 `at` 默认墙钟,只能用于新建。 -- 相关记录:BUG-553 +- 相关记录:BUG-553、BUG-704、BUG-705 - 复发自:BUG-553 - 修复版本:待发布 @@ -10934,6 +10934,54 @@ - 复发自:无 - 修复版本:未修(investigating) +## BUG-704 | 校正答题不推进 chat_sessions.updated_at,会话边用边下沉 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`agentic_rectification_cases.last_activity_at`、`chat_sessions.updated_at`、侧栏历史排序 +- 用户现象:一条校正连着答几小时,侧栏位置仍停在创建那天。叠加上被改成今天的标题后,列表变成「名字是今天、排序是旧日期」。 +- 触发条件:在生时校正里写 turn、点选、采用或停止。打开、刷新、改名、收藏不触发。 +- 根因:复发自 BUG-553 的另一半。全仓只有咨询 RPC `append_consultation_question` 写 `chat_sessions.updated_at`。校正 turn 走 `append_agentic_rectification_turn` 等,只推 `agentic_rectification_cases.last_activity_at`,不碰会话表。`persistSession` 的 update 按 BUG-553 故意不写 `updated_at`。 +- 修复:`last_activity_at` 推进时触发器同步把对应校正会话的 `updated_at` 推到同一时刻,且不得回拨。历史冻结行按最后一条 turn / `last_activity_at` 回填。客户端 `persistSession` 仍不写 `updated_at`。 +- 验证:`frontend/tests/rectification-v9-migration.test.ts` 的 touch-session 迁移合同。浏览器级见 `docs/testing/rectification-open-identity-20260915.md`。staging 回填行数见本单 PROGRESS。 +- 防复发:校正对话活动必须推进 `chat_sessions.updated_at`。元数据 PATCH、打开、刷新不得推进。不得为了修这个让 `persistSession` 重新写 `updated_at`。 +- 相关记录:BUG-553、BUG-699、BUG-705 +- 复发自:BUG-553 +- 修复版本:待发布 + +## BUG-705 | `?c=` 不在已加载页就被当成已删除 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`resolveBootstrapSessionSelection`、`GET /api/sessions/{id}`、首页 bootstrap、popstate +- 用户现象:刷新或从链接进入一条校正,提示「该对话不存在或已被删除」,地址栏 `?c=` 被清掉,侧栏也看不见。没有删除动作发生。 +- 触发条件:目标会话不在当前已加载的 40 条里(常见于 BUG-704 把它压到旧日期),URL 仍带着它的 id。 +- 根因:`listedIds` 只覆盖当前页。未命中就 `missing: true` 并 `replace-clear`。服务端单条 GET 存在却从未被问。 +- 修复:合法 UUID 未在已加载页时改为 `lookup`。`GET /api/sessions/{id}` 200 则并进列表并选中;404 才宣告删除并清 URL;5xx / 网络失败保留 `?c=`,提示「这条对话暂时读不到,请稍后重试。」 +- 验证:`frontend/tests/session-lookup-unlisted.test.ts`、`chat-session-url.test.ts`。 +- 防复发:不在已加载页不得等于已删除。`SESSION_MISSING_NOTICE` 只允许在服务端 404 之后出现。 +- 相关记录:BUG-553、BUG-699、BUG-704 +- 复发自:无 +- 修复版本:待发布 + +## BUG-706 | 点卡上「再答两道参考题」后交付卡整张消失 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`applyLiveCandidateOffer`、`showSelectionCards`、区间交付卡 +- 用户现象:范围卡在场时点「再答两道参考题微调排序」,卡消失,只剩一句确认语和一道没有选项的题,上一轮「范围在上面」也对不上了。 +- 触发条件:交付卡上的参考题按钮;`requestTieBreak` 写入一道未答 `choice` / `collect_spoken`。 +- 根因:`applyLiveCandidateOffer` 和 `showSelectionCards` 只要有未答题就把每一条 `candidateOffer` 剥掉 / 整卡不画。守卫本意是有活题时不要同时诱导采用,但按钮就长在卡上,点一下必然丢卡。产品原意(BUG-686)是出卡前收集参考题,卡上这条路不该存在。 +- 修复:删除卡上入口。有活题时卡留下,「更像这个」置灰并写「先答完上面这道,再选时间」。前置收集仍走 `requestTieBreakPersonality`。 +- 验证:`frontend/tests/rectification-tiebreak-card-loss-20260915.test.ts`、`rectification-candidate-offer-anchor.test.ts`、`rectification-range-delivery-20260907.test.ts`。 +- 防复发:任何抑制交付卡的守卫都必须有出口。不得再在交付卡上放参考题按钮。新写 turn 仍须并进对话区(BUG-685)。 +- 相关记录:BUG-685、BUG-686、BUG-688 +- 复发自:BUG-686(卡上保留了第二条路) +- 修复版本:待发布 + ## BUG-707 | 星历页五要素按 Lahiri,星盘页按账户岁差(默认 Raman) - 状态:investigating @@ -10949,3 +10997,35 @@ - 相关记录:BUG-703 - 复发自:无 - 修复版本:未修 + +## BUG-708 | 参考题按钮能亮,点开却是没有选项的裸题 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`tieBreakPersonalityAvailable`、`buildChoiceCard` / `canRenderYearlessChoice`、`interviewChoiceCardUnavailable` +- 用户现象:点参考题后出现「亲密关系里,你更接近哪一种相处方式?」,没有 A/B/C/D。交付卡已被 BUG-706 抹掉,两条路都断。 +- 触发条件:`tieBreakPersonalityFollowup` 拿得到 followup,但 `styleOptions` 建不出或分盘上升星座少于两种。 +- 根因:复发自 BUG-686。按钮只看 followup 非空;卡片渲染另走 `completeStyleOptions` + `canRenderYearlessChoice`。两个判据不一致。也落在 BUG-674 / BUG-675 的裸题家族上。 +- 修复:followup 存在且同一套可渲染性判断通过,才算 available,才允许 persist。焦点已落库但没有 `choice_card` 时走既有 repair-exit,不画裸题。 +- 验证:`frontend/tests/rectification-tiebreak-card-loss-20260915.test.ts`。`rectification-tiebreak-before-card-20260914.test.ts` 既有断言未减弱。 +- 防复发:`tieBreakPersonalityAvailable` 必须复用 `vargaStyleFollowupRenderable`,不得再复制第三套条件。建不出选项的题不得提出,也不得渲染成裸题。 +- 相关记录:BUG-686、BUG-674、BUG-675、BUG-706 +- 复发自:BUG-686 +- 修复版本:待发布 + +## BUG-709 | 交付旁白写「相对支持度」,并与卡上入口自相矛盾 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:采用/交付旁白、`MACHINE_VOICE_LEXICON`、`validateAdoptNarration`、`VOICE.md` +- 用户现象:旁白写「没有年份的分盘题也不再问」「相对支持度都是 16」,同一屏卡上却摆着「再答两道参考题」。 +- 触发条件:走到区间交付;Agent 旁白引用了内部计分词。 +- 根因:采用旁白指令允许使用「相对支持度数字」。校验只拦未知数字,不拦禁用词。卡上仍保留参考题按钮,与「不再问」打脸。 +- 修复:禁用词表与校验都拦「相对支持度」。指令改为「这两个时间按现有信息分不开」。卡上入口删除后,「不再问」与控件不再打架。卡上「相对可能性 %」保持不变。 +- 验证:`frontend/tests/agent-voice-copy-contract.test.ts`、`rectification-tiebreak-card-loss-20260915.test.ts`。 +- 防复发:用户可见文案不得出现「相对支持度」。`MACHINE_VOICE_LEXICON` 与 `validateAdoptNarration` 双拦。 +- 相关记录:BUG-706、BUG-686 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-open-retitles-session-20260915.md b/docs/tasks/PROGRESS-rectification-open-retitles-session-20260915.md index fe7d9e2e..320036e6 100644 --- a/docs/tasks/PROGRESS-rectification-open-retitles-session-20260915.md +++ b/docs/tasks/PROGRESS-rectification-open-retitles-session-20260915.md @@ -1,46 +1,41 @@ -# PROGRESS · 打开历史校正不再改名(BUG-699,2026-09-15) +# PROGRESS · 校正会话列表身份(BUG-699 / 704 / 705,2026-09-15) -- 执行分支:`codex/rectification-open-retitles-session-20260915` -- 工作树:`.worktrees/rectification-open-retitles-session-20260915` -- 基线:`origin/staging` @ `224244c0`(任务书写 `ff5023a2`;其后一笔是本任务书) +- 执行分支:`codex/rectification-p0-20260915`(与交付卡单同一分支,两单文件几乎不重叠,一次推 staging) +- 工作树:`.worktrees/rectification-p0-20260915` +- 基线:rebase 后 `origin/staging` @ `d3a2c48b` ## 做了什么 -任务 1:`openRectificationCase` 用 `rectificationOpenIdentity(existing, minted)`。已有会话的 `title` / `updatedAt` 全等于 `existing`;新建才 `resolveSessionTitle` + `timestamp()`。`pinned` / `archivedAt` 仍从 `existing` 继承。 +任务 1–2、4(BUG-699)此前已由 `2d7698ea` 合入 staging。本轮补任务 5 / 6 与任务 3 的 `updated_at` 回填。 -任务 1.3 `messages: []`:**无害,保持原样。** `persistSession` 的 update 分支不写 `messages`,服务端对话不受影响。校正面从 turns 渲染。 +任务 1.3 `messages: []`:**无害,保持原样。** `persistSession` 的 update 不写 `messages`,也不写 `updated_at`。 -任务 2.1:`session-open-preserves-identity.test.ts` 断言 existing 命中时 identity 全等、未命中才用 minted,并锁 hook 里 `merged` 只用 `openIdentity`。 +任务 5(BUG-704):新增迁移 `20260915010000_rectification_touch_chat_session.sql`。`agentic_rectification_cases.last_activity_at` 推进时,触发器把对应 `chat_sessions.updated_at` 推到同一时刻,且不得回拨。打开 / GET / 元数据 PATCH 不碰 `last_activity_at`,因此不 bump。`persistSession` 仍不写 `updated_at`。 -任务 2.2:同一文件扫 `frontend/src/hooks/` 与 `frontend/src/lib/` 里带 `id` + `sessionType`/`messages` 的对象字面量,禁止无条件 `updatedAt: timestamp()`(`randomUUID` 新建除外)。把 1.1 改回 `updatedAt: timestamp()` 后这两条变红,已改回,未提交破坏。 +任务 5.3 / 任务 3:运维脚本 `frontend/scripts/repair-rectification-session-titles.mjs` 同时统计并(`--apply`)修: -任务 2.3:`SessionTitleOptions.at` 与 `resolveSessionTitle` 默认 `new Date()` 处加了注释:只能用于新建。 +1. 日期标题与 `created_at` 的 Asia/Shanghai 月日不一致的校正会话(原任务 3)。 +2. `updated_at` 早于最后一条 turn / `last_activity_at` 的校正会话。 -任务 3:修回逻辑在 `rectification-session-title-repair.ts`,运维脚本 `frontend/scripts/repair-rectification-session-titles.mjs`(默认只读 `--check`,`--apply` 才写)。幂等,只动匹配正则且月日与 `created_at` 的 Asia/Shanghai 不一致的行。本机没有 `SCHEMA_DATABASE_URL`,**staging 未跑**,损坏面数字待产品在 staging 执行后回填。 +默认只读。本机没有 `SCHEMA_DATABASE_URL`,**staging 未跑**,损坏面数字待产品在 staging 执行后回填。 + +任务 6(BUG-705):`resolveBootstrapSessionSelection` 对未出现在已加载页的合法 UUID 返回 `lookup`,不再直接 `missing`。`resolveLookupBootstrap` 调 `GET /api/sessions/{id}`:200 并进列表并选中;404 才 `SESSION_MISSING_NOTICE` 并清 `?c=`;5xx 保留 URL,提示「这条对话暂时读不到,请稍后重试。」popstate 走同一条 lookup。非法 `?c=` 仍立即 missing。 + +任务 6.3 侧栏高亮:lookup 命中后 `mergeHydratedSession` 把该会话并进本地列表,`visibleSessions` 再 `sortSessions`,当前项可以高亮。不依赖无限滚动把它翻出来。无限滚动本身未改。 ## 测试数字 -| | `test(` 声明(`git grep -E '^test\(' -- frontend/tests`) | -| --- | ---: | -| 基线 `224244c0` | 3213 | -| 改后 | 3221(+4 identity,+4 title-repair) | - -定向:`session-open-preserves-identity` 4 pass;`rectification-session-title-repair` 4 pass。破坏性复核:把 `merged.updatedAt` 改回 `timestamp()` 后 identity 源码合同与跨文件时钟合同变红。 - -`tsc --noEmit` 0 错。`npm run lint` **0 error** / 120 warning(既有,未动)。 - -本机 Windows 全量 TAP 仍受 CRLF / Skill 软链 / Docker 影响,不能当作 Linux 27 的证据。 - -## 任务 1.3 - -`persistSession` update 字段是 title / theme / model_id / chart_profile_*,不含 messages,也不含 `updated_at`。结论:清空本地 `messages` 再 hydrate turns 对服务端无害,不改。 +定向见本提交的前端测试。全量 `npm test` 数字写在同分支的交付卡 PROGRESS。 ## 生产 `7b620c7a` -该 SHA 没有 `use-rectification-surface.ts`。当时 `page.tsx` 的 `merged` 用固定标题「生时校正」,**没有**墙钟日期,也**没有** `persistSession(merged)`。生产不受「标题写成今天并写库」影响。`updatedAt: timestamp()` 当时只动客户端,刷新可恢复。任务 3 **不必在生产跑**。 +该 SHA 没有 `use-rectification-surface.ts`。当时 `page.tsx` 的 `merged` 用固定标题「生时校正」,没有墙钟日期,也没有 `persistSession(merged)`。生产不受「标题写成今天并写库」影响。任务 3 **不必在生产跑**。BUG-704 / 705 的代码路径当时也不同;生产是否要跑 `updated_at` 回填由产品定,默认不必。 + +## 编号冲突 + +任务书预占 BUG-700 / 701。七政单随后写入了 BUG-700~703,星历页占了 BUG-707。本轮落地号:**BUG-704**(校正 `updated_at`)、**BUG-705**(`?c=` lookup)。交付卡单用 706 / 708 / 709。 ## 偏离 -- 任务 3 staging 核对 / 修补因本机没有库连接而未跑。脚本已落地。产品在 staging 跑:`node frontend/scripts/repair-rectification-session-titles.mjs` 看行数,确认后加 `--apply`。 -- CHANGELOG 写的是修回规则已交付;实际写库要等 staging 脚本跑完。 +- 任务 3 / 5.3 staging 核对因本机没有库连接而未跑。产品在 staging 跑:`node frontend/scripts/repair-rectification-session-titles.mjs` 看行数,确认后加 `--apply`。 - 浏览器项见 `docs/testing/rectification-open-identity-20260915.md`,不得写成通过。 diff --git a/docs/tasks/PROGRESS-rectification-tiebreak-card-loss-20260915.md b/docs/tasks/PROGRESS-rectification-tiebreak-card-loss-20260915.md new file mode 100644 index 00000000..99bc8a51 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-tiebreak-card-loss-20260915.md @@ -0,0 +1,36 @@ +# PROGRESS · 交付卡参考题入口(BUG-706 / 708 / 709,2026-09-15) + +- 执行分支:`codex/rectification-p0-20260915`(与会话列表单同一分支) +- 工作树:`.worktrees/rectification-p0-20260915` +- 基线:rebase 后 `origin/staging` @ `d3a2c48b` + +## 做了什么 + +任务 1:删除交付卡上的「再答两道参考题」按钮、`onTieBreak` prop、`.rectification-range-delivery__tie-break` CSS,以及聊天组件里的客户端 `requestTieBreak`。 + +`POST /api/rectification/cases/[caseId]/tie-break` 与 `requestTieBreakPersonality` **保留**。前置收集走 `persistNextInterviewIfIdle` / `persistNextInterviewAfterChoice` 的 `heldForTieBreak` → `tieBreakPersonalityFollowup`,不经过客户端按钮。`rangeDeliveryTieBreakEntry` 仍用作该 RPC 写入 turn 的 userMessage,所以文案保留。`tie_break_available` 字段保留,不再驱动按钮。 + +任务 2(选 2.1):`applyLiveCandidateOffer` 不再因为有未答题就剥掉 `candidateOffer`。`showSelectionCards` 也不再因此藏卡。有活题时「更像这个」置灰,并写「先答完上面这道,再选时间」。理由:用户已经走到交付,范围和三列必须仍可见;置灰采用比整卡消失有出口。 + +任务 3:`tieBreakPersonalityFollowup` 在 followup 的 `style_options` 非空时过 `vargaStyleFollowupRenderable`(`completeStyleOptions` + `canRenderYearlessChoice`,与 `buildChoiceCard` 同一套)。`persistFocusAfterChoice` 对不可渲染的 `varga_style` 直接 skipped。前端 `interviewChoiceCardUnavailable` 把没有卡的 `varga.*` 题号也当成死题,走既有 repair-exit,不画裸题。 + +任务 4:`MACHINE_VOICE_LEXICON` 与 `validateAdoptNarration` 都拦「相对支持度」。采用旁白指令改为「这两个时间按现有信息分不开」,不再授权念内部计分。yearless stop fact 改为「没有年份的分盘题按现有信息分不开」。卡上「相对可能性 %」未动。 + +## 测试数字 + +基线 `origin/staging` @ `d3a2c48b`:`test(` 3247。改后 3255(+3 lookup,+4 交付卡,+1 迁移合同)。本机 Windows 全量 TAP 仍受 CRLF / Skill 软链 / Docker 影响,不能当作 Linux 27 的证据。 + +改既有断言三栏: + +| 文件 | 原值 | 新值 | 原因 | +| --- | --- | --- | --- | +| `rectification-candidate-offer-anchor.test.ts` | 未答题时剥掉 offer | 交付消息上的 offer 留下 | BUG-706 | +| `rectification-range-delivery-20260907.test.ts` | 按钮在 available 时出现 | 卡上永远没有该按钮 | BUG-706 | +| `agent-voice-copy-contract.test.ts` | 卡源码引用 `rangeDeliveryTieBreakEntry` | 卡不再引用 | BUG-706 | +| `chat-session-url.test.ts` | 未列出的 UUID 直接 missing | `lookup` | BUG-705 | + +## 偏离 + +- 任务 3.2 服务端写焦点前的同门已做(`persistFocusAfterChoice` + followup 可渲染性)。 +- 浏览器项见 `docs/testing/rectification-tiebreak-card-loss-20260915.md`,不得写成通过。 +- 任务书预占 BUG-702~704。七政单已占 700~703,星历占 707。落地号 **706 / 708 / 709**。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 696671e7..c868870d 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -120,8 +120,8 @@ | `TASK-chat-reading-load-20260915.md` | `PROGRESS-chat-reading-load-20260915.md` | 首页主题卡下线 + 回答两层可见性 + 折叠层宽表可横滑 + 输入框 16px。内容一字不删。⚠️ BUG-630 走查路径失效 | 已验收(2 条契约测试未通过,见修复单) | `8144fca2` | | `TASK-mobile-touch-and-breakpoints-20260915.md` | `PROGRESS-mobile-touch-and-breakpoints-20260915.md` | 消息操作按钮触屏命中区 27×34、相邻仅隔 1px(BUG-695);CSS 平板上限 900px 与 `sidebarViewportForWidth` 的 1024 不一致,901–1023 是混合态(BUG-696);报告域 720/760/860 三个断点互不对齐,761–860 目录已塌、正文还是桌面(BUG-697)。含断点白名单契约测试。**串行在 chat-reading-load 之后** | 已验收 | `6c748d86`(BUG-695~697) | | `TASK-chat-reading-load-fix-20260915.md` | `PROGRESS-chat-reading-load-fix-20260915.md` | **验收修复单**:`chat-bundle-splitting-contract`(`message-markdown` 包裹数 1→2、`plainParagraphs(spoken)` 字面量已不存在)与 `chat-navigation-a11y-contract`(`window.location.assign("/login")` 4→3,建议问题入口删除带走一处)两条既有断言被本轮打红且未更新;Linux 全量 27→29。只改测试不改产品代码 | 待验收 | `codex/chat-reading-load-fix-20260915` | -| `TASK-rectification-open-retitles-session-20260915.md` | `PROGRESS-rectification-open-retitles-session-20260915.md` | **P0 复发单**:点侧栏历史校正会话,`openRectificationCase` 无条件用墙钟重算 `title` 并把 `updatedAt` 盖成此刻(同一处 `pinned`/`archivedAt` 却从 `existing` 继承了),`persistSession` 把改名写回服务端 → 12 号的会话被改成「9月15日 · 生时校正」并顶到列表最前。标题**永久损坏**、排序刷新可恢复。复发自 BUG-553(其防复发只是一句话,无测试执行,且没覆盖校正 open 路径)。含历史标题按 `created_at` 修回(BUG-699);另两条:校正答题从不推进 `chat_sessions.updated_at`,会话边用边下沉、名字是今天而位置是旧日期(BUG-700);`?c=` 只比对已加载那一页,翻不到就报「该对话不存在或已被删除」并清掉 URL(BUG-701) | 待验收 | `codex/rectification-open-retitles-session-20260915` | -| `TASK-rectification-tiebreak-card-loss-20260915.md` | — | **P0**:点交付卡上「再答两道参考题」,新写的未答题让 `applyLiveCandidateOffer` 把 `candidateOffer` 从每条消息剥掉 → 卡整条消失(BUG-702);按钮可用判据(`tieBreakPersonalityAvailable`)与题目可渲染判据(`buildChoiceCard` 的 `styleOptions.ok`)不一致 → 题目渲染成没有选项的裸题,两条路都断(BUG-703);交付旁白写「相对支持度都是 16」违反 VOICE 禁用词,且自称不再问分盘题却同时提供分盘题入口(BUG-704)。产品重申:**卡上不要这个按钮,出卡前收集、以卡结算** | 待领取 | `codex/rectification-tiebreak-card-loss-20260915` | +| `TASK-rectification-open-retitles-session-20260915.md` | `PROGRESS-rectification-open-retitles-session-20260915.md` | **P0**:打开历史校正改名(BUG-699,`2d7698ea` 已合入)+ 校正答题不 bump `updated_at`(BUG-704)+ `?c=` 不在当前页就报已删除(BUG-705)+ 标题/`updated_at` 回填脚本 | 待验收 | `codex/rectification-p0-20260915` | +| `TASK-rectification-tiebreak-card-loss-20260915.md` | `PROGRESS-rectification-tiebreak-card-loss-20260915.md` | **P0**:点卡上「再答两道参考题」交付卡消失(BUG-706);按钮亮但选项建不出变成裸题(BUG-708);旁白写「相对支持度」并与卡上入口打架(BUG-709)。卡上入口删除,出卡前收集,有活题时卡留下、采用置灰 | 待验收 | `codex/rectification-p0-20260915` | | `TASK-settings-dialog-size-and-nav-20260915.md` | — | **复发单**:设置弹窗四个分区尺寸仍随内容跳变(BUG-698,复发自 BUG-554——旧防复发只查「有没有写 height」,查不到「写了没生效」);首要嫌疑是 `.settings-modal` 的 `dvh` 没有 `vh` 回退,不支持时整条 `height` 作废退化成内容高度,需先复现确认。另按产品要求去掉分区菜单左侧强调条,并拆开与悬停共用的选中态 | 待领取 | `codex/settings-dialog-size-and-nav-20260915` | ### 个人报告 @@ -223,7 +223,7 @@ | `TASK-rectification-house-lord-gochara-research-20260913.md` | `PROGRESS-rectification-house-lord-gochara-research-20260913.md` | 研究单:宫主触发与木星/土星过运(合冲本命宫主、罗睺紧密合、年精度、用于 block 选上升)四种放宽,20 例公开 AA 离线量 block 层与 minute 层两组指标;引擎里已有宫主/功能吉凶/受控过运,只量缺的四条 | 待验收(无收益,关闭;不立实现单) | `codex/rectification-house-lord-gochara-research-20260913` | | `TASK-qizheng-native-chart-20260915.md` | `PROGRESS-qizheng-native-chart-20260915.md` | **后端单(独占 `scripts/jyotish_api_server.py`)**:vendored `stem-branch` 0.8.0(Apache-2.0)归档 + API 镜像 Node runtime + 七政适配器(计都派别与宿度坐标系参数化、boundary 按实测重写)+ 三个只读端点 `/api/qizheng`、`/api/western`、`/api/ephemeris_events`。实证三条:四柱时柱按 UTC 算(BUG-700,本轮不修不调用)、`ketuMode` 写死未暴露(BUG-701)、boundary 把空神煞与未闭合庙旺说成已生成(BUG-702)。BUG 段 700–703 | 已推 `origin/staging` `e776cf9d` | `codex/qizheng-native-chart-20260915` | -| `TASK-chart-page-20260915.md` | `PROGRESS-chart-page-20260915.md` | **前端单(独占 `app-sidebar.tsx`,同时加星盘与星历两个入口)**:P0 只读星盘页,五个 Tab(星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余),中宫排盘参数卡,三套坐标系各自标注且禁止互相换算。不扣点不调模型不出 spinner;不碰 `page.tsx`(1951/2000);**不搬 `vedic-chart-svg.tsx`**(rectification-board 也在用)。BUG 段 704–706 | 待验收 | `codex/chart-page-20260915` | +| `TASK-chart-page-20260915.md` | `PROGRESS-chart-page-20260915.md` | **前端单(独占 `app-sidebar.tsx`,同时加星盘与星历两个入口)**:P0 只读星盘页,五个 Tab(星盘 / 基础信息 / 大运 / 西洋盘 / 七政四余),中宫排盘参数卡,三套坐标系各自标注且禁止互相换算。不扣点不调模型不出 spinner;不碰 `page.tsx`(1951/2000);**不搬 `vedic-chart-svg.tsx`**(rectification-board 也在用)。任务书预占 704–706,Bug 历史未写入;校正 P0 落地占用了 704–706 / 708–709 | 待验收 | `codex/chart-page-20260915` | | `TASK-ephemeris-page-20260915.md` | `PROGRESS-ephemeris-page-20260915.md` | **前端单**:P1 星历页,今日五要素 + 当日行运(相对本命宫位)+ 未来九十天换座与停滞,底部「带这天去提问」出口。页面不得出现任何运势判断。含实证缺陷:panchanga 写死 Lahiri 与账户 Raman 分裂(关联 BUG-703;本单标注为 BUG-707)。侧边栏入口由 chart-page 单交付。BUG 段 707–709 | 待验收 | `codex/ephemeris-page-20260915` | | `TASK-api-server-decomposition-20260916.md` | `PROGRESS-api-server-decomposition-20260916.md` | **重构单(串行在 qizheng 单之后)**:把业务逻辑搬出 `JyotishAPIHandler`。核心不是行数,是全仓 3 处靠 `JyotishAPIHandler.__new__` 伪造空壳 handler 借方法(`consultation_workflow_service` ×2、`capture_report_blocked_repairs_golden`、`local_accuracy_report`,MCP 也走这条),依赖方向反了、handler 没有 `headers`/`wfile` 随时可炸。四阶段:拆 `__new__` 后门 → 抽 ≥150 行业务方法 → `do_POST`/`do_GET` 改路由表 → 重新冻结行数 baseline(余量 300→50)。纯搬运不改行为,`test_api_server_security.py` 3841 行断言一条不许改。预计 11,314 → 约 9,230 行。BUG 段 710+ | 待领取 | — | diff --git a/docs/testing/rectification-open-identity-20260915.md b/docs/testing/rectification-open-identity-20260915.md index d00bcdf2..2ea6ea58 100644 --- a/docs/testing/rectification-open-identity-20260915.md +++ b/docs/testing/rectification-open-identity-20260915.md @@ -1,6 +1,6 @@ # 真机 / 浏览器清单 · 打开历史校正不得改名(2026-09-15) -代码级合同在 `frontend/tests/session-open-preserves-identity.test.ts`。下面必须用已登录的 staging 看。截图不要带真实姓名、出生资料或会话正文。 +代码级合同在 `frontend/tests/session-open-preserves-identity.test.ts`、`session-lookup-unlisted.test.ts`。下面必须用已登录的 staging 看。截图不要带真实姓名、出生资料或会话正文。 地址:`https://staging.jyotisha.chat`。 @@ -29,6 +29,21 @@ 2. 点首页校正卡。 3. **看:** 标题和位置都不变,与 A 相同。 +## D. 校正答题推进排序(BUG-704) + +1. 打开一条不在「今天」最上面的历史校正。 +2. 答一道题或点一个选项。 +3. **看:** 它出现在「今天」最上面。 +4. 再打开同一条但不答题。**看:** 位置不再因为打开而变。 + +## E. 不在首页的 `?c=`(BUG-705) + +1. 找一条排在第 40 条之后、或因很久没动而不在当前列表里的会话。 +2. 用 `/?c=<它的 id>` 直接打开。 +3. **看:** 能进入这条对话,地址栏 `?c=` 还在,没有「该对话不存在或已被删除」。 +4. 侧栏里这条是当前项(高亮)。 +5. 用一个确认不存在的 UUID 打开。**看:** 这时才出现「该对话不存在或已被删除」,`?c=` 被清掉。 + ## 结论 > 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。 diff --git a/docs/testing/rectification-tiebreak-card-loss-20260915.md b/docs/testing/rectification-tiebreak-card-loss-20260915.md new file mode 100644 index 00000000..4f5de64c --- /dev/null +++ b/docs/testing/rectification-tiebreak-card-loss-20260915.md @@ -0,0 +1,35 @@ +# 真机 / 浏览器清单 · 范围卡上没有参考题入口(2026-09-15) + +代码级合同在 `frontend/tests/rectification-tiebreak-card-loss-20260915.test.ts`。下面必须用已登录的 staging 走完一次校正到范围卡。截图不要带真实姓名、出生资料或会话正文。 + +地址:`https://staging.jyotisha.chat`。 + +## A. 出卡前先问参考题 + +1. 新开一条生时校正,把带年月的经历问到范围卡即将出现。 +2. **看:** 若风格参考题可用,卡出来之前会先出现最多两道带 A/B/C/D 的选择题(相处方式 / 做事风格)。 +3. **看:** 这些题不是一行裸题。建不出选项时这道题根本不出现,而不是退化成打字题。 + +## B. 范围卡上没有第二条路 + +1. 走到范围卡。 +2. **看:** 卡上没有「再答两道参考题」或任何同类按钮。 +3. **看:** 三列「相对可能性 %」还在。「更像这个」还在。 + +## C. 旁白没有内部计分词 + +1. 读范围卡上面那句交付旁白。 +2. **看:** 没有「相对支持度」「概率」「置信度」。 +3. **看:** 若说分盘题不再问,卡上确实也没有再问的按钮。 + +## D. 有活题时卡还在 + +若测试环境仍能构造「范围卡已在 + 一条未答题」: + +1. **看:** 范围卡没有消失。 +2. **看:** 「更像这个」点不了,并写「先答完上面这道,再选时间」。 +3. 答完或跳过该题后,采用按钮恢复。 + +## 结论 + +> 待填。测完写日期 + 设备 + 浏览器 + 逐条正常/有问题。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 5be4e19c..22b1dea5 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -293,7 +293,7 @@ The birth-time rectification session is the consultation transcript plus a house - **Structure:** title, optional subtitle for another person's chart, current-state marker. - **States:** default, hover, current, focus, disabled. -- **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. A session title is named when it is created; opening a stored session must not rename it. `updatedAt` advances only on conversation activity. Locked by `frontend/tests/session-open-preserves-identity.test.ts`. +- **Hierarchy:** section labels stay tertiary; session titles and primary actions use ink so history rows do not collapse into the same gray as “收藏对话 / 历史对话”. History groups use the overline token for “今天 / 昨天 / 最近 7 天 / 最近 30 天 / 更早”. When the chart is not the account holder, a secondary line shows the chart name under the title. A session title is named when it is created; opening a stored session must not rename it. `updatedAt` advances only on conversation activity, including rectification turns, choice, adopt, and stop. Opening, refresh, and metadata PATCH do not bump it. A `?c=` that is not on the loaded page is looked up with `GET /api/sessions/{id}` before anyone may say it was deleted. Locked by `frontend/tests/session-open-preserves-identity.test.ts` and `frontend/tests/session-lookup-unlisted.test.ts`. - **Request behavior:** existing sessions remain selectable for reading while a request is active; creating or sending another request stays locked until the active request settles. - **Surface:** translucent warm-gray sidebar; current uses a white glass surface and deep-brown marker. @@ -478,7 +478,7 @@ or user IDs. Agent 的 live 标记只有 `InlineSpinner` 一种。曾经并存的 canvas 小球(`thinking-orbs`)已移除,不得再引入第二种 live 标记。 -校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下;采集题还没答完时不出卡。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。 +校正面的所有等待复用行内等待:进入前的 hydration 在揭幕之前完成,进入后唯一的等待形态是时间线 live 行(含「正在准备下一个问题…」这一条独立 live 行)。区间交付卡只挂在最新那条采用旁白下面,不得留在更早的采集/区分题下。有未答的采集或选择题时卡仍在,「更像这个」置灰并写「先答完上面这道,再选时间」,不得整卡消失。卡上至多三列并排,相同性格句只写一次,点「更像这个」即采用该列分钟,按钮显示「正在采用…」或「已采用」。采用过程中整张卡留在原处,不得因 `busy` 卸掉。采用后前事核对结束走 `verified_idle`:一行收尾文案跟在卡片下面、与助手列对齐,没有 live 行、没有重载、没有采用状态条。卡片与右栏细则见 §11、§12。 首页只揭幕一次。揭幕前的加载屏分两阶段:先取账户、模型目录与会话列表,再并行取今日星语与校正入口摘要,并预热校正分包;全部就绪或 4 秒预算到期(`BOOTSTRAP_PREPARE_TIMEOUT_MS`)才揭幕。揭幕后不得再出现任何阻塞等待或组件级 spinner:今日星语未到显示静态文案「今天的星语还没写出来。」(不带 `aria-busy`),校正卡用无摘要文案,内容到达后静默替换。登录后的空首页只剩问候语、今日星语卡、生时校正卡和输入框,没有主题卡。切换到消息尚未缓存的会话时消息区留白并只给 `sr-only` 文案,不转圈;揭幕后按侧栏顺序后台预取最近 5 条会话(`SESSION_PREFETCH_COUNT`)让常见切换零等待。轨道环消失后不得再换一套动效继续等。 @@ -546,7 +546,7 @@ Admin 的 antd `` 是独立设计系统,不在此表。 每列 = 时间 + 相对可能性 + 至多三行 + 「更像这个」。列内不再有 `

`。经历对照由服务端拼成一行:「8 件经历里 7 件对得上,最不合的是 2024 年 5 月那段感情」。未来窗一行:「下一个值得留意的时段:2027 年 3 月前后(事业)」;算过但没有窗写「未来一年没有明显的时段」;`by_time` 缺键写灰字「这一分钟还没对照」,不得回退成 `0 · 0 · 0` 或把「没算」说成「没有窗」。用户可见文案不得出现「强相关 / 有关联 / 弱关联」。 -卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。收尾句跟卡片同一列。 +卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。卡上没有「再答两道参考题」入口。风格参考题只在出卡前收集,出卡即结算。`tie_break_available` 仍由服务端决定要不要先问,不再驱动任何按钮。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。有活题时「更像这个」置灰。收尾句跟卡片同一列。 引擎 `event_dasha_ledger_by_time` / `prospective_windows_by_time` 的键是引擎 `candidate_times` 去重全集(上限 64),不是引擎分数前三。卡片仍只投影后验前三。若上一轮 64 分钟对照超过 3 秒,下一轮可以把推断层仍有效的分钟作为 `column_times` 传入。 diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 489de88a..add2eddb 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -34,6 +34,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | 剩下的题分不开当前候选。更站得住的范围是 05:15,代表分钟 05:15。 | 再问下去也分不出更准的时间了。眼下更站得住的是 05:15。 | 单分钟不要把范围和代表分钟念两遍;「当前候选」是内部词。 | | 可以从下面的时间里选一个采用。 / 下面的时间可以先用着。 | 我按你说的经历认真分析过了,下面是这次的结果。 | 这是认真分析后的结果,不是随便先用着;采用仍不是确认。区间交付卡必须就在这句下面。 | | 相对支持度 16 · 采用此时间 | 一行至多三列。相同性格句只写一次。每列:相对可能性、有差别的性格句、人话经历对照、下一个时段,点「更像这个」。缺数据写灰字「这一分钟还没对照」。不预标「排盘用」。 | 交付对象是区间里并排的候选分钟,不是四张卡上的支持度数字。 | +| 已答6轮,再问下去也分不开04:53和05:00,没有年份的分盘题也不再问……两者相对支持度都是16。 | 再问下去也分不开 04:53 和 05:00。这两个时间按现有信息分不开。范围和代表分钟照常说。卡上三列仍写「相对可能性 %」。 | 旁白不得念内部计分词。卡上的「相对可能性」是 09-08 定稿,不要动。 | | 候选 A 预测下一次事业变动更可能在 2027 年 3 月附近。 | 每列用服务器拼好的一行时段;没有窗就写「未来一年没有明显的时段」;没算出来写「这一分钟还没对照」。 | 窗口按候选分钟各算,模型不写。不得把「没算」说成「没有窗」。 | | 「再补一件经历」后卡片消失、输入框上方空白 | 需要补经历时继续在输入框说下一件;交付卡不再提供「再补一件经历」按钮。 | 交付卡只负责选分钟。 | | 采用后会拿盘外核对来验证。 / 已采用 04:53 · 范围 … / 改选 / 之后新建对话即按此时间排盘。 | 没有核对题:已采用 04:53,新建对话即按这个时间排盘。有核对题:前事核对到这里。之后新建对话即按已采用时间排盘;对不上随时改选。 | 采用状态在该列按钮上;整张卡留着。收尾句跟卡片同一列,不单独贴左,也不回输入框上方。 | diff --git a/frontend/scripts/repair-rectification-session-titles.mjs b/frontend/scripts/repair-rectification-session-titles.mjs index 436c3c88..bab79e96 100644 --- a/frontend/scripts/repair-rectification-session-titles.mjs +++ b/frontend/scripts/repair-rectification-session-titles.mjs @@ -2,7 +2,7 @@ import pg from "pg"; const { Client } = pg; -const MATCH_SQL = ` +const TITLE_MATCH_SQL = ` session_type = 'birth_time_rectification' and title ~ '^[0-9]{1,2}月[0-9]{1,2}日[[:space:]]*·[[:space:]]*生时校正([[:space:]]+[0-9]{2}:[0-9]{2})?$' and ( @@ -14,9 +14,9 @@ and ( ) `; -const COUNT_SQL = `select count(*)::int as n from public.chat_sessions where ${MATCH_SQL}`; +const TITLE_COUNT_SQL = `select count(*)::int as n from public.chat_sessions where ${TITLE_MATCH_SQL}`; -const APPLY_SQL = ` +const TITLE_APPLY_SQL = ` update public.chat_sessions set title = case when created_at is null then '生时校正' @@ -25,7 +25,37 @@ set title = case || to_char(created_at at time zone 'Asia/Shanghai', 'FMDD') || '日 · 生时校正' end -where ${MATCH_SQL} +where ${TITLE_MATCH_SQL} +`; + +const ACTIVITY_COUNT_SQL = ` +select count(*)::int as n +from public.chat_sessions as session +join public.agentic_rectification_cases as case_row + on case_row.session_id = session.id + and case_row.user_id = session.user_id +left join lateral ( + select max(turn.created_at) as last_turn_at + from public.agentic_rectification_turns as turn + where turn.case_id = case_row.id +) as turns on true +where session.session_type = 'birth_time_rectification' + and session.updated_at < coalesce(turns.last_turn_at, case_row.last_activity_at) +`; + +const ACTIVITY_APPLY_SQL = ` +update public.chat_sessions as session +set updated_at = coalesce(turns.last_turn_at, case_row.last_activity_at) +from public.agentic_rectification_cases as case_row +left join lateral ( + select max(turn.created_at) as last_turn_at + from public.agentic_rectification_turns as turn + where turn.case_id = case_row.id +) as turns on true +where case_row.session_id = session.id + and case_row.user_id = session.user_id + and session.session_type = 'birth_time_rectification' + and session.updated_at < coalesce(turns.last_turn_at, case_row.last_activity_at) `; function connectionString() { @@ -44,14 +74,19 @@ async function main() { const client = new Client({ connectionString: connectionString() }); await client.connect(); try { - const before = await client.query(COUNT_SQL); - const n = before.rows[0]?.n ?? 0; - console.log(`mismatched_dated_rectification_titles=${n}`); + const titlesBefore = await client.query(TITLE_COUNT_SQL); + const activityBefore = await client.query(ACTIVITY_COUNT_SQL); + console.log(`mismatched_dated_rectification_titles=${titlesBefore.rows[0]?.n ?? 0}`); + console.log(`stale_rectification_updated_at=${activityBefore.rows[0]?.n ?? 0}`); if (!apply) return; - const updated = await client.query(APPLY_SQL); - const after = await client.query(COUNT_SQL); - console.log(`updated_rows=${updated.rowCount ?? 0}`); - console.log(`mismatched_after=${after.rows[0]?.n ?? 0}`); + const titlesUpdated = await client.query(TITLE_APPLY_SQL); + const activityUpdated = await client.query(ACTIVITY_APPLY_SQL); + const titlesAfter = await client.query(TITLE_COUNT_SQL); + const activityAfter = await client.query(ACTIVITY_COUNT_SQL); + console.log(`updated_title_rows=${titlesUpdated.rowCount ?? 0}`); + console.log(`updated_activity_rows=${activityUpdated.rowCount ?? 0}`); + console.log(`mismatched_after=${titlesAfter.rows[0]?.n ?? 0}`); + console.log(`stale_updated_at_after=${activityAfter.rows[0]?.n ?? 0}`); } finally { await client.end(); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1725da75..36ed4f1d 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3326,24 +3326,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .rectification-range-delivery__accept:disabled { cursor: default; } -.rectification-range-delivery__tie-break { - justify-self: start; - min-height: 44px; - padding: 0 12px; - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - color: var(--color-ink-secondary); - background: var(--color-canvas); - font: inherit; - cursor: pointer; -} -.rectification-range-delivery__tie-break:focus-visible { - outline: 2px solid var(--color-focus); - outline-offset: 2px; -} -.rectification-range-delivery__tie-break:disabled { - cursor: default; -} .rectification-range-delivery__invite { margin: 0; color: var(--color-ink); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9a577d5a..7d6832a5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -210,6 +210,7 @@ import { LoginRedirectError, mergeHydratedSession, patchSessionModel, + resolveLookupBootstrap, payloadCode, payloadMessage, readSessions, @@ -993,12 +994,22 @@ export default function Home() { } if (controller.signal.aborted) return; - const bootstrapSelection = resolveBootstrapSessionSelection({ + const listedSelection = resolveBootstrapSessionSelection({ listedIds: nextSessions.map((session) => session.id), defaultSessionId: nextSessions[0].id, search: window.location.search, storedReturnId: readLoginSessionReturn(), }); + const lookedUp = await resolveLookupBootstrap({ + selection: listedSelection, + sessions: nextSessions, + defaultSessionId: nextSessions[0].id, + catalog: nextModelCatalog, + signal: controller.signal, + }); + if (controller.signal.aborted) return; + nextSessions = lookedUp.sessions; + const bootstrapSelection = lookedUp.selection; let landingSessionId = resolveStarterHomeLandingSessionId( nextSessions, bootstrapSelection.sessionId, @@ -1061,6 +1072,8 @@ export default function Home() { setComposerNotice("模型服务暂时不可用,当前无法发送问题。"); } else if (parsedSessions.fallbackSessionIds.length > 0) { setComposerNotice("此前选择的模型已下线,已切换为默认模型。"); + } else if (lookedUp.notice) { + setComposerNotice(lookedUp.notice); } else if (bootstrapSelection.missing) { setComposerNotice(SESSION_MISSING_NOTICE); } diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index ebadc9f1..8c9708a7 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -97,7 +97,7 @@ import { stableChoiceActionKey, type ChoiceOptionId, } from "@/lib/rectification-agentic/v9/choice-action"; -import { RECTIFICATION_USER_COPY, postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy"; +import { postAdoptVerifyDoneCopy } from "@/lib/rectification-agentic/user-copy"; import { isRectificationCaseStatus, isResumableStatus, type RectificationCaseStatus } from "@/lib/rectification-agentic/v9/case-status"; import { CHOICE_MODE, CHOICE_SKIP_QUESTION_LABEL, CHOICE_SKIP_QUESTION_MESSAGE, CHOICE_STOP_LABEL, CHOICE_STOP_MESSAGE, isPersistedFocusId, parseRectificationChoiceCard, type ChoiceKey, type RectificationChoiceCard as ChoiceCardModel } from "@/lib/rectification-agentic/v9/choice-card"; import type { PublicLanguageModel } from "@/lib/public-models"; @@ -1343,45 +1343,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } }, [acceptingCandidateId, beginLiveRun, busy, candidateResult, caseId, loadCaseSnapshot, onCompleted, onSaved, readonly, send, sessionId, setPending]); - const requestTieBreak = useCallback(async () => { - if (!candidateResult || acceptingCandidateId || busy || readonly) return; - if (!candidateResult.rangeDelivery?.tie_break_available) return; - setError(""); - setPending(true); - try { - const response = await fetch( - `/api/rectification/cases/${encodeURIComponent(caseId)}/tie-break`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - sessionId, - requestId: globalThis.crypto.randomUUID(), - }), - }, - ); - const payload = await response.json().catch(() => null); - if (payload?.code === "tie_break_unavailable") { - await loadCaseSnapshot(); - return; - } - if (!response.ok || payload?.ok !== true) { - throw new Error(payload?.error || payload?.message || "暂时无法开始参考题"); - } - await loadCaseSnapshot((turns) => { - setMessages((current) => applySnapshotTurnsToMessages( - current, - turns, - (incoming) => messagesFromTurns(incoming as readonly PersistedTurn[]), - )); - }); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "暂时无法开始参考题"); - } finally { - setPending(false); - } - }, [acceptingCandidateId, busy, candidateResult, caseId, loadCaseSnapshot, readonly, sessionId, setPending]); - async function copyMessage(message: RenderMessage) { try { await navigator.clipboard.writeText(copyTextForMessage(message.text, message.question)); @@ -1509,15 +1470,19 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const keepSelectionCardsWhileBusy = Boolean( acceptingCandidateId || candidateResult?.selectedTime, ); + const liveInterviewBlockingAdopt = Boolean( + candidateResult + && !candidateResult.selectedTime + && messages.some((message) => ( + interviewQuestionBlocksAdoptOffer(message.question, false) + )), + ); const showSelectionCards = Boolean( candidateResult && caseSnapshotLoaded && (!busy || keepSelectionCardsWhileBusy) && selectionCardMessageKey && (canOfferCards || Boolean(candidateResult.selectedTime)) - && !messages.some((message) => ( - interviewQuestionBlocksAdoptOffer(message.question, Boolean(candidateResult.selectedTime)) - )), ); const showReadonlyRange = Boolean( canShowRectificationReadonlyRange(candidateResult) @@ -1843,7 +1808,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { acceptingCandidateId={acceptingCandidateId} readonly={readonly || regeneratingMessageKey !== null || (busy && !acceptingCandidateId)} onAccept={(candidateId) => void acceptCandidate(candidateId)} - onTieBreak={() => void requestTieBreak()} + adoptLocked={liveInterviewBlockingAdopt} /> {verifiedIdleCopy && (

diff --git a/frontend/src/components/rectification-range-delivery.tsx b/frontend/src/components/rectification-range-delivery.tsx index 9adf5b6c..04449f1a 100644 --- a/frontend/src/components/rectification-range-delivery.tsx +++ b/frontend/src/components/rectification-range-delivery.tsx @@ -21,19 +21,19 @@ export function RectificationRangeDelivery({ acceptingCandidateId, readonly, onAccept, - onTieBreak, + adoptLocked, }: Readonly<{ result: RectificationCandidateResult; acceptingCandidateId: string | null; readonly: boolean; onAccept: (candidateId: string) => void; - onTieBreak?: () => void; + adoptLocked?: boolean; }>) { const delivery: RangeDeliveryProjection | null = result.rangeDelivery; const range = delivery?.range ?? result.credibleRange; const eventCount = delivery?.event_count ?? 0; const selected = result.selectedTime; - const busy = Boolean(acceptingCandidateId) || readonly; + const busy = Boolean(acceptingCandidateId) || readonly || adoptLocked; const columns = delivery?.columns ?? []; const title = range ? eventCount > 0 @@ -68,15 +68,10 @@ export function RectificationRangeDelivery({ {delivery?.provenance_line ? (

{delivery.provenance_line}

) : null} - {delivery?.tie_break_available && onTieBreak && !readonly ? ( - + {adoptLocked ? ( +

+ {RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked} +

) : null} {delivery?.tie_break_note ? (

{delivery.tie_break_note}

diff --git a/frontend/src/hooks/use-session-management.ts b/frontend/src/hooks/use-session-management.ts index 64c09428..40d4c29f 100644 --- a/frontend/src/hooks/use-session-management.ts +++ b/frontend/src/hooks/use-session-management.ts @@ -6,6 +6,7 @@ import { useRef } from "react"; import { showChatNotice as setComposerNotice } from "@/lib/chat-notice"; import { writeChatSession } from "@/lib/chat-session-write-contract"; import { + SESSION_LOOKUP_FAILED_NOTICE, SESSION_MISSING_NOTICE, parseSessionUrlQuery, writeSessionUrl, @@ -21,6 +22,7 @@ import { fetchSessionDetail, fetchSessions, LoginRedirectError, + lookupSessionById, mergeHydratedSession, patchSessionModel, readSessions, @@ -405,7 +407,7 @@ export function useSessionManagement(params: SessionManagementParams) { const query = parseSessionUrlQuery(search); const fallbackId = listed[0]?.id ?? ""; const requestedId = query.sessionId; - if (query.present && (!requestedId || !listed.some((session) => session.id === requestedId))) { + if (query.present && !requestedId) { writeSessionUrl(null, "replace"); sessionSelectionSource.current = "history"; if (fallbackId) selectSession(fallbackId); @@ -413,6 +415,29 @@ export function useSessionManagement(params: SessionManagementParams) { setComposerNotice(SESSION_MISSING_NOTICE); return; } + if (query.present && requestedId && !listed.some((session) => session.id === requestedId)) { + void lookupSessionById(requestedId, modelCatalog).then((looked) => { + if (looked.status === "found") { + setSessions((current) => mergeHydratedSession(current, looked.session)); + sessionSelectionSource.current = "history"; + selectSession(looked.session.id); + return; + } + if (looked.status === "missing") { + writeSessionUrl(null, "replace"); + sessionSelectionSource.current = "history"; + if (fallbackId) selectSession(fallbackId); + else setActiveSessionId(""); + setComposerNotice(SESSION_MISSING_NOTICE); + return; + } + setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE); + }).catch((caught) => { + if (caught instanceof LoginRedirectError) return; + setComposerNotice(SESSION_LOOKUP_FAILED_NOTICE); + }); + return; + } if (!requestedId) { if (fallbackId) { sessionSelectionSource.current = "history"; diff --git a/frontend/src/lib/chat-session-url.ts b/frontend/src/lib/chat-session-url.ts index a9d56da9..fdc70ccb 100644 --- a/frontend/src/lib/chat-session-url.ts +++ b/frontend/src/lib/chat-session-url.ts @@ -1,6 +1,7 @@ export const SESSION_URL_QUERY_KEY = "c"; export const SESSION_URL_RETURN_STORAGE_KEY = "jyotisha.session-url-return"; export const SESSION_MISSING_NOTICE = "该对话不存在或已被删除"; +export const SESSION_LOOKUP_FAILED_NOTICE = "这条对话暂时读不到,请稍后重试。"; export const SESSION_URL_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export type SessionUrlQuery = { @@ -10,7 +11,7 @@ export type SessionUrlQuery = { export type BootstrapSessionSelection = { readonly sessionId: string; - readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none"; + readonly urlAction: "keep" | "replace-selected" | "replace-clear" | "none" | "lookup"; readonly missing: boolean; readonly clearStoredReturn: boolean; }; @@ -88,6 +89,14 @@ export function resolveBootstrapSessionSelection(input: { clearStoredReturn: true, }; } + if (query.sessionId) { + return { + sessionId: query.sessionId, + urlAction: "lookup", + missing: false, + clearStoredReturn: false, + }; + } return { sessionId: input.defaultSessionId, urlAction: "replace-clear", @@ -105,10 +114,10 @@ export function resolveBootstrapSessionSelection(input: { }; } return { - sessionId: input.defaultSessionId, - urlAction: "replace-clear", - missing: true, - clearStoredReturn: true, + sessionId: input.storedReturnId, + urlAction: "lookup", + missing: false, + clearStoredReturn: false, }; } return { @@ -118,3 +127,32 @@ export function resolveBootstrapSessionSelection(input: { clearStoredReturn: false, }; } + +export function bootstrapSelectionFromLookup( + status: "found" | "missing" | "unavailable", + requestedId: string, + defaultSessionId: string, +): BootstrapSessionSelection { + if (status === "found") { + return { + sessionId: requestedId, + urlAction: "keep", + missing: false, + clearStoredReturn: true, + }; + } + if (status === "missing") { + return { + sessionId: defaultSessionId, + urlAction: "replace-clear", + missing: true, + clearStoredReturn: true, + }; + } + return { + sessionId: defaultSessionId, + urlAction: "none", + missing: false, + clearStoredReturn: false, + }; +} diff --git a/frontend/src/lib/home-bootstrap.ts b/frontend/src/lib/home-bootstrap.ts index 9e2492fd..fe6fdf03 100644 --- a/frontend/src/lib/home-bootstrap.ts +++ b/frontend/src/lib/home-bootstrap.ts @@ -18,7 +18,7 @@ export type LandingSession = Readonly<{ messages: readonly unknown[]; }>; -export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none"; +export type LandingUrlAction = "keep" | "replace-selected" | "replace-clear" | "none" | "lookup"; /** * A rectification Case is opened only when the address bar names that session @@ -43,7 +43,9 @@ export function resolveStarterHomeLandingSessionId( selectedId: string, urlAction: LandingUrlAction, ): string { - if (urlAction === "keep" || urlAction === "replace-selected") return selectedId; + if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") { + return selectedId; + } const selected = sessions.find((session) => session.id === selectedId); if (selected?.sessionType !== "birth_time_rectification") return selectedId; const emptyConsultation = sessions.find( @@ -59,7 +61,7 @@ export function starterHomeLandingNeedsConsultation( landingId: string, urlAction: LandingUrlAction, ): boolean { - if (urlAction === "keep" || urlAction === "replace-selected") return false; + if (urlAction === "keep" || urlAction === "replace-selected" || urlAction === "lookup") return false; const landing = sessions.find((session) => session.id === landingId); return !landing || landing.sessionType === "birth_time_rectification"; } diff --git a/frontend/src/lib/home-cloud-sync.ts b/frontend/src/lib/home-cloud-sync.ts index 7bbb8c7d..99b5a86e 100644 --- a/frontend/src/lib/home-cloud-sync.ts +++ b/frontend/src/lib/home-cloud-sync.ts @@ -1,5 +1,10 @@ import { writeChatSession } from "@/lib/chat-session-write-contract"; -import { persistLoginSessionReturn } from "@/lib/chat-session-url"; +import { + bootstrapSelectionFromLookup, + persistLoginSessionReturn, + SESSION_LOOKUP_FAILED_NOTICE, + type BootstrapSessionSelection, +} from "@/lib/chat-session-url"; import { parsePublicThinkingSections } from "@/lib/consultation-thinking-plan"; import { parsePublicModelCatalog, @@ -465,6 +470,68 @@ export async function fetchSessionDetail( return readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0] ?? null; } +export type SessionLookupResult = + | { status: "found"; session: ChatSession } + | { status: "missing" } + | { status: "unavailable" }; + +export async function lookupSessionById( + sessionId: string, + catalog: PublicLanguageModelCatalog | null, + signal?: AbortSignal, +): Promise { + const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + signal, + cache: "no-store", + }); + if (response.status === 401) redirectToLogin(); + if (response.status === 404) return { status: "missing" }; + const payload = await response.json().catch(() => null); + if (!response.ok) return { status: "unavailable" }; + const sessionValue = payload && typeof payload === "object" + ? (payload as { session?: unknown }).session + : null; + const session = readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0]; + if (!session) return { status: "missing" }; + return { status: "found", session }; +} + +export async function resolveLookupBootstrap(input: { + selection: BootstrapSessionSelection; + sessions: ChatSession[]; + defaultSessionId: string; + catalog: PublicLanguageModelCatalog | null; + signal?: AbortSignal; +}): Promise<{ + selection: BootstrapSessionSelection; + sessions: ChatSession[]; + notice: string | null; +}> { + if (input.selection.urlAction !== "lookup") { + return { selection: input.selection, sessions: input.sessions, notice: null }; + } + const looked = await lookupSessionById(input.selection.sessionId, input.catalog, input.signal); + if (looked.status === "found") { + return { + selection: bootstrapSelectionFromLookup("found", looked.session.id, input.defaultSessionId), + sessions: mergeHydratedSession(input.sessions, looked.session), + notice: null, + }; + } + if (looked.status === "missing") { + return { + selection: bootstrapSelectionFromLookup("missing", input.selection.sessionId, input.defaultSessionId), + sessions: input.sessions, + notice: null, + }; + } + return { + selection: bootstrapSelectionFromLookup("unavailable", input.selection.sessionId, input.defaultSessionId), + sessions: input.sessions, + notice: SESSION_LOOKUP_FAILED_NOTICE, + }; +} + export function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus { if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效"); const status = payload as Partial; diff --git a/frontend/src/lib/rectification-agentic/user-copy.ts b/frontend/src/lib/rectification-agentic/user-copy.ts index a0d7b79e..3937ed81 100644 --- a/frontend/src/lib/rectification-agentic/user-copy.ts +++ b/frontend/src/lib/rectification-agentic/user-copy.ts @@ -201,6 +201,7 @@ export const RECTIFICATION_USER_COPY = { rangeDeliveryTieBreakEntry: "再答两道参考题微调排序", rangeDeliveryTieBreakAck: "只微调排序,不改目前范围。", rangeDeliveryTieBreakUsed: "这两分钟按现有信息分不开,参考题已经用过。", + rangeDeliveryAdoptLocked: "先答完上面这道,再选时间。", verificationReportSummary: "查看验证报告", } as const; @@ -586,6 +587,7 @@ export function listUserVisibleCopy(): string[] { RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry, RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck, RECTIFICATION_USER_COPY.rangeDeliveryTieBreakUsed, + RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked, rangeDeliveryCollectClosed(2), rangeDeliveryCollectClosed(4), rangeDeliveryCollectClosed(), diff --git a/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts b/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts index 1a3fe710..8816e491 100644 --- a/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts +++ b/frontend/src/lib/rectification-agentic/v9/adopt-narration-agent.ts @@ -22,7 +22,9 @@ export const ADOPT_NARRATION_TIMEOUT_MS = 8_000; export const ADOPT_NARRATION_INSTRUCTIONS = `你只写生时校正采用卡出现时的旁白,不做决定,不改状态,不提问。 用 2 到 4 句中文对用户说清三件事:为什么这一轮不再往下问、现在给的范围和代表分钟是什么、采用之后会用哪些前事核对。 若 post_adopt_verification 为空,必须写「采用后没有还能核对的前事,之后新建对话即按此时间排盘,对不上可改选。」,不得写「会拿……核对」。 -只能使用输入事实里出现的时间、年份和相对支持度数字;输入里没有的数字一律不要写。 +只能使用输入事实里出现的时间和年份;输入里没有的数字一律不要写。 +不要写「相对支持度」「概率」「置信度」。两个时间分不开时说「这两个时间按现有信息分不开」,不要念分数。 +只有 stop_facts 里真有「不再问」时才可以那么说。 不要出现「确认」「精确」这两个词,不得再提问,不要写「我按你说的经历认真分析过了,下面是这次的结果」,不要说「当前候选」,不要说「先用着」。`; export type AdoptNarrationOutcome = diff --git a/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts b/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts index 77af3437..a50ba57b 100644 --- a/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts +++ b/frontend/src/lib/rectification-agentic/v9/adopt-narration.ts @@ -126,7 +126,7 @@ function stopFactsFromDropped( if (yearless > 0) { facts.push({ kind: "yearless_varga", - label: "没有年份的分盘题不再问", + label: "没有年份的分盘题按现有信息分不开", count: yearless, }); } @@ -277,6 +277,7 @@ export function validateAdoptNarration( return { ok: false, reason: "question" }; } if (/确认|精确/.test(trimmed)) return { ok: false, reason: "promise" }; + if (/相对支持度|概率|置信度/.test(trimmed)) return { ok: false, reason: "machine_voice" }; if ( facts.post_adopt_verification.length === 0 && /会拿[\s\S]{0,40}核对/.test(trimmed) diff --git a/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts b/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts index 9c56c562..3f42fe89 100644 --- a/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts +++ b/frontend/src/lib/rectification-agentic/v9/agent-voice-lexicon.ts @@ -26,4 +26,5 @@ export const MACHINE_VOICE_LEXICON = [ "有关联", "弱关联", "以你说的为准", + "相对支持度", ] as const; diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index 61daba0b..fcba3b68 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -73,6 +73,7 @@ import { type V9CaseDossier, } from "./tool-service"; import { CHOICE_SKIP_QUESTION_LABEL, isPersistedFocusId, isTieBreakRoundSchema, type ChoiceKey } from "./choice-card"; +import { vargaStyleFollowupRenderable } from "./probe-question-contract"; import { clusterScoreDeltas } from "./probe-explain.ts"; import { adoptDeliveryFacts, @@ -1326,6 +1327,21 @@ async function persistFocusAfterChoice(input: { followup: ReturnType["next_followup"]; askedTurnId?: string | null; }) { + const followupKind = input.followup?.choice_kind + ?? input.followup?.choice_frame?.choice_kind + ?? null; + const followupStyles = input.followup?.style_options ?? []; + if (followupKind === "varga_style" && followupStyles.length > 0 && !vargaStyleFollowupRenderable({ + choiceKind: followupKind, + styleOptions: followupStyles, + })) { + return { + status: "skipped" as const, + focus: null, + questionId: null, + prompt: null, + }; + } let persisted; try { persisted = await persistServerOwnedFocus({ diff --git a/frontend/src/lib/rectification-agentic/v9/method-followup.ts b/frontend/src/lib/rectification-agentic/v9/method-followup.ts index a9790fda..bc48a3df 100644 --- a/frontend/src/lib/rectification-agentic/v9/method-followup.ts +++ b/frontend/src/lib/rectification-agentic/v9/method-followup.ts @@ -143,6 +143,7 @@ import { isEventQualityProbe, sameYearProbeAsked, SEMANTIC_YEAR_KEY, + vargaStyleFollowupRenderable, type DroppedProbe, type ProbeStyleOption, } from "./probe-question-contract.ts"; @@ -3070,6 +3071,14 @@ export function tieBreakPersonalityFollowup( if (kind !== "varga_style") return null; const key = followup.semantic_key ?? ""; if (!key.startsWith("varga.d9") && !key.startsWith("varga.d10")) return null; + const styleOptions = followup.style_options ?? []; + if (styleOptions.length > 0 && !vargaStyleFollowupRenderable({ + choiceKind: kind, + styleOptions, + })) { + return null; + } + if (styleOptions.length === 0 && !followup.choice_frame) return null; return { ...followup, tie_break_round: true }; } diff --git a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts index 090110f2..6fce1dfe 100644 --- a/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts +++ b/frontend/src/lib/rectification-agentic/v9/probe-question-contract.ts @@ -266,6 +266,22 @@ export function canRenderYearlessChoice(input: { return !optionCopyPresupposesPeriod(input.styleOptions); } +/** Same gate as buildChoiceCard: followup exists is not enough. */ +export function vargaStyleFollowupRenderable(input: { + choiceKind?: string | null; + styleOptions?: readonly unknown[] | null; +}): boolean { + const styles = completeStyleOptions({ + choiceKind: input.choiceKind, + styleOptions: input.styleOptions, + }); + if (!styles.ok) return false; + return canRenderYearlessChoice({ + choiceKind: input.choiceKind, + styleOptions: styles.options, + }); +} + export function isRenderableProbe(input: { informationGain?: number | null; candidateIds?: readonly string[] | null; diff --git a/frontend/src/lib/rectification-agentic/v9/turn-question.ts b/frontend/src/lib/rectification-agentic/v9/turn-question.ts index 401889fb..61bef6f8 100644 --- a/frontend/src/lib/rectification-agentic/v9/turn-question.ts +++ b/frontend/src/lib/rectification-agentic/v9/turn-question.ts @@ -167,19 +167,12 @@ export function applyLiveCandidateOffer( )); } if (!input.canOffer) return [...messages]; - const liveInterview = messages.some((message) => ( - interviewQuestionBlocksAdoptOffer(message.question, false) - )); - if (liveInterview) { - return messages.map((message) => ( - message.candidateOffer ? { ...message, candidateOffer: undefined } : message - )); - } const owner = [...messages].reverse().find((message) => ( isSettledAssistant(message) && !interviewQuestionBlocksAdoptOffer(message.question, false) )); if (!owner) { + if (messages.some((message) => message.candidateOffer)) return [...messages]; return messages.map((message) => ( message.candidateOffer ? { ...message, candidateOffer: undefined } : message )); diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 23ce5117..a648cfe6 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -382,9 +382,8 @@ export function interviewChoiceCardUnavailable(input: Readonly<{ // A choice question without a GET card is a dead tap target, not a // collect-wait. The repair-exit path must take over. if (input.questionKind === "choice" && !input.hasChoiceCard) return true; - // Spoken targeted existence is the same dead end: the stem is a card - // question stored as collect_spoken. Year-stage targeted questions stay spoken. const questionId = (input.questionId ?? "").replace(/:(?:next|next2|next3)$/, ""); + if (!input.hasChoiceCard && /^varga\./.test(questionId)) return true; if ( input.questionKind === "collect_spoken" && !input.hasChoiceCard diff --git a/frontend/supabase/migrations/20260915010000_rectification_touch_chat_session.sql b/frontend/supabase/migrations/20260915010000_rectification_touch_chat_session.sql new file mode 100644 index 00000000..ad19bd04 --- /dev/null +++ b/frontend/supabase/migrations/20260915010000_rectification_touch_chat_session.sql @@ -0,0 +1,51 @@ +-- BUG-704: rectification activity must advance chat_sessions.updated_at the +-- same way append_consultation_question does. Open / GET / metadata PATCH +-- still must not bump it (BUG-553). + +begin; + +do $migration$ +begin + if current_user <> 'schema_owner' then + raise exception 'rectification_touch_chat_session_requires_schema_owner' + using errcode = '42501'; + end if; +end +$migration$; + +create or replace function public.touch_chat_session_from_rectification_case() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if tg_op = 'UPDATE' + and new.last_activity_at is not distinct from old.last_activity_at then + return new; + end if; + update public.chat_sessions + set updated_at = new.last_activity_at + where id = new.session_id + and user_id = new.user_id + and session_type = 'birth_time_rectification' + and updated_at is distinct from new.last_activity_at + and updated_at < new.last_activity_at; + return new; +end; +$$; + +drop trigger if exists agentic_rectification_cases_touch_chat_session + on public.agentic_rectification_cases; + +create trigger agentic_rectification_cases_touch_chat_session +after update of last_activity_at on public.agentic_rectification_cases +for each row +execute function public.touch_chat_session_from_rectification_case(); + +revoke all on function public.touch_chat_session_from_rectification_case() + from public, anon, authenticated; +grant execute on function public.touch_chat_session_from_rectification_case() + to schema_owner; + +commit; diff --git a/frontend/tests/agent-voice-copy-contract.test.ts b/frontend/tests/agent-voice-copy-contract.test.ts index c2987a74..66c88bd9 100644 --- a/frontend/tests/agent-voice-copy-contract.test.ts +++ b/frontend/tests/agent-voice-copy-contract.test.ts @@ -344,8 +344,13 @@ test("range delivery copy bans 这次给出 / 最终 and titles 目前范围", ( assert.match(card, /rangeDeliveryTitle/); assert.doesNotMatch(card, /这次给出|最终/); assert.match(card, /narrow_hint/); - assert.match(card, /rangeDeliveryTieBreakEntry/); + // 原值: 卡源码引用 rangeDeliveryTieBreakEntry + // 新值: 卡上不再引用该入口;文案仍留给出卡前的服务端 turn + // 原因: BUG-706 + assert.doesNotMatch(card, /rangeDeliveryTieBreakEntry/); + assert.doesNotMatch(card, /onTieBreak/); assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rangeDeliveryTieBreakEntry)); + assert.ok(MACHINE_VOICE_LEXICON.includes("相对支持度")); assert.ok(listUserVisibleCopy().includes(RECTIFICATION_USER_COPY.rangeDeliveryTieBreakAck)); assert.doesNotMatch(listUserVisibleCopy().join("\n"), SPLIT_ENDPOINT_PHRASE); const pool = readFileSync( diff --git a/frontend/tests/chat-session-url.test.ts b/frontend/tests/chat-session-url.test.ts index f1125119..83df39a0 100644 --- a/frontend/tests/chat-session-url.test.ts +++ b/frontend/tests/chat-session-url.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import test from "node:test"; import { + SESSION_LOOKUP_FAILED_NOTICE, SESSION_MISSING_NOTICE, SESSION_URL_QUERY_KEY, SESSION_URL_RETURN_STORAGE_KEY, @@ -83,6 +84,9 @@ test("bootstrap clears an illegal or unknown session query", () => { clearStoredReturn: true, }, ); + // 原值: 未出现在已加载页的合法 UUID 直接 missing + replace-clear + // 新值: urlAction lookup,先问服务端 + // 原因: BUG-705,分页里找不到不等于已删除 assert.deepEqual( resolveBootstrapSessionSelection({ listedIds: [sessionA], @@ -91,12 +95,13 @@ test("bootstrap clears an illegal or unknown session query", () => { storedReturnId: null, }), { - sessionId: sessionA, - urlAction: "replace-clear", - missing: true, - clearStoredReturn: true, + sessionId: sessionB, + urlAction: "lookup", + missing: false, + clearStoredReturn: false, }, ); + assert.equal(SESSION_LOOKUP_FAILED_NOTICE.includes("暂时读不到"), true); }); test("default bootstrap selection does not write a session URL", () => { @@ -122,6 +127,7 @@ test("default bootstrap selection does not write a session URL", () => { assert.match(bootstrap, /urlAction === "replace-selected"/); assert.doesNotMatch(bootstrap, /writeSessionUrl\([^)]*, "push"\)/); assert.match(bootstrap, /setComposerNotice\(SESSION_MISSING_NOTICE\)/); + assert.match(bootstrap, /resolveLookupBootstrap/); }); test("session href keeps sibling query keys and can drop a dead c", () => { diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 0e7d5043..f9ddd7bd 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -93,6 +93,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260901010000_append_consultation_question\.sql/); assert.match(migration.stdout, /applied 20260901020000_chat_session_pin_archive\.sql/); assert.match(migration.stdout, /applied 20260905010000_personal_report_longform_appendices\.sql/); + assert.match(migration.stdout, /applied 20260915010000_rectification_touch_chat_session\.sql/); assert.equal( existsSync(fileURLToPath(new URL("../db/migrations/20260901020000_chat_session_pin_archive.sql", import.meta.url))), false, diff --git a/frontend/tests/rectification-candidate-offer-anchor.test.ts b/frontend/tests/rectification-candidate-offer-anchor.test.ts index af8ee7c3..1599324e 100644 --- a/frontend/tests/rectification-candidate-offer-anchor.test.ts +++ b/frontend/tests/rectification-candidate-offer-anchor.test.ts @@ -75,18 +75,28 @@ test("live offer moves off a collect message once the adopt narration exists", ( assert.deepEqual(next[2]?.candidateOffer, { resultId: "result-1" }); }); -test("live offer stays hidden while an unanswered collect question is still on screen", () => { +test("live offer stays on the delivery message while an unanswered question is on screen", () => { const next = applyLiveCandidateOffer([ + { + renderKey: "delivery", + role: "assistant", + state: "settled", + text: "我按你说的经历认真分析过了,下面是这次的结果。", + candidateOffer: { resultId: "result-1" }, + }, { renderKey: "collect", role: "assistant", state: "settled", - text: "工作上呢,还记得哪年入职、换工作,或职责一下子变重吗?", + text: "亲密关系里,你更接近哪一种相处方式?", question: collect("active"), - candidateOffer: { resultId: "result-1" }, }, ], { resultId: "result-1", canOffer: true, adopted: false }); - assert.equal(next[0]?.candidateOffer, undefined); + // 原值: liveInterview 时剥掉每一条 candidateOffer,交付卡消失 + // 新值: 交付消息上的 offer 留下,只抑制采用动作 + // 原因: BUG-706,有活题时卡必须还在 + assert.deepEqual(next[0]?.candidateOffer, { resultId: "result-1" }); + assert.equal(next[1]?.candidateOffer, undefined); }); test("after adopt the offer stays on the original message", () => { diff --git a/frontend/tests/rectification-range-delivery-20260907.test.ts b/frontend/tests/rectification-range-delivery-20260907.test.ts index 505e0f84..0ecbbd85 100644 --- a/frontend/tests/rectification-range-delivery-20260907.test.ts +++ b/frontend/tests/rectification-range-delivery-20260907.test.ts @@ -261,7 +261,7 @@ test("range-delivery DOM is one button per row and adopted is aria-pressed", () assert.match(source, /aria-pressed=\{adopted\}/); }); -test("range-delivery tie-break entry is absent until available and wired", () => { +test("range-delivery never shows a post-card tie-break button", () => { const delivery = buildRangeDelivery({ inference: inference({ probes: [d9Probe()] }), publicCandidates, @@ -269,30 +269,26 @@ test("range-delivery tie-break entry is absent until available and wired", () => representativeTime: "04:50", tieBreakAvailable: true, }); - const hidden = renderToStaticMarkup(createElement(RectificationRangeDelivery, { - result: deliveryResult(delivery, "04:50"), - acceptingCandidateId: null, - readonly: false, - onAccept: () => undefined, - })); - assert.doesNotMatch(hidden, /再答两道参考题微调排序/); const shown = renderToStaticMarkup(createElement(RectificationRangeDelivery, { result: deliveryResult({ ...delivery, tie_break_available: true }, "04:50"), acceptingCandidateId: null, readonly: false, onAccept: () => undefined, - onTieBreak: () => undefined, })); - assert.match(shown, /再答两道参考题微调排序/); - assert.equal([...shown.matchAll(/ undefined, - onTieBreak: () => undefined, + adoptLocked: true, })); - assert.doesNotMatch(skipped, /再答两道参考题微调排序/); + assert.match(locked, /先答完上面这道,再选时间/); }); test("the range-delivery component does not render relative support or the old two actions", () => { diff --git a/frontend/tests/rectification-tie-break-entry-20260913.test.ts b/frontend/tests/rectification-tie-break-entry-20260913.test.ts index 720cf014..b6b7054f 100644 --- a/frontend/tests/rectification-tie-break-entry-20260913.test.ts +++ b/frontend/tests/rectification-tie-break-entry-20260913.test.ts @@ -169,13 +169,16 @@ test("click path does not surface 409 copy; round continue is opt-in only", () = const answer = readFileSync(new URL("../src/lib/rectification-agentic/v9/answer-choice.ts", import.meta.url), "utf8"); const panel = readFileSync(new URL("../src/lib/rectification-agentic/v9/divergence-panel.ts", import.meta.url), "utf8"); const pool = readFileSync(new URL("../src/lib/rectification-agentic/v9/collection-question-pool.ts", import.meta.url), "utf8"); - assert.match(chat, /payload\?\.code === "tie_break_unavailable"/); - assert.match(chat, /await loadCaseSnapshot\(\);\s*\n\s*return;/); - const unavailableBranch = chat.slice( - chat.indexOf('payload?.code === "tie_break_unavailable"'), - chat.indexOf("暂时无法开始参考题"), + const route = readFileSync( + new URL("../src/app/api/rectification/cases/[caseId]/tie-break/route.ts", import.meta.url), + "utf8", ); - assert.doesNotMatch(unavailableBranch, /setError/); + // 原值: 客户端 requestTieBreak 把 tie_break_unavailable 当静默刷新,不 setError + // 新值: 卡上入口已删,客户端不再有这条 click path;409 口径留在 API + // 原因: BUG-706 + assert.doesNotMatch(chat, /requestTieBreak/); + assert.doesNotMatch(chat, /tie_break_unavailable/); + assert.match(route, /tie_break_unavailable/); assert.match(answer, /continueTieBreakRound\?: boolean/); assert.match(answer, /continueTieBreakRound === true/); assert.match(answer, /isTieBreakRoundSchema/); diff --git a/frontend/tests/rectification-tiebreak-card-loss-20260915.test.ts b/frontend/tests/rectification-tiebreak-card-loss-20260915.test.ts new file mode 100644 index 00000000..66a179e2 --- /dev/null +++ b/frontend/tests/rectification-tiebreak-card-loss-20260915.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { vargaStyleFollowupRenderable } from "../src/lib/rectification-agentic/v9/probe-question-contract.ts"; +import { interviewChoiceCardUnavailable } from "../src/lib/rectification-surface-state.ts"; +import { MACHINE_VOICE_LEXICON } from "../src/lib/rectification-agentic/v9/agent-voice-lexicon.ts"; +import { validateAdoptNarration, type AdoptDeliveryFacts } from "../src/lib/rectification-agentic/v9/adopt-narration.ts"; +import { RECTIFICATION_USER_COPY } from "../src/lib/rectification-agentic/user-copy.ts"; + +const chat = readFileSync( + new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), + "utf8", +); +const card = readFileSync( + new URL("../src/components/rectification-range-delivery.tsx", import.meta.url), + "utf8", +); +const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); +const followup = readFileSync( + new URL("../src/lib/rectification-agentic/v9/method-followup.ts", import.meta.url), + "utf8", +); + +const facts: AdoptDeliveryFacts = { + precision_stage: "ready_to_adopt", + already_accepted: false, + credible_range: ["04:48", "05:07"], + representative_minute: "04:53", + runner_up_minute: "05:00", + opening_window: ["04:45", "05:15"], + active_candidates: [ + { time: "04:53", relative_support: 16 }, + { time: "05:00", relative_support: 16 }, + ], + answered_rounds: 6, + stop_facts: [{ kind: "indistinguishable", label: "再问下去也分不开 04:53 和 05:00", count: 1 }], + post_adopt_verification: [], + minutes: ["04:48", "05:07", "04:53", "05:00", "04:45", "05:15"], + years: [], +}; + +test("the delivery card no longer renders a post-card tie-break entry", () => { + assert.doesNotMatch(card, /onTieBreak/); + assert.doesNotMatch(card, /rangeDeliveryTieBreakEntry/); + assert.doesNotMatch(chat, /onTieBreak/); + assert.doesNotMatch(chat, /requestTieBreak/); + assert.doesNotMatch(css, /rectification-range-delivery__tie-break/); + assert.match(chat, /adoptLocked=\{liveInterviewBlockingAdopt\}/); + assert.match(chat, /liveInterviewBlockingAdopt/); + assert.doesNotMatch( + chat.slice(chat.indexOf("const showSelectionCards"), chat.indexOf("const showReadonlyRange")), + /interviewQuestionBlocksAdoptOffer/, + ); +}); + +test("tie-break availability reuses the choice-card renderability gate", () => { + assert.match(followup, /vargaStyleFollowupRenderable/); + assert.equal( + vargaStyleFollowupRenderable({ + choiceKind: "varga_style", + styleOptions: [ + { label: "相处里更在意照顾对方的感受", answer_class: "yes", sign: "巨蟹座" }, + { label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes", sign: "狮子座" }, + ], + }), + true, + ); + assert.equal( + vargaStyleFollowupRenderable({ + choiceKind: "varga_style", + styleOptions: [ + { label: "相处里更在意照顾对方的感受", answer_class: "yes" }, + { label: "习惯带头,也不排斥站到台前", answer_class: "weak_yes" }, + ], + }), + false, + ); +}); + +test("a varga_style focus without a choice card is a dead question, not a spoken stem", () => { + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "choice", + hasChoiceCard: false, + questionId: "varga.d9.cancer/leo", + }), + true, + ); + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "collect_spoken", + hasChoiceCard: false, + questionId: "varga.d9.cancer/leo", + }), + true, + ); + assert.equal( + interviewChoiceCardUnavailable({ + questionKind: "choice", + hasChoiceCard: true, + questionId: "varga.d9.cancer/leo", + }), + false, + ); +}); + +test("adopt narration may not speak 相对支持度 even when the number is in facts", () => { + assert.ok(MACHINE_VOICE_LEXICON.includes("相对支持度")); + assert.equal(validateAdoptNarration("相对支持度都是 16。", facts).ok, false); + assert.equal(validateAdoptNarration("这两个时间按现有信息分不开。", facts).ok, true); + assert.equal(RECTIFICATION_USER_COPY.rangeDeliveryAdoptLocked, "先答完上面这道,再选时间。"); +}); diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts index 3b83f73f..7c038343 100644 --- a/frontend/tests/rectification-v9-migration.test.ts +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -1547,3 +1547,22 @@ test("host-fallback phase migration is forward-only and does not rewrite the fro /answer\.host_fallback/, ); }); + +test("rectification activity touches chat_sessions.updated_at without a metadata PATCH", () => { + const filename = "20260915010000_rectification_touch_chat_session.sql"; + const touch = readFileSync( + new URL(`../supabase/migrations/${filename}`, import.meta.url), + "utf8", + ); + assert.match(touch, /^begin;[\s\S]*^commit;$/m); + assert.equal( + existsSync(fileURLToPath(new URL(`../db/migrations/${filename}`, import.meta.url))), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); + assert.match(touch, /rectification_touch_chat_session_requires_schema_owner/); + assert.match(touch, /touch_chat_session_from_rectification_case/); + assert.match(touch, /after update of last_activity_at/); + assert.match(touch, /session_type = 'birth_time_rectification'/); + assert.doesNotMatch(touch, /persistSession/); +}); diff --git a/frontend/tests/session-lookup-unlisted.test.ts b/frontend/tests/session-lookup-unlisted.test.ts new file mode 100644 index 00000000..9a518ca2 --- /dev/null +++ b/frontend/tests/session-lookup-unlisted.test.ts @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + SESSION_LOOKUP_FAILED_NOTICE, + SESSION_MISSING_NOTICE, + SESSION_URL_QUERY_KEY, + bootstrapSelectionFromLookup, + resolveBootstrapSessionSelection, +} from "../src/lib/chat-session-url.ts"; +import { resolveStarterHomeLandingSessionId, starterHomeLandingNeedsConsultation } from "../src/lib/home-bootstrap.ts"; + +const sessionA = "11111111-1111-4111-8111-111111111111"; +const sessionB = "22222222-2222-4222-8222-222222222222"; + +test("an unlisted ?c= UUID must look up the server instead of announcing deletion", () => { + const selection = resolveBootstrapSessionSelection({ + listedIds: [sessionA], + defaultSessionId: sessionA, + search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`, + storedReturnId: null, + }); + assert.equal(selection.urlAction, "lookup"); + assert.equal(selection.missing, false); + assert.equal(selection.sessionId, sessionB); + assert.equal( + resolveStarterHomeLandingSessionId( + [{ id: sessionA, sessionType: "consultation", messages: [] }], + sessionB, + "lookup", + ), + sessionB, + ); + assert.equal( + starterHomeLandingNeedsConsultation( + [{ id: sessionA, sessionType: "consultation", messages: [] }], + sessionB, + "lookup", + ), + false, + ); +}); + +test("lookup results: found keeps URL, 404 deletes, 5xx keeps URL", () => { + assert.deepEqual(bootstrapSelectionFromLookup("found", sessionB, sessionA), { + sessionId: sessionB, + urlAction: "keep", + missing: false, + clearStoredReturn: true, + }); + assert.deepEqual(bootstrapSelectionFromLookup("missing", sessionB, sessionA), { + sessionId: sessionA, + urlAction: "replace-clear", + missing: true, + clearStoredReturn: true, + }); + assert.deepEqual(bootstrapSelectionFromLookup("unavailable", sessionB, sessionA), { + sessionId: sessionA, + urlAction: "none", + missing: false, + clearStoredReturn: false, + }); + assert.notEqual(SESSION_LOOKUP_FAILED_NOTICE, SESSION_MISSING_NOTICE); +}); + +test("cloud lookup and session management call the server before clearing ?c=", () => { + const cloud = readFileSync(new URL("../src/lib/home-cloud-sync.ts", import.meta.url), "utf8"); + assert.match(cloud, /export async function lookupSessionById/); + assert.match(cloud, /status === 404/); + assert.match(cloud, /bootstrapSelectionFromLookup\("found"/); + assert.match(cloud, /bootstrapSelectionFromLookup\("missing"/); + assert.match(cloud, /bootstrapSelectionFromLookup\("unavailable"/); + const management = readFileSync( + new URL("../src/hooks/use-session-management.ts", import.meta.url), + "utf8", + ); + const pop = management.slice( + management.indexOf("applySessionPopStateRef.current ="), + management.indexOf("async function selectSessionModel"), + ); + assert.match(pop, /lookupSessionById/); + assert.match(pop, /SESSION_LOOKUP_FAILED_NOTICE/); + assert.match(pop, /status === "missing"/); + const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + assert.match(page, /resolveLookupBootstrap/); +});