From 88fe67df9b9fdd8a45c16a32b9f39467e64309e8 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Fri, 25 Sep 2026 01:56:57 +0800 Subject: [PATCH] fix(sessions): title rectification chats with the result time --- CHANGELOG.md | 5 + docs/BUG_HISTORY.md | 16 + ...ification-session-title-result-20260922.md | 55 +++ docs/tasks/README.md | 2 +- frontend/DESIGN.md | 2 +- frontend/docs/VOICE.md | 2 +- .../repair-rectification-session-titles.mjs | 12 +- frontend/src/lib/agent-reply.ts | 4 +- .../lib/rectification-session-title-repair.ts | 43 --- frontend/src/lib/session-title.ts | 5 +- ...000_rectification_session_title_result.sql | 116 +++++++ frontend/tests/agent-reply.test.ts | 9 +- ...rectification-session-title-result.test.ts | 323 ++++++++++++++++++ ...rectification-session-title-parity.test.ts | 47 +++ ...rectification-session-title-repair.test.ts | 60 ---- frontend/tests/session-list-filter.test.ts | 5 +- frontend/tests/session-title-agent.test.ts | 4 + 17 files changed, 592 insertions(+), 118 deletions(-) create mode 100644 docs/tasks/PROGRESS-rectification-session-title-result-20260922.md delete mode 100644 frontend/src/lib/rectification-session-title-repair.ts create mode 100644 frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql create mode 100644 frontend/tests/database-rectification-session-title-result.test.ts create mode 100644 frontend/tests/rectification-session-title-parity.test.ts delete mode 100644 frontend/tests/rectification-session-title-repair.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8860963f..98c688da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-25 — 校正会话标题改为结果时间 + +- 历史校正会话的名字改成显示算出来的出生时间或当前范围,不再在标题里重复日期。 +- 今日节奏仍带日期。手改过的名字不动。Skill 版本不 bump。 + ## 2026-09-24 — 报告操作收敛与桌面导出居中(本地未提交,依赖验收 blocked) - 报告列表已完成行只保留「查看报告」;阅读页仅保留返回和导出图标,不再自动消费旧 `#export` 链接。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 35a1bc63..24508e87 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13603,3 +13603,19 @@ - 相关记录:BUG-999/1003;TASK-report-chapter-export-fix-20260924.md。 - 复发自:BUG-999同输出边界的未覆盖换行路径,继承剥离器缺陷,非本轮新增回归。 - 修复版本:本地待提交。 + +## BUG-1001 | 校正会话标题仍写日期,同一天多条无法区分 + +- 状态:resolved +- 首次发现:2026-09-22 +- 最近更新:2026-09-25 +- 影响面:侧栏校正会话标题、`resolveSessionTitle`、`agentic_rectification_cases` 的结果列、存量 `chat_sessions.title` +- 用户现象:副标题已经是最后活动时间,标题仍印日期,同一天多条校正完全同名;旧标题里的钟点是创建时间,和副标题对不上。 +- 触发条件:侧栏列出多条生时校正;或校正结果(已确认、已采用、当前范围)变化后标题仍是日期。 +- 根因:复发自 BUG-929。BUG-988 把副标题改成最后活动时间之后,标题继续承载日期,同一行两列是同一个事实,而且日期不够区分同日多条。BUG-929 当时决定旧标题不批量改。 +- 修复:标题算式只有 `public.rectification_session_title`。已确认优先于已采用,写 `生时校正 · HH:MM`;否则起止都通过 `agentic_rectification_is_clock` 时写 `生时校正 · HH:MM–HH:MM`(U+2013);否则 `生时校正`。触发器 `agentic_rectification_cases_title_from_result` 只挂在 `accepted_time`、`confirmed_time`、`candidate_range`。回填与触发器共用 `rectification_session_title_is_automatic`,只覆盖自动派生标题。不写 `updated_at`,不改 pinned、messages。今日节奏仍带日期。open 路径未改。 +- 验证:`database-rectification-session-title-result.test.ts` 在本机真实 Postgres 通过:四分支、`provolatile = i`、采用分钟与区间收窄不改 `updated_at`、手改「我的校正」不被覆盖、touch 触发器仍推进活动时间、从迁移文件切出的回填改写前五条、第六条不动、六行 `updated_at` / `pinned` / `messages` 逐字节不变、重跑 0 行。跨层对断测试读迁移里的正则,与 `isAutoDerivedSessionTitle` 同组输入答案相同;把该正则的间隔号改成连字符后测试转红,已撤回。 +- 防复发:标题算式只能有一处实现,运行时与回填共用。侧栏同一行的两列不得承载同一个事实。 +- 相关记录:BUG-929、BUG-699、BUG-704、BUG-988、BUG-987、BUG-992、BUG-995 +- 复发自:BUG-929(标题口径两次返工;「旧标题不批量改」被本单推翻) +- 修复版本:本分支提交,尚未推送、尚未部署 diff --git a/docs/tasks/PROGRESS-rectification-session-title-result-20260922.md b/docs/tasks/PROGRESS-rectification-session-title-result-20260922.md new file mode 100644 index 00000000..2f680239 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-session-title-result-20260922.md @@ -0,0 +1,55 @@ +# PROGRESS 校正会话标题改为结果时间(2026-09-25) + +- 任务书:`docs/tasks/TASK-rectification-session-title-result-20260922.md` +- 分支:`codex/rectification-session-title-result-20260922` +- 基线:`514951d7`(开工时 `origin/staging`) +- 状态:实现完成,待验收。未 push。 + +## 做了什么 + +- 新增 `frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql`。没有抄到 `frontend/db/migrations`(校正迁移只在 supabase,runner 会扫该目录)。 +- 标题算式只在 `public.rectification_session_title(time, time, jsonb)`。已确认优先于已采用;范围原样拼接,中间是 U+2013 EN DASH;否则 `生时校正`。`to_char(..., 'HH24:MI')`。`immutable` / `parallel safe` / `search_path = ''`,不读表。 +- 自动派生判据只在 `public.rectification_session_title_is_automatic(text)`。触发器与回填都调它。 +- 触发器 `agentic_rectification_cases_title_from_result`,函数 `public.sync_rectification_session_title_from_case()`。只挂 `accepted_time`、`confirmed_time`、`candidate_range`。不写 `updated_at`。没有改 `touch_chat_session_from_rectification_case`。 +- 回填 update 用同一对函数。测试从迁移文件的标记之间切出该语句执行,没有手抄。 +- `resolveSessionTitle` 对校正入口和校正交接问句返回 `生时校正`。`datedSessionTitle` 只剩今日节奏调用者,注释写明。 +- `DATED_ENTRY_TITLE` 扩到三种自动派生形态,并认得今日节奏的日期形态。连字符范围不是自动派生。 +- 删了 `frontend/src/lib/rectification-session-title-repair.ts` 和 `frontend/tests/rectification-session-title-repair.test.ts`。它们只服务已被本单取代的「按创建日把标题改回日期」助手,生产代码没有引用。历史脚本 `frontend/scripts/repair-rectification-session-titles.mjs` 仍被 `rectification-session-title-repair-migration.test.ts` 用来锁 `20260916020000` 的逐字合同,`--apply` 本来就会报错,没有改成新口径,也没有改历史迁移。脚本头注明不得执行 `TITLE_APPLY_SQL`。 +- open 路径一个字没动(D4 / BUG-699)。 + +## 断言三栏 + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| `resolveSessionTitle` 校正入口 `生时校正 · 8月22日` | `生时校正` | BUG-1001:新建不带日期,结果由触发器写;今日节奏仍是 `今日节奏 · 8月22日` | +| `session-list-filter` 夹具标题 `生时校正 · 9月14日` | `生时校正 · 05:07` | 例子改成结果时间。该断言只锁是否入列,不锁标题格式 | +| `shouldGenerateSessionTitle("生时校正", consultation)` 为 true(经 generic 前缀,未单测) | false | `DATED_ENTRY_TITLE` 现在认得裸「生时校正」和结果时间,模型不得覆盖自动标题。校正会话仍恒 false | + +删掉的旧修补测试(任务书允许,T5 已取代):`dated titles use the product calendar, not UTC`、`repair rewrites a wall-clock today stamp back to created_at's local date`、`repair leaves matching dates and user-renamed titles alone`、`missing created_at falls back to a dateless title instead of guessing`。 + +## T4 故意改红 + +把迁移里 `rectification_session_title_is_automatic` 正则的范围间隔号 U+2013 改成连字符 `-`(一个字符)。`rectification-session-title-parity.test.ts` 转红(`not ok`,正则不再含 EN DASH)。已撤回,复跑 `ok`。未提交该改动。 + +## 验证 + +| 项 | 结果 | +| --- | --- | +| `npx tsc --noEmit` | 0 错 | +| `npm run lint` | 0 error,121 warning。本单改动的文件没有新增 warning。121 是当前树上的既有数,超过任务书 119 的上限,按「不修无关 warning」没有动 | +| 定向:`agent-reply`、`session-title-agent`、`rectification-session-title-parity`、`rectification-session-title-repair-migration`、`session-list-filter` | 23/23 通过 | +| `database-rectification-session-title-result.test.ts` | 真实 Postgres 1/1 通过(约 30s)。四分支、`provolatile=i`、采用/收窄不改 `updated_at`、手改名不覆盖、touch 与标题触发器互不干扰、回填 5 行、重跑 0 行、六行 `updated_at`/`pinned`/`messages` 不变。套在 `npm run test:db` 里仍是 `ok 41` | +| `npm run test:db` | 跑完,exit 1。48 条里 13 过、35 红。红的是既有 Windows 缺口:双目录扫描 `duplicate migration filename: 20260814025000_personal_report_document_v2.sql`,以及 backup 测试的 WSL `execvpe(/bin/bash)`。不是本单 SQL。地址池没有耗尽 | +| 全量 `npm test` | 未跑。这台 Windows 有已知 Docker/EPERM 基线失败,定向已绿 | +| `npm run build` | 失败。Turbopack 报 worktree `node_modules` junction 指向文件系统根之外。`npx next build --webpack` 编译成功(69s),收集页面时在 `/api/daily-starlanguage` 与 `/api/rectification/agent` 因 Skill symlink EPERM 中断,没有打出路由表。`/` 是否仍 Static、首屏 gzip 本机测不到。这是既有 Windows 缺口,不是本单源码错误 | +| Python `tests/test_daily_and_rectification_entrypoints.py` | 未跑。`G:\Ferti\Jyotisha\.venv\Scripts\python.exe` 不存在,没有装依赖。开工已看过该文件,只断言裸字符串「生时校正」,新格式仍满足 | + +Windows 上 `frontend/db/migrations` 里 4 个 Git symlink 被检出成相对路径文本,标准双目录扫描报 `duplicate migration filename: 20260814025000_personal_report_document_v2.sql`。定向数据库测试先走标准 runner;只有撞上这个既有错误时,才把真正的 SQL 逐字节抄到临时目录(跳过占位文本,重复真文件必须一致),再用 `MIGRATIONS_DIRECTORY` 跑同一个 `scripts/db-migrate.mjs`。Linux 门禁 symlink 不是普通文件,第一条路径就会成功。没有改 runner。 + +## 未改 / 未验证 + +- 没有 push,没有改 workflow,没有动 `page.tsx`、咨询 status 路由、SVG height。 +- open 之后客户端仍会 `persistSession` 写本地 minted 标题「生时校正」。任务书要求 open 路径不动。插入触发器先写成范围标题,这次 PATCH 可能把它写回「生时校正」,直到下一次 `accepted_time` / `confirmed_time` / `candidate_range` 变化再由触发器写回。刷新后的列表读的是库里的标题;已加载列表再打开时,本地标题与库一致,不会改回去。 +- 连字符范围 `生时校正 · 05:00-05:15`:SQL 不覆盖。`isAutoDerivedSessionTitle` 仍可能为 true,因为 `isGenericSessionTitle` 的「生时校正」前缀比本单三种形态更宽。对断表没有把这一条算进「必须相同」的那一组。`DATED_ENTRY_TITLE` 不认连字符。 +- 首屏 gzip 基线未测到。`npm run build` 在 junction 上 Turbopack panic;`next build --webpack` 编译过了,但页面收集被 Skill symlink EPERM 打断,没有路由表,不能宣称 `/` 仍是 Static。 +- 浏览器走查未做。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 193715bc..2c7852d4 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -33,7 +33,7 @@ | 任务书 | 进度 | 主题 | 状态 | 落点 | | --- | --- | --- | --- | --- | -| `TASK-rectification-session-title-result-20260922.md` | — | **校正会话标题改写结果(BUG-1001)**:BUG-988 把副标题改成最后活动时间后,标题里的日期成了重复;而 BUG-929 删掉 `uniquifySessionTitle` 之后,`resolveSessionTitle` 对校正入口只返回 `生时校正 · M月D日`,**同一天多条标题完全相同**(真机截图:9/17 三条同名、9/16 两条同名、9/14 一天 5 条);旧标题的钟点后缀是创建时间、副标题是最后活动时间,同一行两个对不上的时间。**产品 09-22 拍板**:D1 标题改为承载结果——有 `accepted_time`/`confirmed_time` 写 `生时校正 · HH:MM`,否则写 `candidate_range` 的 `生时校正 · HH:MM–HH:MM`(推翻 BUG-929 的日期口径,但「不得再加墙钟去重后缀」保留);D2 存量批量重算(推翻 BUG-929「旧标题不批量改」,先例 `20260916020000_rectification_session_title_repair.sql`);D3 今日节奏保留日期(我判定的例外);D4 只在结果变化时改 title,`open` 路径不动(对 BUG-699 边界的有限扩展)。**红线**:任何写标题的路径都不得 bump `updated_at`(否则 13 条历史会话集体跳顶、毁掉 BUG-988);标题算式必须只有一处实现(一个 SQL 函数,触发器与回填共用)——BUG-987/992 都栽在「两层规则各写一份」。T4 跨层对断不得让步。已 grep 出真正受影响的只有 7 个文件,Python 侧只查裸字符串、无需改。BUG 段 1001 起 | 待领取 | — | +| `TASK-rectification-session-title-result-20260922.md` | `PROGRESS-rectification-session-title-result-20260922.md` | **校正会话标题改写结果(BUG-1001)**:BUG-988 把副标题改成最后活动时间后,标题里的日期成了重复;而 BUG-929 删掉 `uniquifySessionTitle` 之后,`resolveSessionTitle` 对校正入口只返回 `生时校正 · M月D日`,**同一天多条标题完全相同**(真机截图:9/17 三条同名、9/16 两条同名、9/14 一天 5 条);旧标题的钟点后缀是创建时间、副标题是最后活动时间,同一行两个对不上的时间。**产品 09-22 拍板**:D1 标题改为承载结果——有 `accepted_time`/`confirmed_time` 写 `生时校正 · HH:MM`,否则写 `candidate_range` 的 `生时校正 · HH:MM–HH:MM`(推翻 BUG-929 的日期口径,但「不得再加墙钟去重后缀」保留);D2 存量批量重算(推翻 BUG-929「旧标题不批量改」,先例 `20260916020000_rectification_session_title_repair.sql`);D3 今日节奏保留日期(我判定的例外);D4 只在结果变化时改 title,`open` 路径不动(对 BUG-699 边界的有限扩展)。**红线**:任何写标题的路径都不得 bump `updated_at`(否则 13 条历史会话集体跳顶、毁掉 BUG-988);标题算式必须只有一处实现(一个 SQL 函数,触发器与回填共用)——BUG-987/992 都栽在「两层规则各写一份」。T4 跨层对断不得让步。已 grep 出真正受影响的只有 7 个文件,Python 侧只查裸字符串、无需改。BUG 段 1001 起 | 待验收 | `codex/rectification-session-title-result-20260922` | | `TASK-rectification-convergence-20260830.md` | `PROGRESS-rectification-convergence-20260830.md` | 收敛重构 v2 | 已验收 | `3a4396a4`、`86ba17ee` | | `TASK-rectification-decision-authority-20260831.md` | `PROGRESS-rectification-decision-authority-20260831.md` | 决策权威归一与停止语义 | 已合入 | `9f011194` | | `TASK-rectification-billing-20260831.md` | — | run_failed 与开场问题槽为空 | 已合入 | `47b4b06b` | diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 5f2d8744..f06d45f9 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -362,7 +362,7 @@ The birth-time rectification session is the consultation transcript plus a house ### Navigation item -- **Structure:** title is the session name. New dated titles are `生时校正 · M月D日` / `今日节奏 · M月D日` (category first). Subtitle is always `M月D日 HH:MM` in Asia/Shanghai from `updated_at` (last activity), plus ` · ` when the chart is not the account holder. Same-day duplicates are not uniquified with wall-clock `HH:MM`. Display, sort, grouping, and cursor all use `updated_at`. +- **Structure:** title is the session name. Rectification titles show the result: `生时校正 · HH:MM` when a time is confirmed (confirmed wins over accepted) or only accepted, `生时校正 · HH:MM–HH:MM` (en dash) when only `candidate_range` is a clock pair, otherwise `生时校正`. Daily rhythm stays `今日节奏 · M月D日`. Subtitle is always `M月D日 HH:MM` in Asia/Shanghai from `updated_at` (last activity), plus ` · ` when the chart is not the account holder. Same-day duplicates are not uniquified with wall-clock `HH:MM`. Display, sort, grouping, and cursor all use `updated_at`. - **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, 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. diff --git a/frontend/docs/VOICE.md b/frontend/docs/VOICE.md index 45ae01b7..eca5868b 100644 --- a/frontend/docs/VOICE.md +++ b/frontend/docs/VOICE.md @@ -99,7 +99,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、 | Agent 未完成必要的方法与计算步骤,本次不会扣点。(模型其实写过一段) | 把模型写过的那段留下,文末由服务端加一句:「这次没跑完星盘计算,上面是模型直接写的,先看着。」 | 合同没绿但写过字时不得整轮空白。这句不是模型写的。没写过字仍报未完成、不扣点。 | | 这一页是天象本身,不是对你的判断。 | (不写) | 星历页只放日期、五要素、行运、九十天事件。「带这天去提问」在顶栏。不另印定性句。 | | (校正会话上普通输入框仍可打字) | 正在打开生时校正… | 校正会话上普通输入框只有禁用态。打开失败时按钮写「重新打开生时校正」。 | -| 9月14日 · 生时校正 / 9月14日 · 生时校正 02:14 | 生时校正 · 9月14日 | 新建校正/今日节奏标题类别在前、日期在后。同日多条不再加墙钟时刻,靠副标题 `M月D日 HH:MM` 区分。旧标题不批量改。 | +| 9月14日 · 生时校正 / 生时校正 · 9月14日 / 生时校正 · 09:35(创建钟点) | 生时校正 · 05:07 / 生时校正 · 05:00–05:15 / 生时校正 | 校正标题写结果:已确认优先于已采用,写 `生时校正 · HH:MM`;否则当前范围写 `生时校正 · HH:MM–HH:MM`(间隔号,不是连字符);都没有则只写「生时校正」。今日节奏仍是 `今日节奏 · M月D日`。同日不再用墙钟去重。副标题仍是最后活动时间。旧标题按结果批量改;手改过的名字不动。 | | 这么晚还醒着,{名字}。直接说说最在意的问题吧。 | 还没睡呀,{名字} | 开场只一行、不追问、不写场景;提问交给输入框占位符 | | 你更适合把已经积累的专业能力做成长期事业,而不是频繁换赛道。眼下更值得把现有工作做深,而不是立刻另起一摊。 | 今年你的事业是「外松内紧」:表面上木星走到你的事业宫,机会、人缘都在,看着轻松;底下土星压着你的工作宫,做事的规矩和身体的负担在收紧。推动这一年的是土星,不是木星,木星只负责把结果修得体面。所以别去应木星的「享受」,也别去应土星的「磨」,去扮演土星的「守」:把规矩立起来,别人松你不松。
- 上半年别接口头承诺,只接写下来的。
- 6 月前身体的小毛病别拖。
- 9 月木星换宫之后再谈升职,之前谈是白谈。 | 坏的那句像顾问报告:没有反差,没说谁在推谁在修,没有可扮演的象。好的那段是拍板的口气定稿(盘上数据是示意):反差 → 谁推谁修 → 扮演哪个象 → 最多三条短行动。 | | (用户说「你在说什么鬼」)抱歉让你困惑了,我再解释一下:从星盘来看,您的事业宫…… | 说直接点:这份工作还值得留,但你得换个位置,不是换公司。哪句没看懂,指给我。 | 追问轮不重开一遍报告,也不道歉铺垫。一句白话结论 + 一句把球踢回去。 | diff --git a/frontend/scripts/repair-rectification-session-titles.mjs b/frontend/scripts/repair-rectification-session-titles.mjs index 10080516..6ae2c42b 100644 --- a/frontend/scripts/repair-rectification-session-titles.mjs +++ b/frontend/scripts/repair-rectification-session-titles.mjs @@ -1,12 +1,14 @@ -// 只读核对工具。**修补本身已由迁移承担**: +// 历史核对工具,不是活的改标题路径。--apply 会直接报错。 +// 下面三段 SQL 只为锁住历史迁移 // frontend/supabase/migrations/20260916020000_rectification_session_title_repair.sql -// 产品跑 Gitea → `Migrate Staging Database` 就会应用它,不需要 SSH 或库口令。 +// 不得执行 TITLE_APPLY_SQL:它会把标题写回日期。 +// 结果时间口径的回填是 +// frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql。 // // 本文件保留下来有两个用处: // 1. 排查时数一数还有多少行没对上(不写库,只 select count); -// 2. 下面三段 SQL 是那条迁移的**唯一出处**,迁移逐字抄它们。 -// tests/rectification-session-title-repair-migration.test.ts 会比对两边, -// 改了这里而没同步迁移(或反过来)测试就红。 +// 2. 下面三段 SQL 只锁历史迁移的逐字合同,不是新的改标题路径。 +// tests/rectification-session-title-repair-migration.test.ts 会比对两边。 // // 需要 SCHEMA_DATABASE_URL 才能连库;没有它的环境只能跑上面那个合同测试。 diff --git a/frontend/src/lib/agent-reply.ts b/frontend/src/lib/agent-reply.ts index b3b9476c..f82b509a 100644 --- a/frontend/src/lib/agent-reply.ts +++ b/frontend/src/lib/agent-reply.ts @@ -81,6 +81,8 @@ function clipTitle(value: string, maxChars = 14): string { return characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}…` : value; } +// Daily rhythm only. Rectification titles are the result time, written by +// public.rectification_session_title when the case result changes (BUG-1001 / D3). function datedSessionTitle(at: Date, category: string): string { const { month, day } = shanghaiDateParts(at); return `${category} · ${month}月${day}日`; @@ -99,7 +101,7 @@ export function resolveSessionTitle( return datedSessionTitle(at, "今日节奏"); } if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) { - return datedSessionTitle(at, "生时校正"); + return "生时校正"; } const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, ""); if (!normalized) return "新对话"; diff --git a/frontend/src/lib/rectification-session-title-repair.ts b/frontend/src/lib/rectification-session-title-repair.ts deleted file mode 100644 index 6a4bbab3..00000000 --- a/frontend/src/lib/rectification-session-title-repair.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** Product UI dates are wall-clock in China. Repair uses this zone, never UTC. */ -export const RECTIFICATION_TITLE_TIMEZONE = "Asia/Shanghai"; - -export const RECTIFICATION_DATED_TITLE = - /^(\d{1,2})月(\d{1,2})日\s*·\s*生时校正(?:\s+\d{2}:\d{2})?$/; - -export function datedRectificationTitle( - createdAt: Date, - timeZone = RECTIFICATION_TITLE_TIMEZONE, -): string { - const parts = new Intl.DateTimeFormat("en-US", { - timeZone, - month: "numeric", - day: "numeric", - }).formatToParts(createdAt); - const month = Number(parts.find((part) => part.type === "month")?.value); - const day = Number(parts.find((part) => part.type === "day")?.value); - if (!Number.isInteger(month) || !Number.isInteger(day)) { - return "生时校正"; - } - return `${month}月${day}日 · 生时校正`; -} - -export function repairedRectificationTitle(row: { - title: string; - createdAt: Date | null; -}): string | null { - const matched = row.title.match(RECTIFICATION_DATED_TITLE); - if (!matched) return null; - if (!row.createdAt || Number.isNaN(row.createdAt.getTime())) { - return "生时校正"; - } - const next = datedRectificationTitle(row.createdAt); - const nextMatch = next.match(RECTIFICATION_DATED_TITLE); - if ( - nextMatch - && Number(matched[1]) === Number(nextMatch[1]) - && Number(matched[2]) === Number(nextMatch[2]) - ) { - return null; - } - return next; -} diff --git a/frontend/src/lib/session-title.ts b/frontend/src/lib/session-title.ts index 8a522de1..72a1dce8 100644 --- a/frontend/src/lib/session-title.ts +++ b/frontend/src/lib/session-title.ts @@ -10,7 +10,10 @@ function clipHan(value: string, maxChars: number): string { } const BIRTH_STAMP = /\d{4}年\d{1,2}月(?:\d{1,2}日)?|\d{1,2}:\d{2}/; -const DATED_ENTRY_TITLE = /^(?:(?:生时校正|今日节奏)\s*·\s*\d{1,2}月\d{1,2}日|\d{1,2}月\d{1,2}日\s*·\s*(?:今日节奏|生时校正))(?:\s+\d{2}:\d{2})?$/; +// Same automatic shapes as public.rectification_session_title_is_automatic. +// The range dash is U+2013. A hyphen range is not automatic. The parity test +// reads the SQL predicate from the migration and compares answers (BUG-1001). +const DATED_ENTRY_TITLE = /^(?:(?:生时校正|今日节奏)\s*·\s*[0-9]{1,2}月[0-9]{1,2}日|[0-9]{1,2}月[0-9]{1,2}日\s*·\s*(?:今日节奏|生时校正))(?:\s+[0-9]{2}:[0-9]{2})?$|^生时校正$|^生时校正\s*·\s*[0-9]{2}:[0-9]{2}$|^生时校正\s*·\s*[0-9]{2}:[0-9]{2}\u2013[0-9]{2}:[0-9]{2}$/; export function sanitizeSessionTitle(raw: string): string | null { if (/[\r\n]/.test(raw)) return null; diff --git a/frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql b/frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql new file mode 100644 index 00000000..dd7d3174 --- /dev/null +++ b/frontend/supabase/migrations/20260925010000_rectification_session_title_result.sql @@ -0,0 +1,116 @@ +-- BUG-1001: rectification titles carry the result, not a date. +-- One formula and one automatic-title predicate. The trigger and the backfill +-- both call them. Do not assign chat_sessions.updated_at (BUG-988 / BUG-704). +-- Open / GET / metadata PATCH still must not retitle (BUG-699): this trigger +-- listens only to accepted_time, confirmed_time, and candidate_range. + +begin; + +do $migration$ +begin + if current_user <> 'schema_owner' then + raise exception 'rectification_session_title_result_requires_schema_owner' + using errcode = '42501'; + end if; +end +$migration$; + +create or replace function public.rectification_session_title( + p_accepted time, + p_confirmed time, + p_range jsonb +) returns text +language sql +immutable +parallel safe +set search_path = '' +as $$ + select case + when p_confirmed is not null then + '生时校正 · ' || to_char(p_confirmed, 'HH24:MI') + when p_accepted is not null then + '生时校正 · ' || to_char(p_accepted, 'HH24:MI') + when public.agentic_rectification_is_clock(p_range ->> 'start_time') + and public.agentic_rectification_is_clock(p_range ->> 'end_time') then + '生时校正 · ' || (p_range ->> 'start_time') || '–' || (p_range ->> 'end_time') + else + '生时校正' + end; +$$; + +-- The dash in the range alternative is U+2013 EN DASH, not a hyphen. +-- frontend/src/lib/session-title.ts DATED_ENTRY_TITLE must agree with this +-- predicate. The parity test reads this pattern from this file. +create or replace function public.rectification_session_title_is_automatic( + p_title text +) returns boolean +language sql +immutable +parallel safe +set search_path = '' +as $$ + select p_title is not null + and p_title ~ '^(?:(?:生时校正|今日节奏)\s*·\s*[0-9]{1,2}月[0-9]{1,2}日|[0-9]{1,2}月[0-9]{1,2}日\s*·\s*(?:今日节奏|生时校正))(?:\s+[0-9]{2}:[0-9]{2})?$|^生时校正$|^生时校正\s*·\s*[0-9]{2}:[0-9]{2}$|^生时校正\s*·\s*[0-9]{2}:[0-9]{2}–[0-9]{2}:[0-9]{2}$'; +$$; + +create or replace function public.sync_rectification_session_title_from_case() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + update public.chat_sessions + set title = public.rectification_session_title(new.accepted_time, new.confirmed_time, new.candidate_range) + where id = new.session_id + and user_id = new.user_id + and session_type = 'birth_time_rectification' + and title is distinct from public.rectification_session_title(new.accepted_time, new.confirmed_time, new.candidate_range) + and public.rectification_session_title_is_automatic(title); + return new; +end; +$$; + +revoke all on function public.rectification_session_title(time, time, jsonb) + from public, anon, authenticated; +revoke all on function public.rectification_session_title_is_automatic(text) + from public, anon, authenticated; +revoke all on function public.sync_rectification_session_title_from_case() + from public, anon, authenticated; + +grant execute on function public.rectification_session_title(time, time, jsonb) + to schema_owner; +grant execute on function public.rectification_session_title_is_automatic(text) + to schema_owner; +grant execute on function public.sync_rectification_session_title_from_case() + to schema_owner; + +drop trigger if exists agentic_rectification_cases_title_from_result + on public.agentic_rectification_cases; + +create trigger agentic_rectification_cases_title_from_result +after insert or update of accepted_time, confirmed_time, candidate_range +on public.agentic_rectification_cases +for each row +execute function public.sync_rectification_session_title_from_case(); + +do $backfill$ +declare + retitled integer; +begin + -- BEGIN rectification_session_title_backfill + update public.chat_sessions as session + set title = public.rectification_session_title(case_row.accepted_time, case_row.confirmed_time, case_row.candidate_range) + from public.agentic_rectification_cases as case_row + where case_row.session_id = session.id + and case_row.user_id = session.user_id + and session.session_type = 'birth_time_rectification' + and session.title is distinct from public.rectification_session_title(case_row.accepted_time, case_row.confirmed_time, case_row.candidate_range) + and public.rectification_session_title_is_automatic(session.title); + -- END rectification_session_title_backfill + get diagnostics retitled = row_count; + raise notice 'rectification_session_title_result retitled=%', retitled; +end +$backfill$; + +commit; diff --git a/frontend/tests/agent-reply.test.ts b/frontend/tests/agent-reply.test.ts index 1e14903b..429d84cc 100644 --- a/frontend/tests/agent-reply.test.ts +++ b/frontend/tests/agent-reply.test.ts @@ -59,9 +59,9 @@ test("general no-birth-time replies keep a question-specific session title", () }); test("daily and topic consultations get distinct dated or domain titles", () => { - // 原值:8月22日 · 今日节奏;同名追加墙钟 08:36;8月22日 · 生时校正 - // 新值:今日节奏 · 8月22日;同名不再加 HH:MM;生时校正 · 8月22日 - // 原因:任务书 T4 类别在前,同名靠副标题区分 + // 原值:今日节奏 · 8月22日;同名不再加 HH:MM;生时校正 · 8月22日 + // 新值:今日节奏 · 8月22日;同名不再加 HH:MM;生时校正 + // 原因:BUG-1001 新建校正不带日期,结果时间由数据库触发器写入;今日节奏仍带日期(D3) const at = new Date(Date.UTC(2026, 7, 22, 0, 36)); assert.equal( resolveSessionTitle("深入看今日", undefined, { entrypoint: "daily_starlanguage", at }), @@ -84,8 +84,9 @@ test("daily and topic consultations get distinct dated or domain titles", () => entrypoint: "birth_time_rectification", at, }), - "生时校正 · 8月22日", + "生时校正", ); + assert.equal(resolveSessionTitle("生时校正", undefined, { at }), "生时校正"); }); diff --git a/frontend/tests/database-rectification-session-title-result.test.ts b/frontend/tests/database-rectification-session-title-result.test.ts new file mode 100644 index 00000000..a0617dc9 --- /dev/null +++ b/frontend/tests/database-rectification-session-title-result.test.ts @@ -0,0 +1,323 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { startPostgresFixture, type PostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runner = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const migrationPath = new URL("../supabase/migrations/20260925010000_rectification_session_title_result.sql", import.meta.url); +const migrationSql = readFileSync(migrationPath, "utf8"); +const docker = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { stdio: "ignore" }).status === 0; +const EN_DASH = "\u2013"; +const schemaPassword = "schema-owner-test-password"; + +function windowsMigrationDirectory(): string { + // Git symlinks in frontend/db/migrations are plain relative-path files on + // Windows, so the dual-directory scan reports a duplicate filename. Linux + // skips those symlinks (Dirent.isFile() is false). This copy keeps one real + // SQL file per name and still runs scripts/db-migrate.mjs. + const directory = mkdtempSync(join(tmpdir(), "jyotisha-title-migrations-")); + const seen = new Map(); + for (const relative of ["../db/migrations", "../supabase/migrations"]) { + const source = fileURLToPath(new URL(relative, import.meta.url)); + for (const name of readdirSync(source)) { + if (!/^\d{14}_[a-z0-9_]+\.sql$/.test(name)) continue; + const bytes = readFileSync(join(source, name)); + if (/^\.\.\/.+\.sql$/.test(bytes.toString("utf8").trim())) continue; + const previous = seen.get(name); + if (previous) { + assert.equal(bytes.equals(previous), true, `${name} mirrors must match`); + continue; + } + seen.set(name, bytes); + writeFileSync(join(directory, name), bytes); + } + } + assert.ok(seen.has("20260925010000_rectification_session_title_result.sql")); + return directory; +} + +function applyMigrations(fixture: PostgresFixture): void { + const env = { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", schemaPassword), + }; + const first = spawnSync(process.execPath, [runner], { encoding: "utf8", env }); + if (first.status === 0) return; + const detail = `${first.stderr ?? ""}\n${first.stdout ?? ""}`; + assert.match(detail, /duplicate migration filename/, detail); + const directory = windowsMigrationDirectory(); + try { + const second = spawnSync(process.execPath, [runner], { + encoding: "utf8", + env: { ...env, MIGRATIONS_DIRECTORY: directory }, + }); + assert.equal(second.status, 0, second.stderr || second.stdout); + } finally { + rmSync(directory, { force: true, recursive: true }); + } +} + +function backfillUpdate(): string { + const start = migrationSql.indexOf("-- BEGIN rectification_session_title_backfill"); + const end = migrationSql.indexOf("-- END rectification_session_title_backfill"); + assert.ok(start >= 0 && end > start, "backfill markers missing"); + const block = migrationSql.slice(start, end); + const update = block.match(/update public\.chat_sessions as session[\s\S]*?;/)?.[0]; + assert.ok(update, "backfill update must be cut from the migration, not copied"); + assert.doesNotMatch(update, /updated_at\s*=/); + assert.match(update, /public\.rectification_session_title\(/); + assert.match(update, /public\.rectification_session_title_is_automatic\(/); + return update; +} + +function utf8Hex(value: string): string { + return Buffer.from(value, "utf8").toString("hex"); +} + +function titleHex(fixture: PostgresFixture, sessionId: string): string { + return fixture.psql( + `select encode(convert_to(title, 'UTF8'), 'hex') from public.chat_sessions where id = '${sessionId}'`, + ); +} + +function identityFingerprint(fixture: PostgresFixture, sessionId: string): string { + return fixture.psql( + `select pinned::text || '|' || messages::text || '|' || updated_at::text from public.chat_sessions where id = '${sessionId}'`, + ); +} + +function countUpdate(fixture: PostgresFixture, sql: string): string { + const output = fixture.psqlScriptAs("schema_owner", schemaPassword, ` +drop table if exists backfill_row_count; +create temp table backfill_row_count(n integer); +do $count$ +declare n integer; +begin +${sql} +get diagnostics n = row_count; +insert into backfill_row_count values (n); +end +$count$; +select n from backfill_row_count; +`); + const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + return lines[lines.length - 1] ?? ""; +} + +test("rectification session titles follow the result and do not bump updated_at", { + skip: docker ? false : "docker unavailable", +}, () => { + const fixture = startPostgresFixture(); + try { + applyMigrations(fixture); + assert.doesNotMatch(migrationSql, /updated_at\s*=/); + + const call = (sql: string) => fixture.psqlAs("schema_owner", schemaPassword, `select ${sql}`); + assert.equal( + call(`public.rectification_session_title('04:00'::time, '05:07'::time, '{"start_time":"05:00","end_time":"05:15"}'::jsonb)`), + "生时校正 · 05:07", + ); + assert.equal( + call(`public.rectification_session_title('06:30'::time, null, '{"start_time":"05:00","end_time":"05:15"}'::jsonb)`), + "生时校正 · 06:30", + ); + assert.equal( + call(`public.rectification_session_title(null, null, '{"start_time":"05:00","end_time":"05:15"}'::jsonb)`), + `生时校正 · 05:00${EN_DASH}05:15`, + ); + assert.equal( + call(`public.rectification_session_title(null, null, '{"start_time":"25:00","end_time":"05:15"}'::jsonb)`), + "生时校正", + ); + assert.equal( + call(`public.rectification_session_title(null, null, '{}'::jsonb)`), + "生时校正", + ); + const volatility = fixture.psql(` + select proname || '=' || provolatile::text + from pg_proc + where pronamespace = 'public'::regnamespace + and proname in ('rectification_session_title', 'rectification_session_title_is_automatic') + order by proname + `); + assert.equal( + volatility, + "rectification_session_title=i\nrectification_session_title_is_automatic=i", + ); + const bodies = fixture.psql(` + select string_agg(prosrc, ' ') + from pg_proc + where pronamespace = 'public'::regnamespace + and proname in ('rectification_session_title', 'rectification_session_title_is_automatic') + `); + assert.doesNotMatch(bodies, /\bfrom\b/i); + assert.equal( + call(`public.rectification_session_title_is_automatic('生时校正 · 05:00-05:15')`), + "f", + ); + assert.equal(call(`public.rectification_session_title_is_automatic('我的校正')`), "f"); + + const triggerDef = fixture.psql(` + select pg_get_triggerdef(oid) + from pg_trigger + where tgname = 'agentic_rectification_cases_title_from_result' + and not tgisinternal + `); + assert.match(triggerDef, /AFTER INSERT OR UPDATE OF accepted_time, confirmed_time, candidate_range/i); + assert.doesNotMatch(triggerDef, /last_activity_at/); + assert.doesNotMatch(triggerDef, /updated_at/); + const touchDef = fixture.psql(` + select pg_get_triggerdef(oid) + from pg_trigger + where tgname = 'agentic_rectification_cases_touch_chat_session' + and not tgisinternal + `); + assert.match(touchDef, /UPDATE OF last_activity_at/i); + assert.doesNotMatch(touchDef, /accepted_time/); + + const user = randomUUID(); + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `insert into identity.users(id,name,email,email_verified) values('${user}','Fictional title','${user}@example.invalid',true)`, + ); + const insertSession = ( + id: string, + title: string, + sessionType: string, + updatedAt: string, + pinned: boolean, + message: string, + ) => { + fixture.psql(` + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages, pinned, updated_at) + values ( + '${id}', '${user}', '${title}', 'general', '${sessionType}', + '[{"role":"user","text":"${message}"}]'::jsonb, ${pinned}, '${updatedAt}' + ) + `); + }; + const insertCase = ( + sessionId: string, + accepted: string | null, + confirmed: string | null, + start: string, + end: string, + ) => { + const id = randomUUID(); + const acceptedSql = accepted ? `'${accepted}'::time` : "null"; + const confirmedSql = confirmed ? `'${confirmed}'::time` : "null"; + fixture.psql(` + insert into public.agentic_rectification_cases ( + id, user_id, session_id, status, skill_name, skill_version, + baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range, + accepted_time, confirmed_time + ) values ( + '${id}', '${user}', '${sessionId}', 'candidate_ready', + 'jyotish-birth-time-rectification', '9.0.0', '${"a".repeat(64)}', + '{"birth_date":"2000-01-01"}'::jsonb, + '{"start_time":"${start}","end_time":"${end}"}'::jsonb, + ${acceptedSql}, ${confirmedSql} + ) + `); + return id; + }; + + const adoptSession = randomUUID(); + insertSession(adoptSession, "生时校正", "birth_time_rectification", "2026-09-11 08:00:00+00", true, "adopt"); + const beforeAdoptInsert = identityFingerprint(fixture, adoptSession); + const adoptCase = insertCase(adoptSession, null, null, "05:00", "05:15"); + assert.equal(identityFingerprint(fixture, adoptSession), beforeAdoptInsert); + assert.equal(titleHex(fixture, adoptSession), utf8Hex(`生时校正 · 05:00${EN_DASH}05:15`)); + const beforeAdopt = identityFingerprint(fixture, adoptSession); + fixture.psql(`update public.agentic_rectification_cases set accepted_time = '05:07'::time where id = '${adoptCase}'`); + assert.equal(titleHex(fixture, adoptSession), utf8Hex("生时校正 · 05:07")); + assert.equal(identityFingerprint(fixture, adoptSession), beforeAdopt); + + const narrowSession = randomUUID(); + insertSession(narrowSession, "生时校正", "birth_time_rectification", "2026-09-12 08:00:00+00", false, "narrow"); + const narrowCase = insertCase(narrowSession, null, null, "05:00", "05:15"); + const beforeNarrow = identityFingerprint(fixture, narrowSession); + fixture.psql(` + update public.agentic_rectification_cases + set candidate_range = '{"start_time":"05:02","end_time":"05:08"}'::jsonb + where id = '${narrowCase}' + `); + assert.equal(titleHex(fixture, narrowSession), utf8Hex(`生时校正 · 05:02${EN_DASH}05:08`)); + assert.equal(identityFingerprint(fixture, narrowSession), beforeNarrow); + + const renamedSession = randomUUID(); + insertSession(renamedSession, "生时校正", "birth_time_rectification", "2026-09-13 08:00:00+00", true, "renamed-live"); + const renamedCase = insertCase(renamedSession, null, null, "08:00", "08:10"); + fixture.psql(`update public.chat_sessions set title = '我的校正' where id = '${renamedSession}'`); + const beforeRenameAdopt = identityFingerprint(fixture, renamedSession); + fixture.psql(`update public.agentic_rectification_cases set accepted_time = '08:08'::time where id = '${renamedCase}'`); + assert.equal(titleHex(fixture, renamedSession), utf8Hex("我的校正")); + assert.equal(identityFingerprint(fixture, renamedSession), beforeRenameAdopt); + + const touchSession = randomUUID(); + insertSession(touchSession, "生时校正", "birth_time_rectification", "2026-09-10 08:00:00+00", false, "touch"); + const touchCase = insertCase(touchSession, null, null, "04:10", "04:20"); + const titleBeforeTouch = titleHex(fixture, touchSession); + fixture.psql(` + update public.agentic_rectification_cases + set last_activity_at = '2026-09-20 12:00:00+00' + where id = '${touchCase}' + `); + assert.equal(titleHex(fixture, touchSession), titleBeforeTouch); + assert.equal( + fixture.psql(` + select (session.updated_at = case_row.last_activity_at)::text + from public.chat_sessions as session + join public.agentic_rectification_cases as case_row on case_row.session_id = session.id + where session.id = '${touchSession}' + `), + "true", + ); + + fixture.psql("alter table public.agentic_rectification_cases disable trigger agentic_rectification_cases_title_from_result"); + const rows = [ + { key: "confirmed", title: "生时校正", accepted: "04:00", confirmed: "05:07", start: "05:00", end: "05:15", expected: "生时校正 · 05:07", pinned: true, at: "2026-09-01 01:00:00+00" }, + { key: "accepted", title: "生时校正", accepted: "06:30", confirmed: null, start: "05:00", end: "05:15", expected: "生时校正 · 06:30", pinned: false, at: "2026-09-02 01:00:00+00" }, + { key: "range", title: "生时校正", accepted: null, confirmed: null, start: "05:00", end: "05:15", expected: `生时校正 · 05:00${EN_DASH}05:15`, pinned: true, at: "2026-09-03 01:00:00+00" }, + { key: "old-clock", title: "9月17日 · 生时校正 09:35", accepted: null, confirmed: null, start: "04:10", end: "04:20", expected: `生时校正 · 04:10${EN_DASH}04:20`, pinned: false, at: "2026-09-04 01:00:00+00" }, + { key: "old-plain", title: "9月17日 · 生时校正", accepted: "07:07", confirmed: null, start: "01:00", end: "02:00", expected: "生时校正 · 07:07", pinned: true, at: "2026-09-05 01:00:00+00" }, + { key: "renamed", title: "我的校正", accepted: "08:08", confirmed: "08:09", start: "08:00", end: "08:10", expected: "我的校正", pinned: false, at: "2026-09-06 01:00:00+00" }, + ] as const; + const ids = new Map(); + for (const row of rows) { + const id = randomUUID(); + ids.set(row.key, id); + insertSession(id, row.title, "birth_time_rectification", row.at, row.pinned, row.key); + insertCase(id, row.accepted, row.confirmed, row.start, row.end); + } + const orphan = randomUUID(); + insertSession(orphan, "生时校正", "birth_time_rectification", "2026-09-07 01:00:00+00", true, "orphan"); + const consultation = randomUUID(); + insertSession(consultation, "生时校正", "consultation", "2026-09-08 01:00:00+00", false, "consultation"); + const watched = [...ids.values(), orphan, consultation]; + const before = new Map(watched.map((id) => [id, identityFingerprint(fixture, id)])); + const update = backfillUpdate(); + assert.equal(countUpdate(fixture, update), "5"); + for (const row of rows) { + const id = ids.get(row.key); + assert.ok(id); + assert.equal(titleHex(fixture, id), utf8Hex(row.expected), row.key); + assert.equal(identityFingerprint(fixture, id), before.get(id), row.key); + } + assert.equal(titleHex(fixture, orphan), utf8Hex("生时校正")); + assert.equal(titleHex(fixture, consultation), utf8Hex("生时校正")); + assert.equal(identityFingerprint(fixture, orphan), before.get(orphan)); + assert.equal(identityFingerprint(fixture, consultation), before.get(consultation)); + assert.equal(countUpdate(fixture, update), "0"); + fixture.psql("alter table public.agentic_rectification_cases enable trigger agentic_rectification_cases_title_from_result"); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/rectification-session-title-parity.test.ts b/frontend/tests/rectification-session-title-parity.test.ts new file mode 100644 index 00000000..a679b585 --- /dev/null +++ b/frontend/tests/rectification-session-title-parity.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { isAutoDerivedSessionTitle, shouldGenerateSessionTitle } from "../src/lib/session-title.ts"; + +// BUG-987 and BUG-992 were two copies of one rule with nobody comparing them. +// This test reads the SQL predicate from the migration. Do not paste a second copy. + +const migration = readFileSync( + new URL("../supabase/migrations/20260925010000_rectification_session_title_result.sql", import.meta.url), + "utf8", +); + +const functionBody = migration.match( + /create or replace function public\.rectification_session_title_is_automatic[\s\S]*?as \$\$([\s\S]*?)\$\$;/, +); +assert.ok(functionBody, "migration must define rectification_session_title_is_automatic"); +const pattern = functionBody[1].match(/~ '([^']+)'/)?.[1]; +assert.ok(pattern, "automatic-title predicate must be one quoted regex"); +const sqlWouldOverwrite = new RegExp(pattern); + +const EN_DASH = "\u2013"; +const cases: ReadonlyArray = [ + ["生时校正", true], + ["生时校正 · 05:07", true], + [`生时校正 · 05:00${EN_DASH}05:15`, true], + ["生时校正 · 9月17日", true], + ["9月17日 · 生时校正", true], + ["9月17日 · 生时校正 09:35", true], + ["我的校正", false], + ["今日节奏 · 9月17日", true], + ["9月17日 · 今日节奏", true], + ["9月17日 · 今日节奏 09:35", true], +]; + +test("SQL overwrite predicate and isAutoDerivedSessionTitle agree", () => { + assert.match(pattern, new RegExp(String.raw`\[0-9\]\{2\}:\[0-9\]\{2\}${EN_DASH}\[0-9\]\{2\}:\[0-9\]\{2\}`)); + assert.doesNotMatch(pattern, /\[0-9\]\{2\}:\[0-9\]\{2\}-\[0-9\]\{2\}:\[0-9\]\{2\}/); + for (const [title, automatic] of cases) { + assert.equal(sqlWouldOverwrite.test(title), automatic, `sql ${title}`); + assert.equal(isAutoDerivedSessionTitle(title), automatic, `ts ${title}`); + } + assert.equal(sqlWouldOverwrite.test(`生时校正 · 05:00-05:15`), false); + assert.equal(shouldGenerateSessionTitle({ title: "生时校正", sessionType: "birth_time_rectification" }, []), false); + assert.equal(shouldGenerateSessionTitle({ title: "生时校正 · 05:07", sessionType: "birth_time_rectification" }, []), false); +}); diff --git a/frontend/tests/rectification-session-title-repair.test.ts b/frontend/tests/rectification-session-title-repair.test.ts deleted file mode 100644 index 52aabe24..00000000 --- a/frontend/tests/rectification-session-title-repair.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - datedRectificationTitle, - repairedRectificationTitle, -} from "../src/lib/rectification-session-title-repair.ts"; - -test("dated titles use the product calendar, not UTC", () => { - const createdAt = new Date("2026-09-11T18:30:00.000Z"); - assert.equal(datedRectificationTitle(createdAt), "9月12日 · 生时校正"); -}); - -test("repair rewrites a wall-clock today stamp back to created_at's local date", () => { - assert.equal( - repairedRectificationTitle({ - title: "9月15日 · 生时校正", - createdAt: new Date("2026-09-12T03:00:00.000Z"), - }), - "9月12日 · 生时校正", - ); - assert.equal( - repairedRectificationTitle({ - title: "9月15日 · 生时校正 14:32", - createdAt: new Date("2026-09-12T03:00:00.000Z"), - }), - "9月12日 · 生时校正", - ); -}); - -test("repair leaves matching dates and user-renamed titles alone", () => { - assert.equal( - repairedRectificationTitle({ - title: "9月12日 · 生时校正", - createdAt: new Date("2026-09-12T03:00:00.000Z"), - }), - null, - ); - assert.equal( - repairedRectificationTitle({ - title: "9月12日 · 生时校正 14:32", - createdAt: new Date("2026-09-12T03:00:00.000Z"), - }), - null, - ); - assert.equal( - repairedRectificationTitle({ - title: "家里那次校正", - createdAt: new Date("2026-09-12T03:00:00.000Z"), - }), - null, - ); -}); - -test("missing created_at falls back to a dateless title instead of guessing", () => { - assert.equal( - repairedRectificationTitle({ title: "9月15日 · 生时校正", createdAt: null }), - "生时校正", - ); -}); diff --git a/frontend/tests/session-list-filter.test.ts b/frontend/tests/session-list-filter.test.ts index 73057b48..69c54109 100644 --- a/frontend/tests/session-list-filter.test.ts +++ b/frontend/tests/session-list-filter.test.ts @@ -47,7 +47,10 @@ test("empty consultations stay off the sidebar while rectification rows stay on const rectification = session({ id: "rect", sessionType: "birth_time_rectification", - title: "生时校正 · 9月14日", + // 原值:生时校正 · 9月14日 + // 新值:生时校正 · 05:07 + // 原因:BUG-1001 校正标题改为结果时间;本断言只锁是否入列,不锁标题格式 + title: "生时校正 · 05:07", }); const archivedRectification = session({ id: "archived-rect", diff --git a/frontend/tests/session-title-agent.test.ts b/frontend/tests/session-title-agent.test.ts index 6550a123..406bdb42 100644 --- a/frontend/tests/session-title-agent.test.ts +++ b/frontend/tests/session-title-agent.test.ts @@ -35,8 +35,12 @@ test("shouldGenerateSessionTitle only runs on a first-round auto title", () => { assert.equal(shouldGenerateSessionTitle({ title: "我想问一下最近半年换工…", sessionType: "consultation" }, []), true); assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "consultation" }, [{ role: "user" }]), false); assert.equal(shouldGenerateSessionTitle({ title: "新对话", sessionType: "birth_time_rectification" }, []), false); + assert.equal(shouldGenerateSessionTitle({ title: "生时校正", sessionType: "birth_time_rectification" }, []), false); + assert.equal(shouldGenerateSessionTitle({ title: "生时校正 · 05:07", sessionType: "birth_time_rectification" }, []), false); assert.equal(shouldGenerateSessionTitle({ title: "9月6日 · 今日节奏", sessionType: "consultation" }, []), false); assert.equal(shouldGenerateSessionTitle({ title: "今日节奏 · 9月6日", sessionType: "consultation" }, []), false); + assert.equal(shouldGenerateSessionTitle({ title: "生时校正", sessionType: "consultation" }, []), false); + assert.equal(shouldGenerateSessionTitle({ title: "生时校正 · 05:07", sessionType: "consultation" }, []), false); assert.equal(shouldGenerateSessionTitle({ title: "半年内换工作时机", sessionType: "consultation" }, []), false); });