BUG-988 后标题日期与副标题重复,且 BUG-929 删 uniquify 后同日多条 标题相同。产品拍板标题承载 accepted/confirmed 分钟或 candidate_range, 存量批量重算。红线:不得 bump updated_at,标题算式只能有一处实现。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0199rbQDTsUbCVw84wc8BTFe
16 KiB
TASK 校正会话标题改为写结果,不再写日期(BUG-1001)
- 日期:2026-09-22
- 基线 commit:
3fed71ab(origin/staginghead;开工git fetch后以实际 head 为准。已部署 SHA 当时是1bc6a954,门禁在6d06fca0红过,与本单无关) - 分支:
codex/rectification-session-title-result-20260922 - BUG 编号起点:BUG-1001(
docs/BUG_HISTORY.md当前最大号BUG-1000,开工时复核) - 关联记录:BUG-929(本单推翻其标题口径)、BUG-699、BUG-704、BUG-988、BUG-987、BUG-992
1. 事故实证
产品负责人 2026-09-22 的侧栏截图(真机 staging):
| 标题 | 副标题 |
|---|---|
| 今日节奏 · 9月18日 | 9月18日 11:18 |
| 9月17日 · 生时校正 | 9月17日 19:12 |
| 9月17日 · 生时校正 09:35 | 9月17日 11:15 |
| 9月17日 · 生时校正 | 9月17日 07:57 |
| 9月17日 · 生时校正 | 9月17日 00:24 |
| 9月16日 · 生时校正 | 9月16日 22:25 |
| 9月16日 · 今日节奏 | 9月16日 18:34 |
| 9月16日 · 生时校正 | 9月16日 16:20 |
| …9月15 / 9月14 共 8 条,其中 9月14 一天 5 条 | … |
三个问题:
- 日期印了两遍。 BUG-988 把副标题改成最后活动时间之后,标题里的日期就是冗余。
- 标题不再能区分同一天的多条。
uniquifySessionTitle在 BUG-929 那轮删掉了,resolveSessionTitle现在对校正入口只返回datedSessionTitle(at, "生时校正")(frontend/src/lib/agent-reply.ts,符号定位function datedSessionTitle与export function resolveSessionTitle)。截图里 9月17日 三条标题完全相同, 9月16日 两条也相同;9月14日 那天有 5 条。日期这个维度根本不够用。 - 旧标题里的钟点和副标题对不上。 带后缀那几条(
… 生时校正 09:35/ 副标题11:15,… 02:14/02:19,… 15:37/15:38)是 BUG-929 删uniquify之前留下的历史标题, BUG-929 当时决定「旧标题不批量改」。标题里的钟点是创建时间,副标题现在是最后活动时间, 同一行摆着两个对不上的时间,读起来像 bug。
产品负责人的原话是「既然下面有实际日期了,标题是不是就不需要日期了」。 但只做减法会让标题这一列信息量归零——13 行全叫「生时校正」。所以本单是替换,不是删除。
2. 根因
侧栏行有两列信息:标题和副标题。BUG-988 把副标题定成「最后活动时间」之后, 标题继续承载日期,就同时犯了两个错:与副标题重复,且不承载这次会话的内容。
普通咨询会话的标题是模型对首问的总结(内容摘要),校正会话没有这条路
(shouldGenerateSessionTitle 对 birth_time_rectification 直接 return false),
于是退化成了日期。而校正会话真正有区分度、用户真正想在列表里找的,是这次校正算出了什么。
3. 决策记录
产品负责人 2026-09-22 就地拍板:
-
D1(推翻 BUG-929 的「新标题
生时校正 · M月D日」):校正会话标题改为承载校正结果:- 有
confirmed_time或accepted_time→生时校正 · HH:MM - 否则 →
生时校正 · <start>–<end>(用candidate_range,该列not null,永远有值)
BUG-929 的其余决定保留:不得再给同名标题追加墙钟 HH:MM 后缀(那正是问题 3 的来源)。
- 有
-
D2(推翻 BUG-929 的「旧标题不批量改」):存量校正会话标题一次性批量重算, 日期在前的旧格式与钟点后缀一并清掉。有现成先例:
20260916020000_rectification_session_title_repair.sql就是干这个的,路走过。不改就会上半截新格式、下半截旧格式,比现在还乱。 -
D3(我的判断,产品未单独过问,按例外处理):「今日节奏」保留日期。它一天一条, 日期就是它的全部内容,没有别的可写;去掉就全叫「今日节奏」。副标题重复一次日期是可接受的代价。 若产品验收时不同意,另开一轮,本单不动它。
-
D4(对 BUG-699 边界的有限扩展):BUG-699 的防复发写的是「打开已有会话仍不得改 title / updatedAt」。 本单要在校正结果变化时改 title。这不是打开会话时改,
open路径一个字都不动。 执行方不得以 BUG-699 为由拒改,但也不得顺手放宽 open 路径。
4. 硬红线
- 不得 bump
updated_at。 写标题的任何路径都不许碰chat_sessions.updated_at(BUG-988 / BUG-704)。存量回填迁移一旦动了它,13 条历史会话会集体跳到列表顶端、全显示今天—— 那会直接毁掉刚修好的 BUG-988。回填迁移必须有「updated_at逐行不变」的断言。 - 运行时与回填必须共用同一个标题函数。 今天已经在同一件事上吃过两次亏: BUG-987 是服务端与客户端两套相反的过滤规则,BUG-992 是前端与 Python 两套不同步的合同。 本单要求把标题算式写成一个 SQL 函数,触发器和回填迁移都调它,不得各写一份。
open路径不得改 title(BUG-699)。- 不得再引入墙钟 HH:MM 去重后缀(BUG-929 防复发)。
- 不得动会话删除、收藏、重命名;用户手动重命名过的标题不得被覆盖(见 T2 的判据)。
- 删改任何前端符号或可见文案之前,按
frontend/AGENTS.md跑git grep -n "<符号>" -- tests/ frontend/, 不得只扫frontend/(BUG-992 的教训,已列在 §6)。 - 改任何既有断言写「原值 / 新值 / 原因」三栏(AGENTS.md §7.3);比测试规模比用例名列表 diff,
不比
# tests汇总数(BUG-995)。
5. 任务分解
T1|标题算式落成一个 SQL 函数(BUG-1001)
新增迁移,建 public.rectification_session_title(p_accepted time, p_confirmed time, p_range jsonb) returns text,
immutable,set search_path='':
-
p_confirmed非空 →'生时校正 · ' || to_char(p_confirmed, 'HH24:MI') -
否则
p_accepted非空 → 同上,用p_accepted -
否则
p_range的start_time/end_time都合法(复用现成的public.agentic_rectification_is_clock)→'生时校正 · ' || start || '–' || end(破折号用 U+2013 EN DASH,与交付卡口径一致,不要用连字符) -
否则 →
'生时校正' -
验收标准: 四个分支各一条真实 Postgres 断言;
immutable且不读表。
T2|结果变化时回写标题(BUG-1001)
仿照现成的 public.touch_chat_session_from_rectification_case()
(frontend/supabase/migrations/20260915010000_rectification_touch_chat_session.sql,
同一 security 姿态、同一 schema_owner 守卫、同一 drop trigger if exists + create trigger 写法),
新增 after insert or update of accepted_time, confirmed_time, candidate_range on public.agentic_rectification_cases 的触发器:
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 <> public.rectification_session_title(...) -- 无变化不写
and (title = '生时校正' or title ~ <自动派生标题的正则>) -- 用户改过名的不覆盖
- 不得出现
updated_at = ...(红线 1)。 - 「用户改过名的不覆盖」的判据:标题匹配自动派生形态才覆盖。自动派生形态包含
旧格式(
M月D日 · 生时校正[ HH:MM])、BUG-929 格式(生时校正 · M月D日)、 本单格式(生时校正[ · …])三种。这个正则与frontend/src/lib/session-title.ts的DATED_ENTRY_TITLE是同一件事的两处表达——T4 要求两边对断。 - 验收标准:
- 真实 Postgres:采用一个分钟 → 标题变
生时校正 · HH:MM,同一行updated_at逐字节不变; 区间收窄 → 标题跟着变;用户手动改成「我的校正」后再采用 → 标题不被覆盖。 last_activity_at触发的既有 touch 触发器仍正常(两个触发器互不干扰,各测一条)。
- 真实 Postgres:采用一个分钟 → 标题变
T3|新建时不带日期,前端口径跟上(BUG-1001)
frontend/src/lib/agent-reply.ts:resolveSessionTitle对entrypoint === "birth_time_rectification"与isRectificationHandoffQuestion分支改为返回"生时校正"(不带日期,结果由 T2 的触发器补)。datedSessionTitle若只剩「今日节奏」一个调用者,保留但改名或加注释说明它现在只服务今日节奏(D3)。frontend/src/lib/session-title.ts:DATED_ENTRY_TITLE要继续认得住三种自动派生形态 (旧格式、BUG-929 格式、本单格式),否则shouldGenerateSessionTitle/isAutoDerivedSessionTitle会把新标题当成用户自定义标题,进而让模型去覆盖它。- 验收标准:
agent-reply.test.ts断言校正入口返回生时校正(原值生时校正 · M月D日,写三栏说明)。- 新增断言:
isAutoDerivedSessionTitle对生时校正、生时校正 · 05:07、生时校正 · 05:00–05:15、生时校正 · 9月17日、9月17日 · 生时校正、9月17日 · 生时校正 09:35全部为 true; 对我的校正为 false。 shouldGenerateSessionTitle对校正会话仍恒 false(不变)。
T4|跨层对断(BUG-1001,防复发的主断言)
- 新增一条测试,把 T1 的 SQL 函数与前端的
DATED_ENTRY_TITLE放在同一张表里对断: 同一组输入(六种自动派生形态 + 一个用户自定义标题),SQL 侧「会不会覆盖」与 TS 侧 「是不是自动派生」必须给出相同答案。 - 理由写进测试注释:BUG-987 与 BUG-992 都是「两层规则各写一份、没人对断」。
- 验收标准: 故意把 SQL 正则改一个字符,这条测试必须转红(执行方本地自测一次,撤回,不提交)。
T5|存量批量重算(BUG-1001)
新增向前业务迁移:
update public.chat_sessions s
set title = public.rectification_session_title(c.accepted_time, c.confirmed_time, c.candidate_range)
from public.agentic_rectification_cases c
where c.session_id = s.id
and s.session_type = 'birth_time_rectification'
and s.title <> public.rectification_session_title(c.accepted_time, c.confirmed_time, c.candidate_range)
and (s.title 匹配三种自动派生形态之一);
- 幂等(第二次跑匹配 0 行)。不得
set updated_at。 - 没有对应 case 的校正会话(理论上不存在,
session_id是 unique FK)跳过,不报错。 - 验收标准:
- 真实 Postgres:造六条不同状态的会话(已确认 / 已采用 / 只有区间 / 旧格式带钟点 / 旧格式不带钟点 / 用户改过名),
跑迁移后前五条按规则改写、第六条不动;全部六行的
updated_at、pinned、messages逐字节不变; 重跑迁移 0 行受影响。 - 迁移里的
update语句由测试从文件切出来执行,不得手抄(BUG-995 那轮定的做法)。
- 真实 Postgres:造六条不同状态的会话(已确认 / 已采用 / 只有区间 / 旧格式带钟点 / 旧格式不带钟点 / 用户改过名),
跑迁移后前五条按规则改写、第六条不动;全部六行的
T6|记录(BUG-1001)
docs/BUG_HISTORY.md新增 BUG-1001,复发自 BUG-929(标题口径两次返工)。 防复发写两条:标题算式只能有一处实现,运行时与回填共用;侧栏同一行的两列不得承载同一个事实。CHANGELOG.md:一句用户能懂的——历史校正会话的名字改成显示算出来的出生时间或当前范围。frontend/DESIGN.md的 Navigation item 一节:标题口径同提交更新 (现在写的是「New dated titles are生时校正 · M月D日/今日节奏 · M月D日(category first)」)。docs/tasks/README.md状态板加行。
6. 会被牵动的文件(我已 grep 过,照这份查)
真正断言标题格式的只有这 7 处(git grep -ln "生时校正 · \|月.日 · 生时校正\|datedSessionTitle\|DATED_ENTRY_TITLE\|datedRectificationTitle\|RECTIFICATION_DATED_TITLE\|repairedRectificationTitle" -- tests/ frontend/):
| 文件 | 说明 |
|---|---|
frontend/src/lib/agent-reply.ts |
datedSessionTitle / resolveSessionTitle,T3 改这里 |
frontend/src/lib/session-title.ts |
DATED_ENTRY_TITLE 正则,T3 扩到三种形态 |
frontend/src/lib/rectification-session-title-repair.ts |
BUG-699/704 的旧修补助手,判断是否仍需要;若被 T5 取代就删并说明 |
frontend/scripts/repair-rectification-session-titles.mjs |
同上,旧脚本 |
frontend/supabase/migrations/20260916020000_rectification_session_title_repair.sql |
不得修改(历史迁移),只作为写法先例 |
frontend/tests/agent-reply.test.ts |
断言要改 |
frontend/tests/rectification-session-title-repair.test.ts、-migration.test.ts、frontend/tests/session-list-filter.test.ts |
断言要跟着改 |
Python 侧确认不需要改:tests/test_daily_and_rectification_entrypoints.py 只断言裸字符串
"生时校正" in source,任何新格式都满足;tests/test_api_server_security.py 的命中与标题无关。
(这一条是我替你查过的结论,但开工时仍按红线 6 自己再跑一次 grep。)
7. 交付前必须全跑
cd frontend && ./node_modules/.bin/tsc --noEmit→ 0 错npm run lint→ 0 error,warning 不得超过 119npm test→ 全量;用用例名列表 diff与基线比对,只允许新增npm run test:db→ 本单必跑,T2 / T5 的证据全在这里;无 Docker 时按 §8 让步npm run build→/仍○ Static,首屏 gzip ±2%python3 -m pytest tests/test_daily_and_rectification_entrypoints.py -q→ 确认未被波及
8. 让步顺序
- 无 Docker →
test:db跑不了:T1/T2/T5 的实现与真实 Postgres 测试照写照提交,BLOCKED.md记明,最终以门禁 run 转绿为准;BUG-1001 在门禁那几条 DB 测试转绿之前不得标resolved。 - T5 存量回填与 T1–T4 拆轮 → 允许,但顺序必须是先 T1–T4 后 T5: 先让新结果能写对,再回填历史。反过来回填完又被旧触发器逻辑覆盖。
- T4 跨层对断做不出来 → 不得让步。这是本单唯一真正的防复发措施。
- D3(今日节奏保留日期)若产品验收时否决 → 另开一轮,本单不扩范围。
9. 开工前置命令
cd /workspace/Jyotisha
git status -sb | head -1
git fetch origin --prune
git worktree add -b codex/rectification-session-title-result-20260922 \
.worktrees/rectification-session-title-result-20260922 origin/staging
cd .worktrees/rectification-session-title-result-20260922/frontend
ln -s /workspace/Jyotisha/frontend/node_modules node_modules # 或 npm ci
# 基线用例名单(比名单,不比总数)
npm test 2>&1 | grep -E "^(not )?ok [0-9]+ - " | sed -E 's/^(not )?ok [0-9]+ - //' | sort > /tmp/names-base.txt
# 自己再跑一次范围确认(红线 6)
cd .. && git grep -n "datedSessionTitle\|DATED_ENTRY_TITLE\|生时校正 · " -- tests/ frontend/
开工必读:docs/BUG_HISTORY.md 的 BUG-929(本单推翻它)、BUG-699、BUG-704、BUG-988、BUG-992、BUG-995;
frontend/AGENTS.md;frontend/DESIGN.md 的 Navigation item 一节;
frontend/supabase/migrations/20260915010000_rectification_touch_chat_session.sql(触发器写法先例)。
注意:在 frontend/ 里跑 npx tsx --test 会生成未跟踪的 frontend/frontend/node_modules,交付前清掉。
10. 部署后真人走查
- 侧栏里的校正会话,名字变成
生时校正 · 05:07(有结论的)或生时校正 · 05:00–05:15(只有范围的), 不再出现两条同名。 - 那几条带
09:35/02:14这类钟点后缀、且与下面时间对不上的旧标题,全部消失。 - 这些会话在列表里的位置和下面的时间都没有变——如果它们集体跳到顶端、全显示今天,
说明回填动了
updated_at,立刻回报(红线 1)。 - 「今日节奏」仍然是
今日节奏 · M月D日(D3 的有意例外)。 - 自己手动重命名过的会话,名字没有被改回去。