主题
backlog 归档 —— 已结案条目
从
backlog.md移出的已结案条目。 首批 20 条(2026-08-15)全是## ✅;第二批(2026-08-30,8 条)放宽了口径 —— 除## ✅外还收## ❌(已评估否决)与「已结案但体量极大的裁定留痕」 (外部建议逐条裁定、多轮 as-built 流水)。判据是还会不会有人去做,不是开头那个 emoji。为什么不直接删:这些条目里有
CHANGELOG.md不记的东西—— CHANGELOG 记 as-built(做了什么、怎么做的),而这里记的是裁决过程: 为什么否掉某方案、接受了什么近似、「唯一复活条件」是什么。 重开旧议题前先 grep 这里,多半当初已经论证过。⚠️ 同归档区通例:条目内的「当前状态 / 待办」是写作当时的快照。
✅ CI 接线的四条尾巴 —— 四条全做完(2026-08-30)
这批尾巴本身是同日从归档件里捞回来的:post-merge-repo-optimization-plan 以 13/13 归档时,它们只登记在 §1 台账的 T3-1 结论段里(不是独立编号任务),没有搬家 —— 「plan 会归档,尾项不会」的又一个实例。捞回来当天就全做完了。
| # | 结果 |
|---|---|
| 1 | ✅ release-verify 拆静态段(--static-only)并接 ci-verify.yml |
| 2 | ✅ ci-verify.yml 配 paths 触发面 |
| 3 | ✅ privacy-verify 的线上段改走定时巡检(monitor-prod.yml,本仓第一个 schedule 型 workflow) |
| 4 | ✅ 新增 check:readme-paths 并接 ci.yml |
🔴 第 3 条首跑时炸出一个「伪装成安全事故」的假报警
定时巡检首跑红了 13 条,日志写着「/admin 返回 HTTP (应 307 到登录页)」 「门没挡住这个变体」—— 看起来像生产站点的鉴权门全没了。
真因是 mktemp -t privverify:-t NAME 是 BSD 语法,GNU coreutils 要求模板含 XXXXXX,否则报 "too few X's" 并输出空串 ⇒ BODY_FILE="" ⇒ curl -o "" 失败 ⇒ 状态码解析成空 ⇒ 每条依赖响应体的判据都 FAIL。 识别线索是失败的形状:凡是写临时文件的检查全红、只用 /dev/null 的 P10/P11/P13 全绿。
⚠️ 这是最坏的一类假阳性:不是「闸门坏了没人知道」,而是「闸门谎报安全事故」。 而本机(macOS/BSD)恒绿 —— 只有真的推上去跑一次才看得见。 已修两处(privacy-verify 的 BODY_FILE/SCAN_FILE + release-verify 的 WORK, 后者目前只被网络段用到、CI 碰不到,但是同一颗雷),并在两处写明为什么不能用 -t。
🔴 量基线时又踩了一次「剥凭据」的坑
只藏 admin/.env.local 量出 PASS=13 SKIP=2,看着像「大部分都能验」; 实则 P0-P6 正在拿本机 macOS Keychain(security find-generic-password -s lampio-supabase-db)读生产库。把 security 也用失败桩顶掉后,真实数字才是 PASS=7 SKIP=8。 ⇒ 「剥凭据」必须同时剥三样:admin/.env.local + Keychain + SUPABASE_DB_URL, 少一样就会量出假数并据此做错决定(2026-08-29 ops-verify 被误判成最佳 CI 候选是同一个坑)。
原文留档如下。
🟢 CI 接线的四条尾巴 —— 四条全部做完(2026-08-30)
为什么现在才登记:这四条登记在 archive/post-merge-repo-optimization-plan.md §1 台账的 T3-1 结论段里(不是独立编号任务),2026-08-30 该计划 13/13 归档时没有搬家。 即它们从活跃视野里消失了,而事情还没做 —— 正是 README.md 那条 「plan 会归档,尾项不会」的实例。交接 prompt 原文见归档件的「会话 C」段。
按性价比排序,前两条已于 2026-08-30 做完,剩下两条是设计决定:
1. release-verify.sh 拆网络段与静态段 ✅ 2026-08-30
release-verify.sh 拆网络段与静态段解法是拆段而不是调基线:新增 --static-only 只跑 R1-R4(三处版本号一致 / semver 判据自检 / 正式版 identifier 硬闸 / updater 接线五环),全是本地文件读 + node + python3 + grep,零网络零凭据。CI 里跑 RB_PROXY= … --static-only --max-skip=0。 实测(代理指向关闭端口 127.0.0.1:9):静态段 0.45s / SKIP=0,全量 20.1s / SKIP=10。 CI 首跑与本机沙箱预测逐字相同(PASS=4 FAIL=0 SKIP=0)。 反向注入 4/4 全红。顺手把汇总+退出收敛成单一 finish()(免蹈 sync-verify 那个 「早退路径各抄一份、--max-skip 在唯一会走到的 CI 路径上完全无效」的覆辙)。
2. 给 ci-verify.yml 配 paths 触发面 ✅ 2026-08-30
ci-verify.yml 配 paths 触发面形状照 ci.yml:以 '**' 起头 + 逐个 ! 排除(不是正向白名单,起点是全仓、新目录漏不了)。 排除项各有实测依据:admin/**(四个脚本一处不读,release-verify 里的 admin/…只在注释里)· landing/**(只有网络段的 R13 读它)· 三个 archive/**(零引用)。 ⚠️ 不能排除 rvh/**:learning-loop-verify 的 L2 要断言 Dart 消费方读的是根那一份黄金向量。 实测探针(短命分支,验完即删):只改 docs/plans/archive/ 的提交 —— CI 触发、 CI (verify) 未触发 ✅。
3. privacy-verify.sh 改走 schedule 而不是 commit 触发 ✅ 2026-08-30
privacy-verify.sh 改走 schedule 而不是 commit 触发新建 .github/workflows/monitor-prod.yml(本仓第一个 schedule 型 workflow), 每天 01:00 UTC 跑 privacy-verify.sh --max-skip=8;同批给该脚本补上 --max-skip (此前只有另外四个 verify 脚本有,那是接线前置)。
🔑 实施中发现的决定性理由,比登记时写的更硬:不仅是「让第三方 uptime 决定 CI 红绿」, 更是 admin 走 Vercel 部署、而 Vercel 不等 GitHub Actions ⇒「commit 绿」与 「线上安全」之间本来就没有因果,真正会出事的时刻(部署完、而没有任何人提交任何东西) 恰恰是 commit 触发永远覆盖不到的。⇒ 只能跟着时间走。
⚠️ 量基线时又踩了一次那个坑,记在这里:只藏 admin/.env.local 量出 PASS=13 SKIP=2, 看着像「大部分都能验」;实则 P0-P6 正在拿本机 macOS Keychain (security find-generic-password -s lampio-supabase-db)读生产库。 把 security 也用失败桩顶掉之后,真实数字才是 PASS=7 SKIP=8。 ⇒ 「剥凭据」必须同时剥三样:admin/.env.local + Keychain + SUPABASE_DB_URL, 少一样就会量出假数并据此做错决定(2026-08-29 ops-verify 被误判成最佳候选是同一个坑)。
反向注入 4/4:站点不可达 → SKIP 14 > 8 → 红 · 本地起一个「门没了」的假站 (PRIVACY_VERIFY_BASE,脚本自带这个口子就是为反向验证)→ 6 条 FAIL → 红 · 不传 --max-skip 时旧行为逐字不变 · 基线正好满足 → 绿。
4. check:readme-paths(新命令) ✅ 2026-08-30
check:readme-paths(新命令)已落地:check-claude-md-paths.mjs 加 --readmes 模式 + pnpm run check:readme-paths
- 接进
ci.yml(**/README.md照**/CLAUDE.md的先例捞回触发面 —— 37 份里有 20 份 住在被paths排掉的admin/landing/rvh/下)。
🔑 文件集用 git ls-files 现取,不写死清单:索引类文档的职责就是指向别处, 是本仓最容易腐烂的一层;写死清单会让「新加的 README 没被纳入」变成静默缺口 —— 那正是这道门要防的病本身。反向注入 4/4,其中一条专门验这点: 新建一份带失效路径的 README,未 git add 时不报、git add -N 之后当场红。
首次全量跑 37 份里有 5 份不合规,逐条订正后才接进来(接一道恒红的门等于没有门): 3 处「反向指路」(说的正是「这个目录已删」)改成不带反引号的散文 · 2 处子树相对(tools/vocabulary_builder_v3/README.md 指它自己的 scripts/)加全前缀。 同批还归一了 4 份里的 8 处 ~/-锚定双仓写法 —— 那种写法匹配不上判据的正则 (要求反引号后紧跟白名单顶层目录),写了等于没守门;归一后才落进守门范围。
📌 2026-08-30 新增了三份索引(
docs/README.md·scripts/README.md·rvh/docs/README.md
rvh/docs/plans/README.md),这条的收益比登记时更高了。🔴 文件集要显式列,别扩成「所有 md」(2026-08-30 实测得出的约束):
check-claude-md-paths.mjs同日新增了一条判据 —— 反引号路径本机存在还不够, 还要 git tracked(防「本机绿 / CI 红」)。把docs/plans/backlog.md喂给它当场红 4 条:admin/.env.local·src-tauri/.env.notarize.local·supabase/.temp/pooler-url·src-tauri/target/release/bundle/macos/Lampio.app。 那 4 条全是合法引用 —— backlog 本来就要谈本机独有的凭据文件与构建产物 (上面那条硬要求说的正是admin/.env.local)。 ⇒ 两条都要:① 新命令的文件集只收索引类文档(README 家族),不收 backlog / plan / verification —— 那三类会正当地谈构建产物(src-tauri/target/release/bundle/…); ② 真实存在、按设计不入仓、且非指名不可的本地配置进GITIGNORED_BY_DESIGN白名单。 后者当天就用上了:scripts/README.md(正是索引类)合法引用admin/.env.local, 已连同src-tauri/.env.notarize.local一起加进白名单。 ⚠️ 构建产物与依赖不属于白名单那一类,它们该写成不带反引号的散文。
🔒 两条硬要求,任何一条动手前都适用:
- 新接的闸门必须带防全 SKIP 假绿机制(
--max-skip=N),且做反向注入实测 —— 故意注入一个缺陷要能让它变红,本机就能做,不依赖 push。 - 量「哪些检查不依赖凭据」时必须先把 gitignored 的
admin/.env.local藏起来。 脚本会set -a; . 它(里面有 service_role + anon key),不藏的话本机沙箱里一大片 「看起来是静态」的检查其实在拿本机凭据读线上库 —— 2026-08-29 就因此把ops-verify误判成最佳 CI 候选(藏前 PASS=19,藏后 PASS=0,接了就是 100% 假绿)。 同理要剥掉psql/gh/ Keychain(security) 与$HOME。
✅ 文档治理尾项 7 条 —— 已结案(2026-08-30)
3 条真做了,1 条早已完成,3 条改判。 逐条:
| # | 结果 | 说明 |
|---|---|---|
① ci.yml 不跑 eslint | ✅ 收口 + 分出真活 | 见下 |
② admin/AGENTS.md 并入 | 🔄 改判:引用而非合并 | 实测确认它是 Next.js 工具链的注入块(<!-- BEGIN:nextjs-agent-rules -->…END),手工删掉多半下次 next 升级又长回来。已在 admin/CLAUDE.md 顶部加一段说明「不要并进来」+ 它是什么。这正是原条目自己的 ⚠️ 预判到的分支 |
| ③ 4 份文档的「最后更新」行 | ⏭ 条目过期,早已完成 | 四份逐一现查:architecture.md / coding-standards.md / ui-standards.md / ui-component-inventory.md 头部都已经改成「以 git log -1 -- <file> 为准」。条目写于它们被修之前,没人回头销账 |
④ temp/ 11 份归位 | ✅ 收进版本控制 | 见下 |
⑤ Edge Function 的 ReadBrowser/1.0 | ✅ 已改并已部署(5 处 / 4 个函数,2026-08-30) | 验收 = 把线上代码下回来比对:5 处 UA 与本地提交逐行对上、ReadBrowser 线上零残留;四个 endpoint 均 401 而非 404/5xx。⚠️ 这是对外可见的出网标识 |
⑥ tools/build_dict.report.txt 归位 | 🔄 改判:留在原地 | tools/README.md 已有一整节裁定过:输出路径写死在 src-tauri/examples/build_dict.rs:19 的 REPORT_OUT_REL,「移动它需要同步改代码,不要顺手挪」;且它是刻意 tracked 的审计报告(人工复核 14 万条 lemmatizer 映射)。原条目的「散在顶层」是观感问题,而真问题(不知道那是什么)已由那一节解决 |
⑦ build_oxford_csv.py 零引用后删 | 🔄 改判:不删 | tools/vocabulary_builder_v3/README.md 第 58-60 行明令禁止这个动作:「它们不被任何代码引用——这是正常的:它们产的是 data/ 下的 pipeline 输入…别因为『零引用』把它们当死脚本删掉,删了就丢失对应输入文件的来历」。其产物 data/oxford_5000.csv(159 KB)正被 bin/build_all.dart / bin/build_wordlist.dart 消费 |
① 的真实内容:作用域缺陷,不是「Deno globals」
原条目写「全仓 507 errors,多在 supabase/functions/ 的 Deno 代码」,两点都不对。 实测 243,且其中 156 个(64%)在 admin/ landing/ supabase/ 里 —— 根 eslint.config.js 只 ignore 了 dist,于是 eslint . 走进那四个各有自己工具链与 lint 的目录(admin/ 的 lint 由 ci-admin.yml 在跑,它和 landing/ 都有自己的 eslint.config.mjs)。
做了两件:① 作用域限定到桌面端本体(与 ci.yml 的 paths 取反、四个 dev-flow skill 排除 rvh//admin/ 是同一条原则:根级工具的作用域必须显式限定); ② 按原条目给的第二条路「显式接受两条 CI 严格度不同」,把裁定与订正后的数字写进 .github/workflows/ci.yml 头注释。
限定后真实存量 44 个全在 src/(25 个是 react-hooks/set-state-in-effect)—— 那是产品代码重构,已单独立条(有规则分档、热点文件、做法与两条 ⚠️),不混在这里。
④ 的判据:它们是那一轮跨端协调的唯一存世记录
11 份全是 2026-04/05 Bug A–G(vocab-pk Phase 3)那一轮的交接稿与会话 prompt, 躺在 gitignored 的 temp/ 下 —— 一次 rm -rf temp/ 就永久消失, 而 docs/cross-end/archive/01-vocabulary-word-pk-log.md(3 处)与 rvh/CHANGELOG.md(1 处) 都在按 temp/… 引用它们。
全部移进 ../cross-end/archive/,一份没删 —— 本仓多条红线(#5d watermark / #9 归一)能追到这批文件,结论进了红线与 CHANGELOG, 但「当时怎么把两个会话协调起来的」只在这里。该目录 README 已补一节逐份说明 + 路径换算(老文档里的 temp/<名字>.md = 今天的 docs/cross-end/archive/<名字>.md, 冻结件不去改,同 ~/reading_vocab_helper/… 的处理方式)+ 为何不补 NN- 编号(时序语义)。
✅ 本条已完全结清:⑤ 的 UA 改动于 2026-08-30 经
/edge-deploy部署到线上, 四个函数(tts-synthesize/lookup-or-fetch-word/analyze-articles/discover-sites) 全部Deployed,下载比对确认线上跑的就是提交的那份。
原文留档如下。
🟢 文档治理尾项 —— 7 条(2026-08-30 自 docs/plans/archive/docs-governance-plan.md 转入)
来源:docs-governance-plan.md(2026-08-14 立项)2026-08-30 逐项核实后归档。 该方案的 P0/P1/P2/P3 主体全部已落实,下面是核实时确认仍未做的部分。 每条都很小、互不依赖、可在触达时顺手做掉;都不是红线。
归档件里有逐项核实表(判定 + 依据),要看「为什么判它已完成」去那里。 另两项不在这里:P1.5(backlog 分片)与 P5(22 MB SQL + 历史重写)分别由
post-merge-repo-optimization-plan.md的 T4-3 / T4-2 接手。
① ci.yml 不跑 eslint(原 P3.4 的另一半)
ci-admin.yml 跑 pnpm lint,根 ci.yml 不跑 —— 两条 CI 严格度不一致。 已改判为「先清存量再入闸」:全仓 eslint . 现 507 errors,多在 supabase/functions/ 的 Deno 代码(eslint 不认 Deno globals + 大量 any)。 理由已写进 .github/workflows/ci.yml 顶部注释,那里是这条的实际归属。 做法:要么给 Deno 目录单独配置 / 加 ignore 后入闸,要么显式接受两条 CI 不同严格度并把它写死在注释里。 ⚠️ 别直接把 pnpm lint 加进 CI —— 会立刻恒红,而「一道恒红的闸门等于没有闸门」是本仓 2026-08-18 踩过的(连红 6 天)。
② admin/AGENTS.md(5 行)并入 admin/CLAUDE.md
内容是 Next.js 的 <!-- BEGIN:nextjs-agent-rules --> 注入块(「先读 node_modules/next/dist/docs/」)。 ⚠️ 并入前先确认它是不是 Next.js 工具链自动生成/维护的 —— 带 BEGIN/END 标记的块通常会被重新写回, 手工删掉可能下次 next 升级又长回来。若确是自动块,正确做法是在 admin/CLAUDE.md 里引用它而不是合并。
③ 4 份活跃文档的头部「最后更新」行(原 P4)
docs/architecture.md · docs/coding-standards.md · docs/ui-standards.md · docs/ui-component-inventory.md 仍带这行(database-schema.md / database-tables-overview.md 的已删)。 做法:直接删掉,不是去更新它 —— 它注定漂移,git 才是真相源。 同款铁律见 ../verification/README.md 与 ../README.md(「体量数字刻意不写」)。
④ temp/ 下 11 份跨端 handoff / session-prompt 归位(原 P4)
temp/ 已 gitignore(.gitignore:44),但里面躺的是文档不是临时文件 (bug-a-handoff-to-rvh.md / rvh-cover-image-mirror-session-prompt.md 等)。 做法:有价值的移进 docs/cross-end/(按 README 总表发号),无价值的删。 ⚠️ 它们没进版本控制 —— 清 temp/ 之前先做这一步,否则直接消失。
⑤ Edge Function 的 User-Agent: 'ReadBrowser/1.0'(原 P4 品牌改名扫尾)
⚠️ 原方案写「×3」已过期:2026-08-30 重数是 5 处 / 4 个函数 —— analyze-articles(2 处)· discover-sites · lookup-or-fetch-word · tts-synthesize。 做法:改 Lampio/1.0(保留各自的括号后缀)。 ⚠️ 改完要 /edge-deploy 才生效,且这是对外可见的出网标识 —— 若有站点按 UA 做过白名单,改名会改变行为。
⑥ tools/build_dict.report.txt 归位(原 P3.3)
28 KB 生成报告散在 tools/ 顶层。做法:移进产出它的子目录,或 gitignore。
⑦ tools/vocabulary_builder_v3/scripts/build_oxford_csv.py 零引用(原 P3.3)
做法:再跑一次全仓引用检查确认零引用后删。
✅ check-doc-links.mjs 漏检同目录相对链接 —— 已修(2026-08-30)
as-built:脚本改为双模式。模式 A(仓根全路径)行为逐字不变、且收回只跑根侧; 新增模式 B 解析 [文字](目标) 并按引用文件所在目录解析后判存在,范围扩到全仓活跃文档 (含 rvh/docs/ admin/docs/ 与 7 份 CLAUDE.md)。选的是原文里的方案 A。
🔑 实施中比原文多出来的三件事:
① 模式 A 必须收回根侧,不能一起放开。 实测把它放到子项目树上立刻多 26 条误报 —— rvh/docs/ 里裸写的 docs/plans/<某份>.md 指的是该子树自己的 plans 目录,不是仓根的。 这正是原脚本那条 rvh/ 负向 lookbehind 在挡的歧义,也是 /docs-audit 早就记下的「三类不算腐烂」之一(子项目相对路径)。模式 B 没有这个歧义。
② 行内代码要换成 _ 而不是删掉。 本仓主流写法是「链接标签包在反引号里、目标不包」, 直接删反引号内容会把标签整个抹掉,链接语法当场不成立,模式 B 匹配不上 —— 那是丢覆盖面。 换成单个 _ 则标签仍在、链接仍可匹配,而整条被反引号包住的示例才会正确地不匹配。 不做这一步就会把「描述本 bug 时引用的示例写法」误报(原文那句正好中招)。 ⚠️ 这条纪律对写 as-built 的人同样成立:正文里举例用的假路径要写成 docs/plans/<某份>.md 这种带尖括号的形状,否则模式 A 会把它当真引用 —— 本条目初稿就被自己这道闸门当场抓了两处。
③ 合仓的收益之一在这里才真正兑现:rvh/docs/ 59 份文档此前没有任何链接闸门 (rvh/scripts/doc-consistency-check.sh 的 Step 4「断链检查」是一张写死 3 个文件名的 黑名单,对新造的悬挂恒绿)。首次覆盖当场扫出 12 处真悬挂,见同日 commit。
反向注入 4/4 实测(本机,当场还原):① 根侧同目录相对链接指向已归档 plan → 红 · ② rvh/docs/ 悬挂相对链接 → 红(新覆盖面)· ③ 根侧裸写全路径悬挂 → 红(模式 A 无回归)· ④ rvh/docs/ 里合法的子树内裸路径 → 不红(证明收窄没造误报)。
⚠️ 原文第二个面(「目标存在但换了含义」)只解决了一半:归档造成的层级变化现在会 暴露(相对基准变了 ⇒ 多半解析不到),但
](README.md)这类两个位置都有同名文件 的情形仍然恒真。要根治得比对「引用时预期的那一份」,超出链接检查的能力范围 —— 不做。
原文留档如下。
🟡 check-doc-links.mjs 漏检同目录相对链接 —— 归档动作最常造的那一类它看不见(2026-08-30 实测)
来源:post-merge-repo-optimization-plan.md T4-2 收尾时撞上。
症状:把两份 plan git mv 进 archive/ 之后,node scripts/check-doc-links.mjs 当场绿, 而 docs/plans/backlog.md / backlog-archive.md 里实际已有 3 处悬挂链接 (写法是 [x](post-merge-repo-optimization-plan.md),归档后解析到 docs/plans/post-merge-...-plan.md —— 那个位置已不存在)。
根因:脚本的 LINK_RE 只匹配以 docs/plans/ 或 docs/cross-end/ 开头的全路径:
js
const LINK_RE = /(?<!~\/reading_vocab_helper\/)(?<!rvh\/)docs\/(?:plans|cross-end)\/[A-Za-z0-9_./-]+\.(?:md|html|json)/g;而 docs/plans/*.md 之间互相引用时按惯例写同目录相对路径(不带 docs/plans/ 前缀), 正好落在正则外面。⇒ 闸门防的是「别的目录引用 plans」,恰恰漏掉「plans 内部互相引用」, 而后者正是归档动作最高频制造的悬挂类型(T4-1 与本次 T4-2 各撞一次,两次都靠人写一次性脚本才扫出来)。
⚠️ 同一个盲区还有第二个面:链接指向的文件存在但换了含义时它也不报。 本次 git mv 后,plan 内部 5 处 ](README.md) 从「docs/plans/README.md」静默变成 「docs/plans/archive/README.md」—— 两个文件都存在,existsSync 恒真。
做法(未裁定,倾向 A):
- A:改成解析 markdown 链接语法
[...](...)后按引用文件所在目录做相对解析, 再existsSync。覆盖面从「全路径」扩到全部相对链接,顺带把上面第二个面也接近解决 (至少归档时会因层级变化而暴露)。⚠️ 要保留现有那两条负向断言 (~/reading_vocab_helper/与rvh/前缀)与PLACEHOLDER_RE,否则闸门会恒红 —— 本仓 2026-08-18 踩过。 - B:只在正则上补一条「同目录裸文件名」的分支。改动小,但解决不了第二个面, 且裸文件名在正文里歧义大(会把「提一嘴某个文件名」当成链接),误报风险高。
为什么记着而不顺手做:T4-2 明令「不与任何改动同批」,本次只做了修 9 处悬挂这件事本身, 没动脚本。这条属独立的闸门加固,可在下次触达 scripts/check-doc-links.mjs 时做。
验收:改完必须反向注入实证 —— 在 docs/plans/ 某份活跃文档里塞一条指向已归档 plan 的 同目录相对链接,脚本当场红;去掉,绿。(否则无法区分「真的能查」与「换了个写法照样漏」。)
✅ 指纹清单「发版后必须重生成」的闸门 —— 已接进 /release Step 8.1(2026-08-30)
as-built:/release skill 新增 Step 8.1(硬步骤)+ 护栏 #10 —— publish 之后跑 ./scripts/migration-verify.sh --update-manifest,有变更就用 pathspec 提交清单。 实测该命令可跑且幂等(无新发版时打印「清单已是最新,未改动」)。 🔒 仍刻意不做成 CI 硬闸(会在「发版当天 → 跑清单」之间恒红,与恒绿一样坏)—— 让它跟着发版动作走,不跟着时间走。 docs/verification/data-baseline.md 的 K3 已标 ✅、E2 已改写(跳过它仍然不会有东西变红, 所以那一格继续要人来问)。
原文留档如下。
来源:建 docs/verification/data-baseline.md 那轮。 判断层与「什么条件下重新处理」在该清单 §4 K3,这里只登记要动手的那部分。
🔴 已出厂迁移的指纹清单没有「发版后必须重生成」的闸门
src-tauri/src/db/shipped_migrations.txt 是「哪些迁移已经进了野外」的真相载体, migration_chain_tests::shipped_migrations_are_byte_frozen 靠它守住红线 #11 的 内联迁移那一半(v28+ 在 2026-08-27 之前完全没有守门)。
清单由 ./scripts/migration-verify.sh --update-manifest 从发布 tag 现推 —— 但没有任何东西强制发版之后去跑它。不跑的后果是:这一版新出厂的那几条迁移 回到「改了不会有任何东西变红」的状态,而那正是本轮要根治的病。
刻意不做成硬闸:那会在「发版当天 → 跑清单」之间恒红,而恒红的闸门与恒绿的一样坏。 修法是接进 /release 的收尾步骤(publish 之后自动跑一次并提交清单变更), 让它跟着发版动作走,而不是跟着时间走。
⚠️ 顺带:/release 的 SKILL.md 里已知有过期数字(见 release-chain.md K6), 动它的时候一并核一遍。
✅ 发版链路两条「散文约束没有断言」—— 均已补上断言(2026-08-30)
as-built:
- ①
scripts/release.sh的cmd_promote_updater开头加gh release view --json isDraft前置断言:草稿即拒绝,并提示gh release edit <v> --repo ... --draft=false。 反向注入实测(假gh顶掉真gh,全程不触网):isDraft=true→ 拒绝且不进入 download;false→ 放行。release-verify.shR12 的注释同步改写 —— 它不退役,分工变成 「promote 侧事前拦截 / R12 事后复核」(endpoint 仍可能被别的路径改坏,比如手工传 latest.json)。 - ②
scripts/gen-latest-json.mjs从release.yml的矩阵 target 现推期望平台集合 (判据与release-verify.shR8 同构:同一张映射表、同一条正则;两处刻意不分叉 —— R8 事后复核、这里事前拦截),集合不等即exit 1。读不到 workflow / 推不出平台也exit 1(fail-closed,否则这道闸会变成恒绿)。反向注入实测:抽掉 windows 那条腿的制品, 旧版静默产出 2 平台 latest.json 且 rc=0,新版 rc=1;cwd 不在仓根时也确实红。
docs/verification/release-chain.md 的 K2 / K3 已标 ✅,T3 那格改写为「两处都在」。 顺带把 K6 点名的 SKILL.md 过期数字修了(.msi 事实错误改对;「当前 0.1.0-dev.8」 删掉这个数而不是更新成 dev.10 —— 更新一次只会再漂一次,正是 K6 自己总结的教训。 三处作为历史事实的 0.1.0-dev.8 刻意保留)。
原文留档如下。
来源:建 docs/verification/release-chain.md 那轮的实测。 判断层与「什么条件下重新处理」都在该清单的 §4 已知未修,这里只登记要动手的那部分。
两条是同一个形状:约束写在注释/散文里,代码没有断言。而这个域的失效不可观测—— 出事时没有报错、没有告警,只有存量用户悄悄停在旧版本上。
① 🔴 promote-updater 缺「目标版本的公开 Release 已 publish」前置断言
scripts/release.sh 的 cmd_promote_updater 注释写着「前置:该版本的公开 Release 已 publish (其 updater 制品对外可下,latest.json 里的 URL 才有效)」,但那只是散文:
- 它用你的 token 调
gh release download,草稿态照样取得到 latest.json; - 取到之后原样推到
updater-latest(公开,非草稿); - 于是 endpoint 上线了,而客户端匿名去下资产是 404。
两边都返回"成功",只有用户那头是坏的——典型的「拿到成功返回 ≠ 那件事真的发生了」。 顺序目前靠 /release skill 的 Step 7 → 7.5 保证,但任何人手跑一次 promote-updater 就绕过了。
修法:cmd_promote_updater 里先查一次目标 release 的 isDraft,为真则拒绝并提示先转正。 gh release view "$version" --repo "$PUBLIC_REPO" --json isDraft 即可,无新依赖。
验证:release-verify.sh R12 会在事后报出来(那是发布之后才发现);修完之后 这条前置断言把它提前到发布之前。
② 🔴 gen-latest-json.mjs 不核对平台数是否等于构建矩阵
脚本只在零个 updater 制品时报错(found.length === 0)。配合 release.yml 里 upload-artifact 的 if-no-files-found: warn,一条腿"成功了但没产出制品"的情况下, 会静默生成一个只有两个平台的 latest.json 并上线。
后果是最难发现的一种:只有那个平台的存量用户永远收不到更新,其余平台一切正常—— Release 页、下载页、看板三路版本字符串全部一致,什么都不会红。
当前的兜底只有 needs: build(矩阵任一条腿失败才不会走到 publish),漏的正是 「腿成功、制品没出」这一格。
修法:gen-latest-json.mjs 从 release.yml 的矩阵 target 现推期望平台集合 (release-verify.sh R8 已经这么做了,判据可直接抄过去),不等就 exit 1, 把失败挡在生成这一步而不是发布之后。
✅ 周报过期不清空 —— 已落地(2026-08-14,从议题 C 派生)
as-built:Rust 侧 get_cached_reading_report 删掉按年龄返回 None 的分支(REPORT_TTL_DAYS 常量随之删除,REPORT_WINDOW_DAYS 保留——判据本来就是"年龄 ≥ 它描述的窗口长度"); 前端 WeeklyReportCard 按 generated_at 算 staleDays,替代(不并列)页脚的「生成于 X」 显示「已过去 N 天」,weeklyStale 三处 locale 对齐。措辞只陈述事实、不催办——「该重新生成了」 那类是催办,而按钮就在同一行。没有上限:60 天前的报告照样显示,页脚自己会说清楚。
留原始推导在下方(🟢 条目原文),因为它给出的分界线还要继续用: 不调 LLM 的自动合法,调 LLM 的自动才是红线。
触发条件(已完成,留档):下次碰 WeeklyReportCard / report.rs 时顺手;很小(半小时级)。 纯 RB 本地,零 LLM、零新数据流、零 Supabase、零 RVH。
现状:get_cached_reading_report 在报告年龄 ≥ REPORT_TTL_DAYS(7) 时返回 None (report.rs:354),前端随之退回 idle 空态 —— 用户第二周回到 Stats,上一份报告凭空消失, 只剩一颗按钮,且唯一拿回任何内容的方式是再烧一次 LLM。
为什么现在该改:2026-08-14 口径统一时加了「覆盖区间 + 生成于 X」页脚,就是为了让陈旧度 自证。陈旧度既然已经可见,硬过期就是多余的——它在用「删掉信息」解决一个「标注信息」已经 解决的问题。而一份 9 天前的回顾对用户仍有价值(「上次说我在读天文」),删掉它没有换来任何东西。
改法:get_cached_reading_report 不再按年龄返回 None,改为照常返回 + 附一个 stale 标记 (或让前端按 generated_at 自行判定,Rust 侧只删那段年龄检查——更小);前端在过期时把页脚 措辞换成「这份回顾覆盖 X–Y,已过去 N 天」+ 保留「重新生成」。 ⚠️ 别顺手加「过期就自动重生成」——那是议题 C 已否决的东西(见 §2.2〈二次复评〉)。 🔒 判据分界:不调 LLM 的自动(例如自动展示旧报告)合法;调 LLM 的自动才是红线。
✅ 隐私政策补上周报 payload —— 文案已改(2026-08-14,议题 C 顺带发现)
as-built(landing/lib/legal.ts,zh + en 各三处):① AI 文本处理条目下新增一条 「AI 阅读回顾(仅在你点『生成回顾』时)」,如实列出发送内容(聚合统计 + 读过的页面标题与 网站域名 + 新保存的生词)并写明「只在你每次主动点击时发生,不会在后台自动进行」「不发送 文章正文」「服务器不保存这些内容(只记录调用量与费用)」;② 原句「任何情况下我们只发送 这些句子」收窄为「这项功能任何情况下只发送这些句子」——它本来就只在说语境助手,写成 全局断言才与周报冲突;③ 摘要 list 的「阅读过的页面地址」补成「地址与标题」,第三方服务 的 AI 条目补上「阅读回顾」。LAST_UPDATED → 2026-08-14。tsc + build 均通过。
剩余两步:
- [ ] 部署:landing 由 Vercel 在推送后自动部署;本次只本地提交,未推送。
- [ ] 知会 RVH(新会话,CLAUDE.md §9):见本文件 §「知会 RVH:隐私政策已改」。
触发条件(已完成,留档):下次改 landing/lib/legal.ts 时一并;或正式版发布前的合规过一遍时。 landing 侧(+ 知会 RVH),非 RB 代码。
legal.ts 的「AI 文本处理」条目枚举的是「你选中的文本或其所在句子」,并写明 「任何情况下我们只发送这些句子,绝不发送整页内容」。但 AI 周报(generate_reading_report) 发的是阅读历史摘要:读过的标题 + 域名 + 生词 + 统计数字(report.rs 的 POST body)。 它既不是「句子」,也不是「整页内容」—— 现有措辞没有覆盖它,而这份政策自我定位是 「白话诚实版…如实披露真实数据流」(legal.ts:2)。
注意这不是「偷偷上传」:它 100% 由用户点「生成回顾」触发,落在「你主动发起的请求」那一档, 且服务端不留内容(llm_call_log 只存 token/成本/延迟)。缺的是枚举完整性,不是行为越界。
改法:在 AI 文本处理条目里补一句周报(用户点击触发、发送读过的标题/域名/生词、不发正文), zh + en 两份同步。⚠️ 据本文件 §「知会 RVH:隐私政策已改」条目,这是双端共用对外文本 → 改完要知会 RVH(新会话,CLAUDE.md §9)。
✅ CEFR 配色改冷色系 —— 已落地(2026-08-12)
暖色难度坡 → 冷色系 + 亮/暗底双色板(按页面背景实测择一)。 定稿色值、设计原则(序信号 = 与背景的对比度,不是明度)、落点与跨端约定 全部记在 docs/cross-end/22-rb-cefr-cool-palette-alignment.md。 改 CEFR 配色(任一端)前先读那份。本条留一行指路,下次清理 backlog 时可删。
✅ App-shell Item C:复习期 Settings/Account Modal 逃逸 —— 已完成(roadmap 3-4 残留,2026-08-01)
背景:复习会话中点 ActivityBar 的 Settings/Account →
switchModule触发endSession(),丢会话。 原 review-focus-mode-plan §2.3 的 Settings-Modal 方案,模块化后落地。
✅ 已落地 + live 验证(2026-08-01,as-built app-shell-redesign-plan.md Item C 段):
- 新增
src/components/modals/SettingsModal.tsx/AccountModal.tsx:<Modal title size><div h-[70vh]><SettingsPanel/AuthPanel/></div></Modal>。定高容器解决 panelh-full在 Modal 内塌陷 + 双滚动。Modal 自带useHideContentWebviewsWhileOpen——复习期盖住 review-source webview,关闭恢复。 src/components/ActivityBar.tsx:switchModule('settings'|'account')当isInFocusSession为真 → 本地useState(focusModal)开对应 Modal 而非切模块(不 endSession);ActivityBar 渲染 Modal。- live 验(dev app + MCP):Review 会话中点 ⚙ → Modal 弹出、背后 Review 保活变暗;
rb_state review确认isInFocusSession仍 true +preFocusSnapshot完好;关 Modal → 完整回到原复习态。
✅ release.sh verify-notarized 验错对象 —— 已修(2026-08-01)
背景:dev.10 发版 Step 5.5
verify-notarized对 DMG 容器跑spctl/stapler,报source=Unnotarized Developer ID+ stapler 未装订 —— 假阴性。tauri 是对.app公证 + staple, 再打包/签名外层 DMG,ticket 在 DMG 内部的.app上,不在 DMG 容器上。
✅ 已修(2026-08-01):cmd_verify_notarized 改为 hdiutil attach -readonly 挂载 DMG → 对内部 *.app 跑 codesign -dv + spctl -a -vvv + xcrun stapler validate → hdiutil detach。实跑 dev.10 全绿(spctl accepted / Notarized Developer ID + stapler worked)。顺带修 tag-push 等 6 个 tag/release 消费命令的 v 前缀不对称(新增 vtag helper 统一补 v,根治「bump 收裸版本号、tag-push 原样用 → 传裸版本号造出 不匹配 v* glob 的坏 tag」踩坑)。
✅ 遥测 4-1 收尾残留 —— 全部完成(2026-07-29;#1 建表 + #2 admin 看板 + #3 端到端实测)
背景:roadmap 4-1 opt-in 匿名遥测主体已落地(客户端采集 + 本地缓冲 v28 + opt-in 开关 + 隐私政策改写,as-built
telemetry-opt-in-plan.md)。后端选型 = 自建 Supabase 事件表。以下为剩余尾巴。
1. ✅ Supabase Dashboard 建 usage_events 表(2026-07-29 完成 + RLS 实测)
- 用户已在 Dashboard 建表。DDL 权威源
supabase/sql/observability-tables.sql(表 + anon/authenticated INSERT-only RLS,无 SELECT)。 - 实测通过(curl 走本机代理,用 flush 完全相同的 anon key 路径):anon INSERT → HTTP 201(客户端能投递);anon SELECT → HTTP 200
[](RLS 锁死读,仅 service_role 可见)。与其它运营表不同的 anon INSERT-only 策略正确生效。 - ⚠️ 遗留一条测试行
install_id=selftest-4-1/platform=selftest/event_name=selftest_insert——anon 无 DELETE 权限删不掉,须 Dashboard 手动删,或 admin 看板查询按platform != 'selftest'过滤掉。
2. ✅ admin 看板消费 usage_events(2026-07-29 完成)
- gen-types 已刷新(
pnpm gen:types,usage_eventsRow 类型已进admin/lib/database.types.ts)。 admin/lib/queries.ts新增(仿getLlmLoglist 型 +getCostTrendgroup 聚合型):getUsageEvents(limit)(原始列表)、getUsageDailyByEvent(30)(每日事件量按 event_name 堆叠)、getTriageStats()(指标①首轮完成率 + marked 分布)、getReviewDurationStats()(指标③单卡时长中位数/P25/P75 + 时长桶 + Hard/Easy 占比)。所有查询排除platform='selftest'。- 落点 = 运营监控页新增「使用遥测」tab(
observability/page.tsx+observability-tabs.tsx+ 新组件usage-telemetry-tab.tsx),复用现有 Recharts 范式(同 llm_call_log 成本看板同页)。 - 原始事件列表(
getUsageEvents(100)→ tab 底部表格,仿「LLM 调用」原始列表 idiom):作 ground-truth 核对 JS 聚合数 + 露出未建图的新event_name(防重蹈死表)+ 看 app_version/platform/install 分布与上报漂移。props 纯数值/枚举无 PII,原样 JSON 展示。 - 验证:
pnpm --dir admin exec tsc --noEmit绿 +pnpm lint绿 +pnpm build绿(/admin/observability为ƒ动态渲染);client/server 边界干净(tab 组件仅 type-only import queries,service_role 只在 server 页 fetch)。 - 死表教训已由本地
usage_events_local(dev 期 rb_db_query 可读)先行兜底,读出口不缺位。
3. ✅ 应用内端到端实机验证通过(2026-07-29,dev app 实跑 + MCP 探针)
- 默认关=零采集:清数据后未开开关就做 triage/复习 →
usage_events_local0 行(telemetry_record全 no-op)。隐私最关键项坐实。 - 开启后全链路:Settings 打开开关 →
telemetry_opt_in=true+ 生成telemetry_install_id+telemetry_triage_first_done=true;走一轮 triage(标 3 词 Esc 退出)+ 复习评分 3 卡 → 落 4 行:triage_session_end{marked:3,completed:false,first_session:true}+ 3×review_card_rated{duration_ms,was_revealed,is_easy}。 - 匿名护栏:props 仅数字/布尔,零 word/句子/URL/user_id。
- 上报:60s polling 搭车 flush → 4 行
synced_at全回填(flush 仅 2xx 时标记 = Supabase 接受写入坐实;叠加 #1 curl anon INSERT 201 双重确认)。 - 未单独实测断网降级(fire-and-forget 代码路径 + Rust 单测已覆盖,风险低)。
✅ 已解决:「schema.sql 数据基线」冻结点 = 0.1.0-dev.8(2026-07-26)
背景:
assets/sql/schema.sql被include_str!当作 v1 迁移本体,sqlx 按 SHA-384 checksum 校验。 编辑它 = 改 v1 指纹 → 任何「编辑前建库、之后未删库」的存量库启动即VersionMismatch→ 中止整条迁移链。
决议(2026-07-26,随 roadmap 1-2 Updater 落地):选定「最后压平一次再冻结」——旧 v1→v27 全链折叠进 v1 baseline(终态等价由 golden 单测 terminal_schema_baseline.txt 锁定),随首个带 updater 的构建 0.1.0-dev.8 发出。自此 schema.sql/v1 永久冻结、append-only,新表/列只进新编号迁移(v28+)。
- 存量 dev.7 用户无 updater → 手动重装 dev.8(一次性重置本地库),此后 v28+ 经 updater 自动推送、永不再重置。
- 守门:
scripts/check-schema-frozen.mjs(SHA 校验,CI)+migrations.rs::flattened_baseline_matches_terminal单测。 - 已同步:CLAUDE.md §5 + 红线 #11(措辞改「已冻结」)、
docs/database-schema.md§10、/releaseSKILL.md Step 1.5、memory。 - as-built:
docs/plans/archive/updater-version-unify-plan.md。
✅ LLM 调用统一到服务端 + 成本可归因 + 配额(2026-08-03 全部完成)
✅ 已落地 → as-built
docs/plans/archive/llm-server-unification-plan.md(2026-08-02,commite41b86e..)。 WP0-WP5 全部完成并部署验证(含 WP3 阶段 2 与 WP4d)。
WP3 阶段 2已完成(a944797:9 个带闸 function 拒匿名 401 + 客户端重试一次 +require_user_token)WP4d已完成(删translate_paragraphs/DeepL/call_chat_api/设置项 UI/9 个孤儿 string key)。 原定「等一个版本周期」的硬序前提证伪:旧路径运行时已不可达(translateParagraphs零调用方、 4 个设置字段只写不读),保留不构成回退路径;而设置面板仍在展示静默失效的 API key 框。三个裸 function 无准入→ 已收敛:gutenberg-proxy(三端零引用)与google-books-proxy(数据流不可达,_googleBooksCoverUrl永为 null)均已删除 (RVH 未对外分发无兼容顾虑,线上 14→12)。全部完成:
lookup-or-fetch-word加闸两步已于 2026-08-03 部署收口。 阻塞已解除:RVH 会话已回填docs/cross-end/18-rvh-edge-jwt-handoff.md§六 —— Dart SDKfunctions.invoke自动携带用户 access_token(AuthHttpClient每请求现取accessToken ?? anon_key),RVH 无需改代码;RVH 有硬 auth gate,未登录不可达任何功能页。 ⚠️ 加闸判据须为「无sub才拒」(RVH 送标准role=authenticatedtoken,无自定义 claim)。附带待办:已随_shared/auth.ts顶部注释与实测不符632406b修正。两步走(因 RVH 结论是客户端 wire 探针、服务端未观测,直接上 401 有 RVH 补词挂掉的风险):
- 第一步 ✅ 已部署并实测(2026-08-03):只记账不拒绝 (
bumpQuota('dict_lookups')+[jwt-probe]日志带x-client-info区分 RB/RVH)。 顺带补上该 function 一直缺的账本。- 第二步 ⏳ 待第一步产出真实观测:验收查询见
supabase/sql/quota-and-attribution.sql末尾。看到dict_lookups的真实 uuid 行 + 日志client=supabase-flutter/… userId=<uuid>即可上unauthenticatedResponse(),并执行同处的dict_lookups_limitDDL。 本条剩余工作没有一项必须改 RVH 代码。下面保留的是第一轮的现状核对,仍然有效;但三处结论已被第二轮推翻,以 plan 为准: ① 定价整节移出(暂时全免费,收费点看产品力)→ 配额闸建成反滥用档而非转化杠杆; ② 不做 BYOK(本条第 291 行的「建议保留 BYOK 逃生舱」作废,理由见 plan §三 D2); ③ 全页双语改段落点按(产品决策:整页常开 = 替代品,关掉了产品其余全部功能; 单段按需 = 脚手架)——「翻译是异类」这个前提随之消失,配额塌缩成统一计数器。 另:TTS 并入范围(
tts-synthesize无记账 +issue-speech-token结构上不可计量)。
用户设想:把 Settings 里的 LLM key 移到服务端统一管理,LLM 成本统一到服务端承担, 减少「必须填 key 才能用」的使用摩擦,后续通过产品定价摊薄成本。 结论:方向合理,且 9/10 已是既成事实;但顺序不能跳,且「key 移到 admin 管理」这一点建议改掉。
现状核对(2026-08-02):客户端还持 key 的只剩「翻译」一个—— translate_api_key(TranslationSettings.tsx 密码框)→ translate.rs::translate_paragraphs 直连 DeepSeek/DeepL/OpenAI/custom。其余 9 个 LLM 功能(breakdown-sentence / resolve-context / disambiguate-sense / generate-example / lookup-or-fetch-word / judge-phrases-batch / reading-report / discover-sites / analyze-articles)+ TTS/跟读(issue-speech-token / tts-synthesize)已全部服务端持 key,走 _shared/llm.ts。 → 所以这不是战略选择题,是补最后一个漏网的收口题;现状本身就不一致(点「拆解」不用填 key、点「翻译」要)。
但翻译不是同类项:其余 9 个都是 per-sentence、用户点一次触发一次、几百 token; 翻译有 bilingual 全页模式(bilingual.js 抓全页段落,Rust 端 50 段一批)—— 一次开双语 = 整篇文章进 LLM。它是唯一一个用户单次操作就能触发无上界 token 量的功能, 搬到服务端 = 把成本曲线最陡的那条接到自己账单上。
服务端当前缺的三件事(对已在服务端的 9 个功能同样是欠账,只是敞口小没暴露):
- 无用户身份 —
supabase.rs所有 edge 调用拿anon_key当 Bearer(107/237/356/583/737 行), 不带用户 JWT;llm_call_log(observability-tables.sql:16)无user_id列 → 算不出每用户成本,而「定价摊薄」的前提恰恰是能算清 ARPU vs COGS。这是硬前置。 - 无准入 — anon key 公开 by design,任何人可直接打 edge function 烧额度。 接上全页翻译后敞口性质变了 → 本文件「🔴 轮换 Supabase anon key」那条会从非紧急待办升级为前置条件。
- 无配额 —
_shared/governance.ts月度预算闸(默认 $5)只挂推荐链路,用户链路 6 个 function 只记账不设闸; 烧穿表现 = 所有 AI 功能全线失败,无优雅降级。
建议修正「key 移到 admin 管理」:key 应留在 Supabase Function Secrets(现状 Deno.env.get('LLM_API_KEY'), 进程内存、不落库、不过网)。搬进表让 admin 界面编辑 = 一次权限失误/XSS 就能读出 key + 每次调用多一次 DB 读。 admin 该管预算参数和配额,不管 key 本身——recommend_config 表已是这个模式的现成先例(阈值进表、key 留 Secrets)。
建议顺序(①②③ 不能跳):
① Edge Function 带用户 JWT(替掉 anon_key 做 Bearer) ← 波及 RVH,须新会话(CLAUDE.md §9)
② llm_call_log 加 user_id + callLLM 接收它 ← 成本可归因
③ 每用户配额闸(按功能分档,翻译单列) ← 敞口封顶
④ 翻译迁 edge(新建 translate-batch,复用 callLLM)
⑤ 定价④ 的已知坑:DeepL 是非 LLM 的专用翻译 API,_shared/llm.ts 覆盖不了 → 要么单独实现要么砍掉该 provider。 配套删除:content-script loadTranslateSettings(5 次 invoke get_setting)、设置页 API key 输入框。
建议保留 BYOK 逃生舱:用户自填 key 降级为「高级设置」而非删除——免费额度耗尽时用户能自救 / 隐私敏感用户有出路 / 自己的成本有泄压阀。与「减少摩擦」不冲突:摩擦来自必须填,不来自可以填。
✅ pipeline 迁 RB 后 Supabase 词库 DDL 归属对齐 — 双端已收尾(2026-07-19)
背景:2026-07-19 pipeline(
vocabulary_builder_v3)反向从 RVH 迁入 RB(commit6a4d0a8)。 Supabase 后端主体(14 Edge Functions + 共享表 DDL)早已统一到 RBsupabase/,但词库/缓冲池这条线的 DDL 归属没跟着 pipeline 迁移一起对齐——审计(2026-07-19)暴露两处尾巴。
✅ RB 侧已处理(2026-07-19,本会话):
- 删除
tools/vocabulary_builder_v3/supabase_migration.sql——死历史文件(打旧表名vocabulary_items, 2026-04-28 word-PK 改造后已不存在;build_all.dart不调;pipeline 活跃上传路径import_to_supabase.dart:112走 REST →vocabulary?on_conflict=word)。缓冲池现表权威 DDL =supabase/sql/vocabulary-buffer.sql。 - 更新
rvh-vocab-pipeline-inventory.md§二 清单。
✅ RVH 侧已处理(2026-07-19,RVH 会话,commit 见 RVH 仓):
- 采方案 A(原地保留 4 个历史 migration 作 git 追溯留痕 + 改写 README banner)——最省事、零风险, 且这些文件是「曾发生的 live 迁移记录」非「当前权威定义」,搬迁/归档只增 churn 无收益。
- 改写
~/reading_vocab_helper/supabase/migrations/README.md:删掉失效的「归 RVH(红线 #10)」保留理由, banner 明确共享同步表 DDL(sync-tables.sql)+ 缓冲池 DDL(vocabulary-buffer.sql)+ Edge Functions + pipeline 真相源全部在 RB,RVH 不再写任何 Supabase migration。 - 审计确认 RVH 仓内无其它散落 Supabase 后端定义:
functions/仅剩 README 指针;Deno.servegrep 空; 唯一.sql命中user_*表名的是 RVH 本地 SQLite schema(assets/sql/01_create_tables.sql)的对齐注释, 非后端 DDL,合法。 - 红线保护:未碰 RVH 运行时 Dart 代码 / 预装库
reading_vocab.db(红线 #10),纯文档 + 归属治理。
✅ 潜在/已学词高亮在 flex 类标题里被挤成多行(2026-07-18 记录 → 2026-07-19 已修复+验证)
现象:CNN 报道页标题
<h1>内被.rb-discover/.rb-highlight包裹的词(如 western / catastrophic)在词前后各产生换行,标题被拆成多行。reader 模式无此问题(reader 重建 DOM, 脱离宿主页标题 CSS)。
- 已试但不够:给
span.rb-highlight.rb-highlight/span.rb-discover.rb-discover加display:inline !important(特异性 0,2,1,压过.headline span类规则)。对「宿主页把子 span 设display:block」的场景有效,但 CNN 标题依旧断行。 - 根因已确认(2026-07-19,实测 CNN 线上 CSS):标题
<h1 class="headline__text vossi-headline_elevate__text">的样式规则为display:flex; flex-direction:column; align-items:flex-start——是个竖向 flex 容器。高亮 span 是 h1 的直接子节点(rb_dom实测), 于是每个匿名文本 run + 每个 span 各成一个 flex item,被flex-direction:column逐个竖排 → 每个高亮词 和每段文本各占一行(截图 5 item = 5 行)。(0,2,1) display:inline !important(styles.js:457)被静默忽略: flex item 的 blockify 是 formatting context 对 used value 的强制转换,display属性(连!important)无法覆盖。 这就是特异性修法无效的确切原因。reader 模式因重建 DOM 脱离该 flex 标题而免疫。 - 修法(已落地,采候选 a 的精确版):包裹前检测文本节点父级 computed
display,是flex/inline-flex/grid/inline-grid就跳过包裹——比「跳过所有h1–h6」更窄:普通 block 标题照常高亮, 只在这类动画 flex/grid 标题里让位。代价 = 该子集标题丢 CEFR 下划线(双击查词仍可用,因走 selection 非包裹); 换display:contents会丢下划线故不取,改父级 display 会伤宿主动画故不取。- 新增
core/utils.js::isFlexOrGridContainer(el)(getComputedStyle读 display,try/catch 兜底)。 - 三处包裹点各加守卫:
highlight.js(CEFR 高亮,hasMatch后)/discovery.js(潜在词,hasMatch后—— panel 词表在 loop 里已收集不受影响)/phrase-highlight.js(短语,wrapPhrasesInTextNode入口)。 - 保留 styles.js:457 的
(0,2,1) display:inline !important(对非 flex 宿主页把子 span 设 block 的场景仍有效、无副作用)。
- 新增
- 验证:CNN elevate 标题
rb_dom实测<h1>内已无.rb-highlight/.rb-discoverspan,恢复正常流式排版不再断行(用户肉眼确认)。
✅ 双击标题栏右侧空白最大化窗口(2026-07-18 已修复+验证)
期望:双击 tab 栏右侧拖拽空白区 → 最大化/还原窗口(macOS 通用操作)。
- 根因:Tauri 2 ACL 权限缺失——
capabilities/default.json里有core:window:allow-start-dragging(所以拖拽正常)但漏了core:window:allow-toggle-maximize。core:window:default默认集只含allow-internal-toggle-maximize(原生 drag-region 双击用)+allow-is-*查询权,不含 JS APItoggleMaximize()需要的allow-toggle-maximize。手动 spacer handler 的getCurrentWindow().toggleMaximize()被 ACL 拒绝 → promise reject → 代码里的void吞掉错误 → 控制台无任何线索的"静默无反应"。handler 逻辑(300ms 双击检测 / preventDefault)本身无罪。 - 修复:(1)
capabilities/default.json补core:window:allow-toggle-maximize;(2)TitleBar.tsx把void ...toggleMaximize()改成.catch(console.error),未来这类 ACL/API 失败可在控制台留痕。 - 教训:Tauri 2 里"某个 window/webview API 静默无反应"第一嫌疑是 capabilities 漏授权;
void吞 promise rejection 会抹掉唯一的诊断线索。
✅ 进入/退出 Reader 模式快捷键 = ⌘⌥R(2026-07-18 已验证)
键位历程:⌘⇧R = WKWebView 原生硬刷新(JS 拦不住)→ ⌘⇧E 撞 macOS 输入法切换 → 最终 ⌘⌥R (保留 R 助记,避开原生刷新 + 输入法;Option 变 e.key 为 ®,故按 e.code=KeyR 匹配)。React 层
useKeyboardShortcuts+ content-scriptevents.js双层。用户实测通过。
方案已定稿:
docs/plans/archive/reading-cleanliness-tiers-plan.md(三档 original/calm/reader、 domain_prefs 枚举前向兼容三纪律、RVH 未来简易阅读预留段、明确不做清单)。 背景:CNN 类重门户阅读体验差;否掉「PC 模拟移动端布局」,采纳三档净度模型。
WP1 重页面 nudge✅ 已完成(2026-07-12,as-built:docs/plans/archive/reader-nudge-wp1-plan.md)WP2 安静模式 calm v1✅ 已完成(2026-07-12,as-built:docs/plans/archive/calm-mode-wp2-plan.md; read_mode 三值枚举 + 三档入口改【原生 popup menu】根治 webview 遮挡(ui-standards §14.4 双轨规范)- 形态启发式实战修正:float 浮动件 / nav-header 语义豁免 / sticky 不判 overlay / exit 清标记自愈)
calm v1.1 · 空广告占位折叠✅ 已实施(2026-07-14,as-built:docs/plans/archive/calm-mode-wp2-plan.md§追加 v1.1):文档流内空广告占位框(CNN 顶部 ~400px ADVERTISEMENT 空白 + 右栏空框, 广告加载失败所致)折叠。谨慎越 v1「不碰文档流」界,仍走形态启发式(空 + 预留高度 + 无媒体,非广告 class 清单)。多重护栏防误折叠懒加载/异步内容:仅 static/relative 定位、 须占可见预留高度、子树无任何已渲染媒体/懒加载标记/背景图、正文(main/article)内一律 不碰、只在用户主动 enter 时一次性扫(不进晚注入 observer);折叠=display:none 回收空间。 埋点[RB-CALM] enter: hid=N (empty=M)供「多站点高频/误伤」实证;正文内占位待实证再评估。 遗留(实机自测):CNN 类重门户 calm 进入后肉眼确认顶部/右栏空框消失、正文与懒加载图无误折叠- macOS 菜单栏 View → 阅读净度 子菜单(低优):全局可发现性 + 未来系统级快捷键挂点; 成本=常驻菜单状态同步机器(切 tab/模式变化更新对勾)+ Windows 无对应形态(自绘 TitleBar 冲突),等快捷键需求出现时一起做
WP3 reader 排版打磨✅ 已完成(2026-07-14,as-built:docs/plans/archive/reader-typography-wp3-plan.md; 共享排版模板(reader+EPUB 同源)+ 暗色画布(--rb-* 变量组 + rb-dark 主题通道,settings 持久源- __rb_setTheme 广播实时源)+ 高亮减重(实线 1.5px/点线 2px)+ 暗纸面 hover 增强三类高亮 + tab 上限可配置)
短语高亮 reader 内重绘✅ 已完成(2026-07-14):Readability 提取剥 class,生词有 reader 内 重绘(reader.js TreeWalker)但短语 pipeline(后落地)没接——reader 里波浪线整体消失。as-built: ①collectBodyTextNodes(scanRoot)扫描根参数化——显式传入优先,reader 模式默认收敛到 overlay (否则命中 overlay 背后仍在文档流的原始<article>→ 波浪线画在隐藏 DOM 上 = 缺口根因),其余走 findMainContentRoot;analyzePhrasesOnPage(scanRoot)/analyzePhrasesImpl(scanRoot)一路透传。 ②reader.js enterReaderMode重绘块改 phrase-first:先analyzePhrasesOnPage(overlay)(复用 per-page deepCache,命中零 LLM;miss 优雅回落重判)再 vocab 重绘(highlightTextNode 跳.rb-phrase内部)。 auto-enter(settings 未加载、highlightEnabled=false)走后续 pipeline pass(默认根已 reader-aware)兜底。 ③ 新增phraseDomRoot()(reader→overlay,否则 document),emitDiscoveryPhrases/__RB_SCROLL_TO_PHRASE/markPhraseSavedOnPage全改用它——面板清单/滚动定位/saved 升级只认 overlay 内 span,不再把隐藏原始 DOM 的同名副本重复上报。纯 content-script、无 Rust/React 改动、bundle 通过。遗留(实机自测):LLM 门控 开/关 × reader 进出 × 面板一致性,需用户跑 dev app 肉眼看 overlay 内波浪线恢复(视觉类缺陷不靠探针)原始页面整页 zoom(⌘+/-)✅ 已完成(2026-07-14,as-built:docs/plans/archive/original-page-zoom-plan.md; TauriWebview::set_zoomwebview 级等比缩放 + per-tab 内存态 zoom(不落库/不同步)+ 两路快捷键捕获 (主窗口 useKeyboardShortcuts / 内容 webview content-script →report_zoom_shortcut→zoom-shortcut事件)- 地址栏 zoom≠100% 时 % pill 点击复位。遗留(实机自测):整页缩放视觉 + 各 tab 独立性需用户跑 dev app 肉眼验收)
- RVH 简易阅读立项时:plan §4 整段进
docs/cross-end/交接文档(RVH 今日未接 domain_prefs,零风险窗口)
✅ roadmap ⑦ 词源/助记——段一 RVH + 段二 RB 全落地(2026-06-15)
段一 RVH commit 0b7a42e 离线生成 word 级 vocabulary.etymology(12743 词,反 folk 纪律 posh/golf/tip 全 NULL)→ 段二 RB byte-equal reseed v20(SHA c81d137…,merge SQL DO UPDATE SET 新增独立列 etymology)+ dictionary.rs/srs.rs 读列解析 JSON + 新 EtymologyAction.tsx(chip→圆角卡,复用 ⑤/④ 原语,en 斜体/zh 正体/roots 一行)贴词头一次 (WordDetailView + ReviewCardBack + ClozeRevealCard)。弹窗(content-script)入口留 v2。 详 docs/plans/archive/etymology-mnemonic-plan.md + docs/cross-end/12-rb-etymology-reseed-confirmation.md。
✅ 释义弹窗上下文消歧 Phase 1(LLM 选择式)——已落地 commit d6976c0(2026-06-02)
核心已上线:Edge Function disambiguate-sense(deepseek / json_object / 服务端 confidence≥0.6 门控)+ Rust disambiguate_sense(graceful)+ content-script opt-in(默认关 + 首次隐私确认)+ 仅多义词触发 + 命中置顶/徽标/义项底纹。载荷只发 gloss。实机 number/picture/question 语境消歧准确。完整设计见 docs/plans/archive/word-context-disambiguation-phase1-plan.md。
UX 打磨(2026-06-02 已落地):✅异步调用期词头「• AI」脉冲提示 + 结果平滑过渡 ✅例句轻量化(Georgia serif + 浅灰 + 行首圆点 + 悬挂缩进,去竖条/引号/斜体/块缩进)✅徽标贴在命中 sense(匹配本是 sense 级)+ 措辞「贴合此句」→「贴合语境」✅内存缓存(word+句子;仅当页 + 仅正向结果;学习者重复点击秒出、无双跳)。
呈现方式:方案4 滚动定位(2026-06-03 定稿,替代早期"上浮重排+折叠"):✅命中 sense 不重排——保留全部词性/义项的原序原号(1..N 稳定、无缺口、无乱序)✅用平滑 scrollTo 把命中 sense 带到视口顶 + 绿底高亮(命中在别组时弹窗顺势滚过去 = 平滑过渡,非瞬切)✅词性+CEFR 头 sticky 吸顶,滚动中当前词性始终可见 ✅自然滚动(靠后/末尾项浏览器钳制,不留空白)✅滚动条位置即"第几条/共几条"天然指示。早期 FLIP 上浮 / 去序号 / 折叠其他词性方案已撤销(编号别扭根因是重排)。
剩余 follow-up(独立、不阻塞):
- 持久缓存:
内存缓存仅当页;若要"重开文章/跨会话也命中",再加 Rust+SQLite 持久层→ 已评估,决定不做(2026-06-03)。理由:① 成本侧已无增量——Edge Function 已按hash(word+normalize(sentence))服务端缓存,跨会话/跨用户的 deepseek 调用成本本就摊掉;持久 L2 唯一边际价值是把"命中服务端缓存的那次网络往返(~200ms)"压成本地读(~1ms),且仅限跨页/跨会话重复同 (词,句) 的长尾(同页回点已被内存 L1 覆盖)。② 风险侧有两个强制项:seed 版本失效(VOCABULARY_SEED_VERSION变即义项可能增删/换序——v14"义项精简"已是实例——旧{pos,sense_index}会静默指向错义,即 Phase 0 被弃的"自信标错"失败模式;每次 RVH 治理 reseed 都要清表)+ 隐私清除(缓存含用户阅读句子,须挂红线 5cclear_learning_data_if_user_changed随 user-switch/登出清)。用一张须按版本失效的持久表 + 一条隐私清除义务,换长尾延迟差,投入产出不成立。保留 L1 内存版。未来若真需压延迟,更划算方向是 Edge 返回Cache-Control/ 客户端对已缓存命中做短 TTL 复用,且须先有"跨页重复查是高频痛点"的真实数据支撑。 - v2:例句质量治理后,给消歧载荷每义项附 1 条干净例句提升准确率。
- 置信度露出(可信度增强,P3,RB 侧纯前端):当前消歧体验「太顺滑、太自信」,LLM 选错义项时用户无察觉成本(沿袭 Phase 0 被弃的「自信标错」失败模式,只是概率降低)。触发条件:消歧准确率收到真实负反馈 / 想给「便利 vs 可信」平衡加保险时。方向:在服务端
confidence不高(如 0.6–0.75 边界带)时弱化徽标的绝对感 + 露出次优义项,给用户少数错时的回旋余地——而非沿用当前「命中即权威置顶」的二元呈现。注意与方案4 滚动定位呈现兼容(不重排前提下做徽标分级 / 次优 sense 副标)。当前 Edge Function 已返回 confidence(服务端 ≥0.6 门控),客户端只需把这个连续值用起来而非二值化。 - 运维提醒:
LLM_API_KEY是与 analyze-articles 共用的 Supabase secret,轮换 DeepSeek key 后须同步更新它(改完无需重新部署)。
✅ 预装词库 释义(gloss)+例句 质量治理(跨端专题)——已完成(v14+v15)
v14(减法,已 reseed commit 3c9e6a3):例句去噪(>200/省略号/古拼写/书目/OCR)+ gloss 精简(每词性封顶 5/丢 archaic/去重/裁剪)。 v15(GDEX round2 + 例句生成,已 reseed 见下条 commit):例句 >150 归零;LLM 预生成补全 → ~95% 义项有例句(corpus+llm);新增 example_source 平行数组(corpus/llm)。简报 dictionary-example-quality-rvh-brief.md;RB 记录 docs/cross-end/pos-definitions-v15-examples-reseed.md。
剩余 follow-up(可选,小):
- 「AI 生成」角标:弹窗/词卡对
example_source[i]==='llm'的例句加小角标(透明度+信任)。RB 侧纯前端,可独立做。
长效协议:未来 pos_definitions 任何变更走「RVH 治理 → byte-equal 重导出 → bump version → RB /vocab-reseed」(红线 #10 / CLAUDE.md §9)。
✅ ⑪ AI 阅读周报——已落地(2026-06-07)
周报核心已上线:Edge Function reading-report(照 explain-phrase 骨架,复用 LLM secrets,输入本周 digest 输出 {summary, themes, next_step},graceful→null)+ Rust commands/report.rs::generate_reading_report(滚动 7 天聚合 reading_log/learning_entries/vocabulary + 未读 RSS 候选;空周不调 LLM)+ Stats 顶部 WeeklyReportCard(纯 on-demand、不门控、点即发)+ Part 2 Stats 模块叙事重排(按「读了什么→记了什么→水平→复习效果」排 + SRS 内部指标弱化)。不发文章原文、不重列下方图表数字。完整设计见 docs/plans/archive/ai-weekly-report-plan.md。
未做(future):月报(digest 加 period=month 即可扩);⑫ 跨端学习画像(需 RVH 新会话)。
✅ 指代消解高亮增强(③ 延伸)——已落地(2026-06-11,commit de45f8d)
最终形态超出原 B 设计:panel hover 同色配对(顶部句中代词 + 行内 evidence 引文同绿,highlightSurface + SEMANTIC.referenceHighlight token,整词 \b 匹配防 his→this)+ 原文 hover 绿色持续高亮先行词(代词在不同文本节点时一并高亮,__RB_HIGHLIGHT_REFERENCE / rb-ref-hover 绿样式,debounce 140ms,不滚动)+ click 滚到已存在绿标(scrollToReferenceMark,不重新搜,根治"来回滚动")+ mouseleave 清除。定位走代词锚定(locateReferencePair:句内定位代词 → 取代词之前最近的 evidence = 真回指先行词,修短/常见 evidence 跳错段)。遗留:同节点代词不在原文高亮(见下条 🟢)。详见 docs/plans/archive/context-resolution-plan.md。
✅ 已实施(2026-07-02)——单词/短语 多语境处理
2026-07-01 决策搁置 → 2026-07-02 重启并实施完成(RB 侧)。实施版设计见
docs/plans/archive/multi-context-cloze-plan.md。想法:
word_cloze_contexts去 UNIQUE 允许一词多条真实语境(页面+句子两层);复习卡按repetitions % len自动轮换语境;弹窗「+收藏这个语境」增量收集(想法 C + 想法 B 改良版)。搁置理由:高价值的第 1 条语境已做完(存词即捕获句 → cloze 挖空),当前单语境已拿 ~80% 价值; 第 2..N 条价值递减(只对多义/搭配广度这类深度精通有增量)、轮换依赖用户主动收集(多半不发生)、 兜底例句已托住下限;成本却是 schema 迁移 + 5+ 命令改造 + 跨端欠账,性价比不足。 顺带砍掉 B-1「无语境词页面虚线态」(零语境只来自 RVH 同步/老数据/孤词,边缘情况)。
重启条件:真实用户信号「想在多语境下复习同一词」,或产品主线转向深度精通/多义词掌握。 重启时两条已定设计要点见该 plan §0(语境=(page+sentence)、occurrence 级只放弹窗不上页面)。
2026-07-02 重启讨论:收集侧死结(用户不会主动收藏)的根因确认为「页面上没有可判断/可行动的信号」, 且逐 occurrence 标记「新语境」无区分度(去重按 (source_url, sentence) 精确匹配 → 新页面几乎每句都是新语境)。 收集侧改走 C:查词即自动收录——用户双击已存词/单击已存短语查释义时,句子+消歧 gloss 已算出, 静默入池(去重+封顶+可撤销轻提示),收集变成查词的零决策副产物,不再依赖主动性。短语同路支持 (
handlePhraseLookup已连句传递)。B:hover 惰性探测(悬停已存词时才取句查池、浮出「语境 n/5 · +收藏这句」微 affordance;零全页成本、意图触发)记录为后续可选补充——服务「没查词但想收藏这句」场景, 暂不实施。2026-07-02 实施完成(RB 侧,migration v21):收集侧 C + 复习侧 position list(语境级 stepper 分组 + 揭晓侧 ‹n/m› 切换 + 同页
__rb_reviewSwitch轻量定位 + 跨页 reveal 恢复 +repetitions % len确定轮换)。v18 个性化例句入口随之隐藏(代码/表保留可逆)。跨端欠账照旧:RVH 建复习读表时一起镜像去 UNIQUE。
✅ 笔记管理第二期:四级删除闭环(2026-07-06 RB 侧实施完成,移出 backlog)
已进入实施 → 独立 plan docs/plans/archive/notes-crud-phase2-plan.md(含 RVH 交接清单)。 RB 侧完成:migration v23(word_page_links + reading_pages 加 deleted_at)+ Supabase DDL + remove_reading_page / remove_word_page_link / remove_cloze_contexts_for_sentence 命令 + 复活 UPSERT + sync 双表墓碑传播 + NotesPanel 四级删除 UI(笔记本/来源/句子/单词)。
剩余跨端待办(RVH 新会话):见 plan §八交接清单——RVH 本地 migration 加两列 + sync_repository_impl.dart word_page_links/reading_pages push/pull 墓碑适配。 部署前置:Supabase Dashboard 先执行两条 ALTER … ADD COLUMN IF NOT EXISTS deleted_at (见 sync-tables.sql 注释),再上线带 deleted_at 的新版客户端。
✅ word_cloze_contexts 纳入跨端同步——RB + RVH 双端均已完成(2026-07-09/10)
三端表名统一重构(2026-07)时审计同步覆盖发现:
word_cloze_contexts(用户查词时正在读的真实句子 + surface + sense_gloss,复习卡正面据此挖空)此前是 local-only——用户在 RB 采集的语境,换到 RVH 复习完全看不到(RVH 复习卡只能回落策展例句)。这是唯一"用户学习内容却未跨设备"的表,现已补齐。
RB 侧(2026-07-09,migration v24):
- 表加同步列
updated_at/deleted_at/synced_at;撤销/满池替换/来源级删除全部从硬删除改软删除,insert_cloze_context命中同 (word,sentence) 软删行改为复活(红线 #7 精神)。 - 合并策略(原设计难点):
pull_cloze_contexts三分支墓碑传播(模板=word_page_links,不做 FK 存在性 检查,沿 v16 松耦合设计)+ sense_gloss 取非空一方回填,pull 结束后对本批次触达的每个 word 跑reconcile_cloze_pool(按句 COLLATE NOCASE 去重 + 软删最旧超额行),把两端各封顶 5 条的池子合并后 重新收敛到 ≤5 条,而不是简单 union 成 10 条。 push_cloze_contextson_conflict 业务键(user_id, word, sentence),同 learning_entries/known_words 既有模式。Supabase 新建user_word_cloze_contexts(全新表,非 ALTER,部署前提:须在 Dashboard 手动 执行该表 DDL,见docs/cross-end/16-rb-cloze-context-sync-handoff.md§5——需与用户确认已执行)。- 顺手补齐
clear_user_learning_data(signout 清理)遗漏的word_cloze_contexts/word_personalized_examples/phrase_interaction_log三张含用户内容表(隐私缺口)。 reset-dev-data.shTABLES 数组加user_word_cloze_contexts;cross-end-check.sh的SHARED_TABLES(7 张)/SHARED_SYNC(6 张)/ Section G 列探测均已加入(2026-07-10,RVH 镜像完成后补)。- 完整设计见交接文档
docs/cross-end/16-rb-cloze-context-sync-handoff.md。
✅ RVH 侧镜像(2026-07-10 完成):本地建表(schema v57)+ sync 双向适配(忠实复刻 RB 合并算法,含 _reconcileClozePool)+ 复习卡消费真实语境(落点=背面释义抽屉挖空块,非 RB 的正面挖空范式——RVH 复习正面是"原文聚光灯",与 RB 既有范式不同,属有意设计差异)。RVH 定位 pull-only 消费(不新增本地 采集路径,仅参与 reconcile 去重封顶 + 删词/切用户软删墓碑 push 传播)。完整确认见 ~/reading_vocab_helper/docs/cross-end/17-rvh-cloze-context-sync-confirmation.md。
收尾待办(非阻塞,自然触达时处理):RVH 侧设备级验证清单(schema replay / sync 往返 / 复习卡实机 查看)仍标为待办,见 RVH 确认文档 §4。
📦 第二批 —— 8 条(2026-08-30 移入)
由
post-merge-repo-optimization-plan.mdT4-3 一次性移出,backlog.md从 255 KB 降到约 150 KB。内容逐字保留,只换位置。这批不止「标了 ✅ 的」,还包括三块已结束但体量极大的留痕:
- EPUB 长期阅读 9 条缺陷(47.7 KB,占原 backlog 19%)—— 9 条里 8 条已闭环, 唯一未做的 B8「改名/移动」已在
backlog.md里另起一条精简活跃条目,指回这里看全文。- 外部 backlog 第二轮 / 第三轮(19.9 + 10.8 KB)—— 两批外部 LLM 建议的逐条裁定留痕, 全部 ✅/❌ 已定、采纳项已实施。它们的价值是「防止同一批建议被反复评估」, 而那个价值在归档区一样成立(重开旧议题前 grep 这里),不需要占着活跃区。
✅ 「墓碑父行算不算存在」—— 已裁定并落地(2026-08-26)
判据见 docs/verification/sync-consistency.md §4 K1 与 §2.1 S3/S3a。 裁定结果 = 原三选一里的 ③ 拆开,外加第四件事(补观测)。
裁定与理由(这段别删——CHANGELOG 只记 as-built,不记为什么否掉另两个方案)
面 B(读侧)判为「红线措辞错」,代码是对的。 决定性事实是 watermark:last_sync_at 推进到本轮取回行的 MAX(server_updated_at) (sync/mod.rs),与某行有没有被守卫 skip 掉无关。所以在 FK 落地守卫上加 AND deleted_at IS NULL 不是「下次再试」,是永久跳过。这条推理仓里早有人写下过 (pull/library.rs::pull_reading_pages 的父笔记守卫注释:「watermark 越过后不会重试,把缺父的页 永久丢掉才是更大的风险」),只是没进红线。pull_domain_prefs 那处严格不是因为它更守规矩, 而是它守的是另一类东西 —— note_id 是会驱动未来写入的语义绑定,且有优雅回落(置 NULL)。 真正的不变量是三态不许压成两态(红线 #6c 已按此重写)。
面 A(写侧)判为缺陷,但 backlog 原来给的论据站不住。 「两条删除路径不一致」这个对比不成立:soft_delete_note_tree 带走 links 是因为它删的是页, link 的页侧父行死了;delete_vocabulary 死的是词侧。同一张 join 表的两条边不必然同命。 换用的三条论据:① 同函数隔壁三行的 clear_cloze_context_for 注释明写「删词 = 重置该词的学习语境, 保留旧行会让重新添加时残留已失效的旧池」—— links 是同一形状的残留,同一个函数里对同一个问题 给了两个答案;② 复活语义已经把 SM-2 全部清零(level0/ef 2.5/interval 0),删词的既定语义 就是「抹掉这词的学习记录」;③「留着好让 re-add 恢复来源」不需要活行 —— save_word 的 ON CONFLICT ... DO UPDATE SET deleted_at = NULL 本就会复活软删的 link。 且它不是完全不可见:text_source::doc_has_traces 数 links 时不 join 词表 → 删掉唯一存过的词后,那篇粘贴文档永久锁成不可编辑,且无从解释。
没选 ②(写侧读侧全修):会把 pull 的 FK 落地守卫变成永久丢行路径。 没选 ①(承认立场):那要求「孤儿无害」,而 doc_has_traces 那条已经反证。
顺带查出来:缺口比原记录宽
把 learning_entries 打成墓碑的写路径有 3 条,当时三种行为 —— delete_vocabulary / batch_delete_vocabulary(清 cloze 不清 links)、 known_words::add_known_word(两样都不清,原记录没提到它)、 pull 墓碑传播(跨端删词落到本机,两样都不清)。 最后一条说明孤儿靠写侧根治不了,这正是必须补巡检的理由 —— ②「写侧补齐」会给人「修完就不会再有」的错觉。
落了什么
- 写侧:新增
vocabulary/crud.rs::soft_delete_word_traces(cloze + links),三条路径共用, 父行与子树同一事务;子查询限定「父行已是墓碑」,活词的 link 一条不动 (回归测试word_subtree_tests,含反向断言,注入验证过鉴别力) - 读侧:
pull/mod.rs::parent_state三态 + 三处调用点(pull_word_sources×2 /pull_page_annotations); 顺手修掉 annotations 那处「下一个周期再试」的假注释(watermark 模型下它是错的) - 红线:
CLAUDE.md#6a 补 join 行归属规则 + 「按父表算不按命令名算」;#6c 整条重写为三态规则 - 观测:
monitoring.sql§3b +scripts/db-audit.sh§3b 新增tombstone_parent(warn)
⚠️ 未完:monitoring.sql 要在 Supabase Dashboard 手动执行才生效。 没执行 = 那条兜底不存在。
✅ 例句层露骨内容 —— 已清(2026-08-26 当日做掉)
round30 的露骨词清理只做了释义层,例句层残留 65 条挂在普通可学词下(含 6 个 A1 词)。 🔴 症结在于同轮的载荷 ④ 给全部例句配了中文译文 —— 在此之前学习者可能一眼扫过英文脏话, 之后 slur 是用母语呈现的(date 的「那个小同性恋」是种族 + 性向双重蔑称,挂在 A1 词上)。 那是载荷 ④ 引入的净损害,不是既有问题的延续;且例句会进复习卡(pickFallbackCloze 的回落路径)。
已做掉:删 66 条例句 / 64 词 + 连带 8 条义项(那 8 条的唯一例句必删、删完会空 → 空保护一响即说明正解是整条义项都不该留)。词数 18,925→18,916,例句 86,817→86,693, SHA ae33c8a4…→96a98416…(同日又叠了下一条的 per-POS cefr 修复, 最终交付的是 06cbb4dc… / rb-18916-v28-poscefr-2026-08-26),已 byte-equal 同步进 RVH 仓。
⏱ 没赶上「零协调成本」那个窗口:动手时才发现 RVH 已经收完 round30 并回执了 doc 32, 所以这轮得另开一份交接单 33-rb-explicit-example-cleanup-handoff.md (不是改 doc 31 —— 那是一份已被应答的文档,事后改它会让 doc 32 里对其 §4 的订正 读起来不知所云)。好在 doc 32 落的 prune 是通用集合差,删的 9 个词 RVH 那边自动生效, 额外成本只剩「提交资产 + bump 版本」。
留下的三条经验(下轮 reseed 直接可用):
- 正则只用来召回,不用来裁定。 早期一版把
turkey-cock/cock crowed/Dick Hudson/cocktail全部误命中,按它批量删会毁掉一批正当例句。 498 条命中里 119 条挂普通词上,逐条人工过:删 64 / 整义项删 8 / 留 47。 裁定表33-example-adjudication.tsv。 - 拼读脏话扫不到。
eff you see kay why oh you.(= fuck you,挂see/why下) 在词界锚定的召回里一条都没进 —— 补一层掩码/拼读变体规则才捞出来。 doc 30 §5 当初是肉眼撞见它的,doc 31 §7 原把它记作已知残留。 - 平行数组必须同步删同一下标。
examples/example_translations/example_source按下标配对,只删其一 = 此后每条例句配的都是别人的译文和来源, 无异常、无日志。dropex的预检把「长度不齐」判死而不是猜。
🔴 仍在的义项层残留(明知未做,不是遗漏):有一类词露骨的是义项本身 —— john(嫖客)· madam(老鸨)· commercial(男妓)· steady(固定客)· versatile(性角色)· parachute(阴囊环)· beaver(阴部)等。它们的例句多为临床语域、 并不比 gloss 更露骨,删例句治不了。要处理它们是义项层的产品决策(这些词条该不该有 这个义项),不属于例句层清理的范围,也不该顺手夹带。下次整库 reseed 时可一并裁定。
✅ 新补的 80 个词 per-POS cefr 大面积缺失 —— 已修(2026-08-26 当日)
RVH doc 32 §5.2 真机实测挖出:round30 新补的 80 个词里 25 个的 pos_definitions 完全没有 per-POS cefr 键、18 个部分缺(全 POS 齐全率 新增 46.2% vs 存量 98.7%)。 症状是 RVH 拍照管线的难度筛选读的是 per-POS cefr、不是 primary_cefr_level 列, 缺键的词被判 UNKNOWN 静默丢掉 —— ourselves(A2)「查词能查到、拍照永不进候选」, 而它正是 round30 为补 lemmatizer 缺口才加的词,等于只修好了一半。 另 8 个受损可学词:measles confetti impending insignia undersignedunqualified bioethics cheerleading。
已修:212 个 POS 块补上词级值,齐全率 新增 100% / 存量 100%。 SHA 96a98416…→06cbb4dc…,VOCABULARY_SEED_VERSION → rb-18916-v28-poscefr-2026-08-26。
根因 100% 是 round30 自己造的(RVH 排除了 cefr_source 与 POS 键两个假设,方向对—— 它根本不按词的属性分,而是按哪条载荷写进去的分):
| 来源 | 缺口 | 为什么 |
|---|---|---|
_applyFold 造的 name 块 | 157 / 157 全缺 | 从零构造 POS 块,代码里压根没写 cefr 这个键 |
_applyAdd 从 add_entries 直灌 | 55 / 168 | 素材里就不带,直灌不补 |
| 合计 | 212 | = 全库缺口总数,一个不多 |
留下的两条:
- 加了机械守卫,不靠「下轮记得看一眼」。
apply_round30.dart的事后检查里 现在有一条「缺 per-POScefr的块数只许降不许升」,一升整轮回滚。 理由正是 RVH 那句「单测和 SHA 校验都发现不了」—— 这类缺陷只有真机才看得见, 所以必须有机械闸。做了注入式反向验证:摘掉加固后守卫立刻咬(209→212, exit 1)。 - 🔴 只补缺失,绝不覆盖已有值。 per-POS
cefr对cefr_source='oxford'的词是 权威且可以与primary_cefr_level不同的(above.adj=B1而above.prep=A1)。_archive/backfill_oxford_pos_cefr.dart记着把 per-POS 拍平成 top-level 的那次事故 (~6,271 个多词性词丢了分词性区分)。「无值→填词级值」与「有值→改成词级值」 是两回事,只做前者。
同一份回执里 RVH 还请 RB 清 learning_entries 残行(alway 等已删词形, 否则经 RVH pull 的 backfill 兜底会复活它们)—— RVH 自己的判断是「不可达,不是在 发生的事」(双端用户数据 2026-08-25 已全清、两端字典也没有这些词形,用户造不出这种条目), RB 侧复核同意,未动。若哪天真出现,按 doc 32 §5.1 处理。
📋 外部 backlog 第二轮(发现模块 × 学习统计)—— 逐条裁定(2026-08-12)
来源:~/downloads/readbrowser-discover-optimization-backlog.md(另一 LLM 审视发现页 + 学习统计截图后给的 5 条建议 + 2 条设计原则)。本节是裁定留痕,防止同一批建议被反复评估 (沿 roadmap §六 的做法)。逐条对着代码核过,结论:2 条采纳、1 条改前提后可做、 1 条事实错误(功能已存在)、1 条与既有明确决策冲突。
| 原稿条目 | 裁定 | 去向 |
|---|---|---|
| 一、新用户默认等级 A1 → B1 | ✅ 采纳(问题比原稿描述的严重) | → cefr-cold-start-calibration-plan.md 阶段一 |
| 2.1 首次启动水平自评/校准 | ✅ 采纳(是 roadmap §〇 已采纳未做那半条的具体化) | → 同上,阶段二 |
| 2.2「这周的你」卡片 | ❌ 不做(2026-08-14 二次复评定,由 🟡 降级);同条的「下一步读什么」已按路 2 上线 | 本节留痕 |
| 2.3 发现页复习到期提示条 | ❌ 不做 | 本节留痕 |
| 2.4「为你推荐」补充解释性文案 | ❌ 已存在(2026-06-09 v1 落地) | 指向既有条目 §推荐流个性化 V2 |
| 三、两条发现页设计原则 | ✅ 吸收 | 见下 |
✅❌ 2.2「下一步读什么」= ✅ 已按路 2 上线 / 「这周的你」卡片 = ❌ 不做 —— 本条已结
纯 RB + Supabase,零 RVH。
🔴 2026-08-14 复评:原结论「换候选池后做成可点击」不再推荐(推导见本条〈2026-08-14 复评〉), 已按路 2 落地上线:
next_step改方向性建议(EF prompt 硬规则禁止点名具体文章)+ 周报卡内 「去发现页看看」导流 + Rust 侧删掉未读 RSS 候选查询。commita69f256。🔴 2026-08-14 二次复评:「这周的你」卡片 ❌ 不做(由 🟡 降级),周报维持纯手动生成。 见本条末尾〈2026-08-14 二次复评〉——它是本条的最终状态,先读它;下面的「现状核实」 与「三条前置」保留作事实底稿。
现状核实(原稿说的「纯文本、不可点击」属实,且比它想的更麻烦):
report.rs:54ReadingReport.next_step: String,无 id/url;report.rs:213喂 LLM 的rec_candidates只有 title + feed 标题,连 URL 都没传 —— 所以「前端加个按钮」做不到。
要改四处:候选带 id → EF reading-report prompt 要求回选中项 id → ReadingReport 加 next_step_ref(#[serde(default)] 兼容旧缓存)→ /edge-deploy。1-2 天。
启动前必须先解决的三条(原稿都没提,直接照做会做出一张常年不显示的卡):
- 候选池必须换:现在是「未读 RSS items」。没订阅 feed = 空池,而新用户恰恰没订阅。 发现页真正的推荐源是
recommended_articles(有 url / cefr_level / word_count / body_text), 候选池应改成它或并入。 - 发现页只读
get_cached_reading_report,绝不触发生成 —— 否则打开 app 就烧一次 LLM。 缓存未命中就不显示这张卡(周报缓存是周级的,见WeeklyReportCache)。 - 槽位已被占用:原稿给的位置(问候语→搜索→卡片)正是
HomePage.tsx:474TriageActionCard所在处,而它对新用户恒显示(A1/A2 待分级必 > 0)。两张横幅要定互斥规则 (建议:有待分级 → triage 卡优先;否则 → 周报卡)。
〈2026-08-14 复评〉两个"推荐"入口的勾稽关系
起因:用户问「『下一步读什么』和发现页的推荐算不算两个类似入口?周报是自动还是按周生成?」 查代码后发现三件事实,足以翻转原结论。
事实 ①:周报是纯手动,无任何自动/定时生成。WeeklyReportCard 挂载只 hydrate 缓存(getCachedReadingReport),命中即显示、未命中显示 空态 + 「生成回顾」按钮;generateReadingReport 全仓只有那颗按钮一个调用点。
事实 ②:两个入口的候选池完全不同源 —— 它们不是"同一推荐的两种皮肤":
| 发现页「为你推荐」 | 周报「下一步读什么」 | |
|---|---|---|
| 池子 | recommended_articles(Supabase analyze-articles 抓取 + LLM 评级) | 本地 rss_items 的 is_read=0,ORDER BY published DESC LIMIT 6(report.rs:213) |
| 前提 | 无需订阅(全局池) | 必须订过 RSS,没订 = 空池 |
| 携带信息 | url / cefr_level / word_count / body_text / 难度档 / 在学词命中 | 只有 title + feed 标题,无 URL |
| 排序 | rankArticles + orderByBandThenRotate(甜区×兴趣×在学词) | 纯按发布时间 |
| 可操作 | 点开就读 | 一句话,点不了 |
→ 质量倒挂:叙事上更郑重的那个(AI 写的周回顾),实质是降级版。用户会问"既然发现页能推荐 能点,为什么这份报告里推荐的我点不了?"。更糟的是跨池不可达——周报可能点名一篇发现页 里根本没有的 RSS 文章,用户想读却找不到入口。
事实 ③:时间口径三重不一致(独立缺陷,不管本条怎么走都该顺手统一):
名字 「本周回顾」
数据窗口 滚动 7 天 now - 7 days (report.rs:112)
缓存失效 日历周 按周一算 (report.rs::current_week_start)周三生成的"本周回顾"实际覆盖上周四→本周三;周一因跨周失效清空,但数据窗口只挪一天。 若用户每天点「重新生成」,拿到的是滚动 7 天的日报,却顶着「本周回顾」的名字。 另:「重新生成」每次都覆写缓存并烧一次 LLM,而数据窗口只滚动一天 → 收益不可预期。
为什么原方案(换池 + 可点击)不推荐
换池解决空池,却制造重复:两边都用 recommended_articles 后,就成了同一个池子—— 一个用算法排序展示决策卡,一个用 LLM 挑一条讲成人话。而只加按钮不换池,等于给一个 基于窄池、无 URL 的推荐加权重,比现状更糟。两头都不对。
根子在定位:周报的定位是反思层(report.rs 头注释 + WeeklyReportCard 注释均写明: 把下方图表数字串成"本周你…"的人话叙事)。而"下一步读什么"是前瞻。它混进来是 "一份报告习惯性要有建议"的格式惯性,不是产品需要第二个推荐源——RB 已有专门的前瞻入口, 且做得更好。
三条路(🟢 推荐第 2 条)
| 做法 | 评价 | |
|---|---|---|
| 1 | 周报去掉 next_step,纯反思 | 最干净,但读完戛然而止、少了去处 |
| 2 | 🟢 next_step 改成指向发现页的一句过渡,不点名具体文章(如"你这周偏自然主题,发现页里有几篇相关的") | 推荐 |
| 3 | 换池 recommended_articles 且可点(= 原方案) | 承认重复,接受"同一内容两种呈现" |
选 2 的理由:① 保住周报叙事完整(有下文、有去处);② 不制造第二个推荐源; ③ 天然免疫空池(发现页池子是全局的,总有货);④ 把两个入口从"竞争"改成"串联"—— 周报是反思 → 一句过渡 → 发现页是行动,用户动线单向,不用在两个"推荐"之间判断信哪个。
选 2 还更省事:可取消 next_step 对 rec_candidates 的依赖 —— EF reading-report prompt 里那句"可点名某个候选标题/域名"去掉,LLM 不再需要文章列表, report.rs:213 那段 RSS 查询整段删掉。比原方案(候选带 id + prompt 回 id + next_step_ref + /edge-deploy)改动更小。
⚠️ 上方「三条前置」中的第 2、3 条(发现页只读缓存不触发生成 / 与 TriageActionCard 的槽位 互斥)只在真要往发现页放卡时才适用;选路 2 则不涉及新卡,两条自动落空。
〈2026-08-14 二次复评〉三题连问:C 生成节奏 → A 发现页周报卡 → B 发现页状态行
起因:路 2 上线当天追问三件事——周报要不要自动/后台生成、要不要往发现页放一张「这周的你」、 发现页开场要不要加一句个人化状态行。按 C → A → B 定序(不按编号):C 是上游——周报若变成 「不点按钮也存在」的东西,A 的循环依赖当场消失、那条 ❌ 就得重写;B 是 A 否决后的替代方案。
C. 周报改自动 / 后台生成 —— ❌ 维持纯手动
先把「自动」拆开,三种含义结论不同:
| 变体 | 结论 | 决定性理由 |
|---|---|---|
| C1 推送 / 角标提醒「你的周报好了」 | ❌ | 直接撞 ActivityBar.tsx:187 写死的零 push 红线,无需展开 |
| C2 服务端 cron 定期生成 | ❌ 物理上做不到 | digest 的数据源 reading_log 是 local-only(v27),从不进同步矩阵;阅读时长/词数/域名/标题全在本地 SQLite。服务端要生成,就得先把阅读历史常驻上传——而现在这些数据只在用户按下按钮那一刻过境(llm_call_log 只存 token/成本/延迟/状态,不存内容,见 supabase/sql/observability-tables.sql)。落库是隐私面的实质扩大,不是一个开关 |
| C3 客户端自动(启动时 / 进 Stats 时按 TTL 续期) | ❌ | 见下三条 |
C3 的三条理由(⚠️ 成本不是主要理由——TTL 现在是 7 天,自动化上限 ≤1 次/人/周,钱可忽略):
- 会打破一条现在成立的不变式:每一次 LLM 调用背后都有一个正在等它的人。 自动化后,从不打开 Stats 的用户也会被生成。这才是「零后台成本」这句话真正保护的东西 ——不是账单,是「不替用户决定他现在要什么」。
- 它会成为第二条「未经逐次确认就发送」的通道,而载荷比第一条大一个量级。 对外隐私政策(
landing/lib/legal.ts)现在的口径是:AI 文本处理 =「你选中的文本或其所在句子」, 唯一的默认开启例外是「AI 语境助手」,且配了设置开关。而周报 payload 是阅读历史摘要 (读过的标题 + 域名 + 生词 + 统计数字,见report.rs的 POST body),不是句子。 手动时它落在「你主动发起的请求」那一档;自动后必须 ①改对外法律文本(zh + en,且据本文件 §「知会 RVH:隐私政策已改」条目,这是双端共用对外文本 → 要知会 RVH)②照 AI 语境助手 的先例配一个设置开关。C 不是一个开关,是一次跨 landing 法律文本 + 设置 + 对外承诺的改动。 - 产品面:手动那一下不是摩擦,是授权 + 意图。「回顾」这个动作只在用户想回顾时才成立; 应用主动递上一份关于你这一周的评价,是未被请求的评判——
docs/product.md原则 3 「感知进步不制造焦虑」防的正是这个方向。 ⚠️ 精确说:红线不是「自动」本身,而是「未经请求的评判 + 未逐次确认的上传」。 这个区分很重要——它决定了下面 C' 那种「不调 LLM 的自动」完全合法。
考虑过并否掉的折中:「首次手动生成后自动续期」(把第一次按钮当持续授权)。它能免掉第二周 回来还要再按一次的摩擦,但 ①理由 2、3 只是被削弱不是消失 ②进 Stats 会撞上最长 20s 的 spinner (EDGE_FUNCTION_TIMEOUT_SECS),而现在是「有缓存立刻显示 / 无缓存显示按钮」,两种都不等待 ③它想解决的那个摩擦有零成本解法,见下。
C 里唯一成立的真问题 → 已另立条目:TTL 满 7 天后 get_cached_reading_report 返回 None, 卡片凭空退回空态,上一份报告消失得无影无踪。而 2026-08-14 刚加的「覆盖区间 + 生成于 X」 正是为了让陈旧度自证——陈旧度既然已经可见,硬过期就是多余的(用「删掉信息」去解决一个 「标注信息」已经解决的问题)。→ 见本文件 §「周报过期不清空」。
A.「这周的你」卡片(往发现页放周报卡)—— ❌ 不做(由 🟡 降级)
前一会话给的五条理由,逐条对着代码复核:四条成立、一条要削弱、并另找到一条更强的。
| 原推导 | 复核结论 |
|---|---|
| ① 循环依赖:要显示必须有缓存 → 必须去过 Stats 且点过按钮 → 他在那儿已经读过了 | ✅ 代码确认(getCachedReadingReport 是纯本地读、不触发生成;generateReadingReport 全仓只有那颗按钮一个调用点)。「显示条件与价值指向相反」成立 |
| ② 与刚建立的单向动线反向(Stats 反思 → 发现页行动) | ✅ 成立,且现在已是上线代码里写死的(WeeklyReportCard.tsx 那段 🔒 注释 + switchModule('discover'))。发现页再展示周报内容 = 自指回环 |
| ③ 陈旧度在高频面被放大(缓存最长 7 天,发现页是日常入口) | ✅ 成立 |
| ④ 违反「一句话状态=互补,缩小版图表=重复」 | ✅ 成立,且它顺带给出了 A 真正的死因:把这张卡压到合规的「一句话」之后,剩下的是一句 7 天前生成的、要烧过 LLM 才有的主题描述——而 B 那条状态行实时、零 LLM、无循环依赖,做的是同一件事。A 被 B 支配。所以结论不是「暂时不做」,是「这个位置本来就该放页面自己知道的事,不是另一个模块生成的东西」 |
| ⑤ 槽位之争是假问题(两卡受众不重叠,从不冲突) | ⚠️ 削弱,别再当论据:CalibrationActionCard 条件是「未校准且未跳过」,确实只对新用户在场;但 TriageActionCard 是 pending>0 && 7 天内未关闭——老用户持续存词后 A1/A2 待分级会再次 >0,两者会同时成立。结论不变(槽位不是主要矛盾),但「从不冲突」是错的 |
🔒 唯一复活条件(两条必须同时成立,缺一不重议): ① 周报变成「不点按钮也存在」的东西(= C 翻案;而 C 的否决里有两条是外部约束而非偏好: 服务端没有数据源、对外法律文本口径),且 ② 它对没有阅读史的新用户也能产出内容。 —— ② 不能省:即使 C 全面翻案,canGenerate(hasReadingData)仍让零阅读的新用户拿不到报告, 而「读了两篇」的报告本身就是 roadmap 2-4 治过的稀疏叙事。只满足 ① 时复活的只是「老用户但 没去过 Stats」这一小撮,而这撮人由 B 以零成本更好地覆盖——那时该做的仍是 B,不是 A。
B. 问候语下加一句个人化兴趣状态行 —— 🟡 方向成立,但提案那句文案现在说不出口
诉求(「发现页开场有点冷」)成立,位置对,也确实落在「一句话状态 = 互补」那一侧。 但数据不支持提案给的示例文案。核实 get_reading_interest_signals(reading.rs:1906):
| 字段 | 真实来源 | 问题 |
|---|---|---|
top_domains | reading_log GROUP BY domain | 无时间窗(是全量累计,不是「最近」);per-user ✅ |
top_tags | rss_feeds.tag GROUP BY tag | 是「你订阅了什么」,不是「你读了什么」;同样无时间窗 |
→ 示例文案「你最近常读经典文学和科学发现」两个词都不成立:「最近」没有窗口; 「常读什么主题」来自订阅表——订了一个科技 feed 但从没打开过的用户,会被告知「你常读科技类」。
⚠️ 同一信号已经在用(buildReason 的「因为你常读科技类」)。区别在位置: 行内理由挂在某一条推荐上、读者会当作「系统的猜测」;提到页头就成了关于用户本人的断言。 同一信号在「行内理由」里可容忍,提到「页头状态行」就是把失真放大——这条判据值得复用。 (顺带:top_tags 那条 SQL 没有 user_id 过滤,与同函数的 top_domains 不一致;今天无害, 因为 auth.rs 换用户时会删掉非当前用户的 rss_feeds 行。记录备查,非本轮议题。)
最小可诚实版本(真要做时的落地形状,已写进独立条目):
- 换信号:给
get_reading_interest_signals加时间窗(reading_log.created_at >= now-30d), 用top_domains(真实阅读行为)而非 tags,文案退到「你最近常读 The Atlantic 和 Nautilus」。 代价:信息量低于主题版,且与下方「最近打开」有部分重叠(习惯 vs 上一次动作,不完全重复)。 - 或先补主题维度:要说「主题」,必须给读过的东西打标(而不是给订阅打标) —— 那是 V2 画像的活,不是一句文案的活。
- slogan 处置 = 条件替换,不并列两行:无信号(新用户)→
hpGreetingSub品牌叙事; 有信号 → 换成状态行。理由:品牌叙事在首次接触时价值最高,而到有阅读史时用户已看过很多次; 两行并列会把 2026-07-24 主叙事对齐特意定的那句稀释成装饰。
裁定:🟡 另立条目(见本文件 §「发现页问候语下的个人化状态行」),不按原文案做。
❌ 2.3 发现页复习到期提示条 —— 明确不做
与 ActivityBar.tsx:187 写死的决策直接冲突:
回顾模块有待复习词 → 柔和主色小点(仅"有东西"的存在感,不量化、不报警, 贴合"零 push / 学习自然发生"定位;不用数字 badge / 红点)。
发现页放「12 个词今天到期复习」= 数字 + 催办,正是这条注释拒绝的东西; 且与原稿自己第三节的原则二("只陈述已发生的事,不提醒还没做的事")自相矛盾。 表达也已存在:StatsInsights.tsx:71dueCount > 0 ? insightsDue : insightsNoDue,且刻意用「暂无到期复习,安心读」的正向框架。
❌ 2.4「为你推荐」解释性文案 —— 事实错误,功能已存在
HomePage.tsx:85 buildReason 已经在做原稿描述的事: 命中订阅主题 tag 时输出「因为你常读科技类」(hpReasonInterest,数据来自 getReadingInterestSignals),回落 Supabase 全局 recommendation_reason; RecommendedArticleCard 还额外渲染在学词 chips。这就是 2026-06-09 落地的 v1 「⑨ 模板理由,0 LLM」。真实增量只剩覆盖率(category 未命中 top_tags 时只剩全局理由), 那正是本文件既有条目 §推荐流个性化 V2:⑨ 理由 LLM 润色,其触发条件写明 「观察到用户真的在用推荐理由再启动」—— 看截图推断不构成新证据,维持暂缓。
✅ 三、两条发现页设计原则 —— 吸收(10 分钟,随手做)
两条都与仓内既有实践一致,措辞好用,值得写进 docs/product.md 或 roadmap §附:
- 轻量提示不能变成缩小版图表:发现页新增的水平/进度信息应是叙事性一两句话, 不做图表、不做 7d/30d 切换。判据:「看起来像缩小版统计图就是重复,像一句话状态就是互补」。 (
StatsInsights已是这个做法。) - 措辞只陈述已发生的事,不提醒"还没做的事":避免连续天数/排名/"你已经 N 天没读"。 —— 本仓早有此红线,本条是它的发现页落地判据。
📋 外部 backlog 第三轮(四模块视觉优化)—— 逐条裁定(2026-08-13)
来源:~/Downloads/readbrowser-visual-optimization-proposal.md + 4 个 mockup HTML (mockup-01-vocab-detail / 02-discover-sites / 03-weekly-review / 04-review-landing)。 同一批讨论的第三份文档,聚焦视觉层(词汇详情 / 发现页站点 / 学习统计周回顾 / 回顾 landing)。 本节是裁定留痕,防止同一批建议被反复评估(沿第二轮与 roadmap §六 的做法)。
逐条对着代码核过,约 24 条建议的分布:6 条直接可用 · 5 条方向对但方案要改 · 4 条事实错误(描述的是已实现状态)· 3 条与既有明确决策冲突 · 1 条数据形态不支持。 整体质量低于第二轮那份——它只看截图不读代码,故已实现的功能被当成缺口重提。
📌 ✅ 采纳项已全部实施完毕(2026-08-14,T1-T7),as-built 与决策留痕见
visual-hierarchy-refinement-plan.md。 T8「下一步读什么」按钮不做,转交本文件 §2.2。 本节保留裁定结论——尤其 §0 那张「已否决、别重新论证」的清单仍然有效。
| 原稿条目 | 裁定 | 说明 |
|---|---|---|
| 一、「我的记忆」上移到词条标题下 | ✅ 采纳 | 属实(WordDetailView.tsx:70 → :79),但它已在词典释义之前(roadmap 2-1 验收线),只是排在词源/同族词之后 → 是"再上移一格"非"从底部搬到顶部",严重程度被夸大 |
| 一、词源/同族词降权 | 🟡 改方案后采纳 | 别改「纯文字链接」——「收起 chip → 圆角卡」是仓内统一 idiom(SynonymUsageAction/CollapsibleSection/DiscoveryPanel 共用)。改法 = 收起态去 border+bg,展开态才成卡 |
| 一、AI 徽章从逐句改区域级一次 | 🟡 改方案后采纳 | 徽章是溯源信息非装饰,区域级会丢失"哪一句是 AI 生成"(词典产品的诚实性损失)。改法 = 保留逐句,文字「AI」换 12px Sparkles 图标 + tooltip,噪声降约 70% 而信息不丢 |
| 一、生僻义项 70% 透明度 | ❌ 不做,换折叠 | ① opacity-60 在 WordMemorySection SourceRow 已表示「不可点」,撞语义;② 对比度下降;③ 判据不存在——pos_definitions 的 sense 顺序 ≠ 词频。改用复习卡现成的「hero + 展开其余 N 个义项」(信息量守恒的降权) |
| 一、CEFR 色点加大 + 提饱和度 | 🟡 尺寸属实,饱和度不属实 | VocabPanel.tsx:132 = w-1.5 h-1.5(6px)确实小;但用的是 -600 档,饱和度不低。真问题被说轻了:2026-08-12 改冷色系后是 teal→violet 六个相邻冷色相,且同时撤掉了右侧字母 badge——6px 圆点区分六个相邻冷色相肉眼做不到,加大到 8px 也救不回。要做得连字母一起回来 |
| 二、站点两列改单列 | ❌ 不做 | 与 HomePage.tsx:698-701 写死的刻意设计冲突("把 我的 vs 推荐 提到主轴…两侧列宽相等 + 底部对齐 = 视觉/交互对称")。原稿理由("只能靠记住左=我的右=推荐")不成立——每列上方有 SectionHeader 标题。代价实打实:四栏变纵向堆叠 = 首屏信息量减半、滚动翻倍 |
| 二、星标改状态色(实心/空心) | ❌ 事实错误,已实现 | HomePage.tsx:724 实心 ★(我的)vs :778 空心 ☆(推荐),已经是原稿要求的样子。同理"品牌色完全没用"——星标走 SEMANTIC.bookmark(amber) |
| 二、星标改品牌绿 | ❌ 不做 | SEMANTIC.bookmark(amber)是全仓统一收藏语义(NotesPanel / SentenceWorkbench 同用),改绿 = 同一动作两个模块两种颜色 |
| 二、星标移到行首 | ❌ 不做,换 hover 显现 | 行首是 leading(favicon),塞可点星 = 出现"点它不会打开站点"的热区;且 action 在 trailing 是 ListItem 全部调用点的一致约定。替代 = 推荐栏空心星 opacity-0 group-hover:opacity-100,「有星=已收藏」退化成存在性信号 |
| 二、Tile 卡片改轻量列表行 | ✅ 采纳(本节唯一真增量) | 属实:站点/订阅用 Tile tone="bordered"(HomePage.tsx:759),同页「最近打开」「继续阅读」用 ListItem —— 同页两套列表语法 |
| 三、本周主题标签加分类色点 | ❌ 数据形态不支持 | report.themes 是 LLM 自由生成的字符串非枚举,没有稳定类别集可映射 3-5 色。要做须先让 EF 返回受控枚举。且 mockup-03 给的 #4a8f5c 与其自身 --text-success #2f8f5b 几乎同色,会与"个性化/成功"撞语义 |
| 三、「下一步读什么」独立成卡 + 按钮 | ❌ 按钮不做(2026-08-14 复评翻转) | 视觉部分已随 T5 落地(消费 SEMANTIC.personal)。按钮不做:原因不只是「做不到」(next_step 无 url、候选连 URL 都没传),更是换池解决空池却制造与发现页的重复——两个入口候选池本就不同源且质量倒挂。改走「一句过渡导流到发现页」,推导见 §2.2〈2026-08-14 复评〉 |
| 三、数据稀疏时降级展示 | ❌ 事实错误,已实现 | 本文档最大的事实错误。roadmap 2-4 / stats-sparse-insight-cards-plan.md(2026-07-23 as-built),验收原文即"新账号首日打开 Stats 无空图、无零值大数字":StatsPanel.tsx:145/153 四个 gate + :184 StatsInsights 补位卡。且现有做法优于原稿的"通用降级组件"(空段整段隐藏 + 一张统一补位卡 = 连贯叙事,而非一页零散短句) |
| 三、叙事↔仪表盘语气断裂 | ⚠️ 已大幅处理过 | 2026-06-08 narrative-dashboard-blite:9 块砍到 5 块 + 冷名词标题全换第二人称问句。原稿看到的是处理后的状态 |
| 四、picker 数字权重过重 | ❌ 事实错误,已实现 | ReviewPickerRow.tsx:42 = text-caption text-text-secondary tabular-nums(10px 次要灰)。原稿「改为小号浅色文字」已是现状,mockup-04 画的就是当前样子 |
| 四、landing 首屏「今天」摘要卡 + 到期数 + 开始复习按钮 | ❌ 不照做,取其意 | 与 ActivityBar.tsx:187 写死的红线冲突("不量化、不报警,不用数字 badge"),且第二轮 §2.3 已否过同类;又与 picker 第一行冗余。取其意的落地见下方 ✅ |
| 四、landing 中央大面积留白 | ❌ 我方误判,已订正 | 我最初据 MainApp.tsx:830 的 !inFocusReview 分支判为"漏渲染",实机证伪:中央由 review webview 自己的 rb-cache://localhost/__review_empty__?reason=picker 渲染「选择左侧清单开始复习」,是有意的空态页。原稿「同屏两处同义空态」只在右侧「本页精读」同时开着时成立,本次未复现 |
五、新增 --memory 暖色 token | ❌ 不做,换既有语言 | amber 已有 5 个语义(warning/bookmark/reviewHard/stateBadge.user/typeBadge.highlight)+ CEFR unknown,其中两个会与"我的记忆"同屏。且仓内已有个性化视觉语言:WeeklyReportCard.tsx:171 的 border-l-2 border-primary/40。统一目标应是它(primary 左竖条),不是新造暖色。原稿自己在 §一 备注里警告"不要复用 warning",mockup-01 却直接用了 --bg-warning/--text-warning |
| 五、「待你行动的提醒」品牌绿 token 家族 | ❌ 不建 | 三个落点全部不成立:发现页到期提醒(第二轮已 ❌)· 回顾今天卡(见上)· 站点收藏状态(已有 SEMANTIC.bookmark)。建了就是死 token(有 phrase_interaction_log v20 建 v25 删的先例) |
mockup HTML 的共性问题(实施时会撞上,原稿未提)
- 全是亮色单主题。RB 是双主题(
prefers-color-scheme+data-theme双向覆盖),#fbf1e0/#e8f4ec这类极淡填充在暗底要整套重配,不是调透明度能了事。 - 品牌绿对不上:稿子用
#2f8f5b,RB primary 是#1f4e3d(深得多)。 mockup-04 那张绿卡换真实 primary 后会明显变暗变重——若偏好稿子的调性, 要讨论的是品牌色本身是否偏暗,而非那张卡怎么画。 - mockup-02 同一行里绿承担两个语义(实心星=收藏状态 / "对你刚好"=难度匹配)。
- 图标依赖 Tabler CDN,RB 用 lucide(ti-sparkles / volume / star-filled / arrow-right 均有等价物,无阻碍)。
✅ 已落地(2026-08-13):回顾 picker 去待办感
原稿 §四的问题成立、方案不成立,故取其意重做。三条已实施 + 一条实施中发现的:
- 起手行从清单项变行动:
全部 · 221→✨ 开始复习 · 20 张。 221 是total_due,而会话按REVIEW_BATCH_SIZE=20分批取牌、批空自动续、 中途永不回显剩余量 —— 那个数只在点击前出现一次,不对应任何可完成的单位, 唯一作用是让人不敢点。份量数取min(REVIEW_BATCH_SIZE, allTotal)(due 不足一批说实数)。 - 计数带单位词:
ReviewPickerRow的count: number→countLabel: string, 单位由调用方给(复习「N 词」/ 重温「N 页」/ 起手「N 张」)。 裸数右对齐 +›就是收件箱行语法,带单位才从计数器变回体量描述。 该行被复习与重温共用且单位不同,故不能写死在组件里。 - 图标
Target→Sparkles:靶心 = 达标/任务,是全部候选里最任务化的一个。 - (实施中发现)会话内进度
{已复习}/{totalDue}→已复习 N: 原先每张卡上都挂着3/221。只从 picker 拿掉而留着它 = 把债务数从看一次改成看每一次。 会话是开放式的(批空自动续、随时可停),那个分母既走不到头也不对应可完成单位。 改为只报累计 —— 符合上方第二轮吸收的原则二「只陈述已发生的事」。 连带:SessionFilter.totalDue成死字段已删;addressBar.reviewSessionProgressAria随之无引用,三个 locale 文件一并摘除;文字自解释后去掉role="progressbar"(没有终点的进度条本身就是错误的语义承诺)。
遗留(削弱非消除):221 仍在「按日期 · 更早」那一行。按判据它合法(本周 1 词 vs 更早 221 词 是可比较的体量,有选择价值;而原「全部 221」无处可比),但用户这一屏仍会看到它, 只是从"第一眼 + 行动位"退到"最后一组 + 浏览位"。若仍嫌重,后续可选:日期分组默认折叠, 或把日期桶从「本周/更早」改细让 221 自然拆开。未动,属新决定。
✅ commands/notes.rs 拆分(2026-08-12 记录 → 2026-08-25 完成)
拆成
notes/{source,crud,page,content,continue_reading},最大一块 654 行(拆时已涨到 2126 行)。 基本沿用下面建议的四类切法,只有两点调整:① 快照那簇命名为content而非snapshot——避与commands/snapshot.rs撞名(那边管「文件在哪、缺了怎么办」,这边只管写进去读出来); ② 笔记本 CRUD 再分出page(页 / 词-页链接的粒度与笔记本不同)。20 个命令逐个守恒。
刚从 query.rs 抽走 memory.rs 的同一个信号,换了个文件响:notes.rs 现 2050 行, 是全仓最大的 Rust 命令模块(S1 强警告线 1000 的两倍)。它现在装着四类互不相干的东西: 笔记本 CRUD 命令 / 来源页解析(extract_domain·canonical_source_url·ensure_source_for_url· note_identity_for_domain)/ 快照落盘(save_html_to_file)/ 续读查询(get_continue_reading)。
拆法倾向按上面四类切 notes/{mod,source,snapshot,continue_reading}.rs, 其中来源页解析那一簇最自洽(已有 note_identity_tests 单测跟着走)。 纯搬迁、无行为变化,随下次触达该文件时做——与 query.rs 那次同样的处理方式。
🔴 EPUB 长期阅读 —— 验收捞出的 9 条缺陷(roadmap 3-5 产出,2026-08-07 记录)
验收报告(含每条的可复现证据):
epub-longform-acceptance-handoff.md§4 + §4.1 测试书 =~/Downloads/The City of God, Volume I by Saint of Hippo Augustine (4505).epub(Gutenberg 45304)。 本轮只验收不修复,故全部落这里。按影响面排序,前 3 条建议合成一个「EPUB 可用性」小 sprint 一起做 —— 它们叠加起来的效果是「打开一本书之后走不下去」,单修任何一条都不解决问题。进度(2026-08-12 三次更新 —— B7 验证矩阵跑完后的全量对账): 已闭环:B1 / B2 / B3 / B5 / B6 / B7(+7.1 ncx 实体 · 7.2 高亮残留 · 7.3 关面板清高亮)/ B9 / 6.5(B') / 6.6(A·C·D) / 6.7。 as-built:Sprint 1(B1-B4)
epub-usability-sprint1-handoff.md§6 · B5+B6epub-anchor-precision-handoff.md· B'+A+C+Depub-toc-quality-handoff.md· B7epub-fullbook-search-handoff.md(§5.5 验证矩阵 26 项全绿)。 四轮全部零 migration。2026-08-13 更新:B4 章内滚动偏移已闭环(第 4 条)——
epub-intra-chapter-position-plan.md,migration v32 (这一串 EPUB 工作里唯一碰 schema 的一轮,此前四轮全零 migration)。仍未做(1 条):B8 改名/移动(第 8 条,🟢 低频)。
(第 10 条 N3「TOC 被 ⌘F 驱逐后不恢复」2026-08-12 补进清单当天即修; 验收稿两处「从未压测」的场景已于 2026-08-12 压测完,见第 11 条。)
1. ✅ 开书即死路(B1)—— 2026-08-07 已修
as-built:
epub-usability-sprint1-handoff.md§6。 三条子改动全做了:①openFileByPath成功后openLeftPanel('toc'); ② 跳过纯封面 —— 判据落在 Rust(commands/epub.rs::first_content_spine_index,按剥标签后的 可见字符数判,非文件名/索引;新增EpubInfo.start_index),实测该书spine[0]=wrap0000.xhtml可见字符 0、spine[1]157,403 → 落 idx 1;③ 章末去处 = EPUB tab 上的 ‹ › 改成上一章/下一章 (它们对 epub 本来恒 disabled,是一对死控件;AddressBar.tsx的CHROME.cluster命令簇内, 未新起 EpubNavBar)。
原始记录(2026-08-07 验收)
打开任一 Gutenberg EPUB → 落在 `chapters[0]` = 纯封面页(无正文、**滚不动**)→ 目录**不自动打开** → **全仓没有任何「下一章」UI**(`EpubNavBar` 已删,`computeNextTocAnchor` 只被 `reader.js:504` 用于正文切片)。 唯一出路是发现 `AddressBar.tsx:381-386` 那个只在 epub tab 出现的第 4 个图标。 - 触发:打开任何 spine[0] 是封面的 EPUB(Gutenberg 系全中)。 - 修法候选:① 开书时 `openLeftPanel('toc')`;② `addEpubTab`(`useTabsStore.ts:325`)跳过纯封面 spine 项; ③ 章末补续读入口。倾向 **①+③**(② 要小心:并非所有书 spine[0] 都是封面)。2. ✅ TOC 间歇性空白且不自愈(B2)—— 2026-08-07 已修
as-built:effect 依赖补
activeEpubInfo(MainApp.tsx)。 ⚠️ 原始根因描述不准,以此为准:closeTab()/await prepareEpub()那条竞态在 2026-08-06 收口后已不存在(openFileByPath不再先关 tab)。真正的机制不是间歇而是确定:openContent见到空白 tab 会就地复用(useTabsStore.ts::canOpenInPlace,tab id 不变), 于是activeTabId纹丝不动 → 只依赖[activeTabId]的 effect 根本不触发。 判据:进 Read 模块自动建的那个空白 tab 还活着时开书,必空。 实测已复现并验通(目录 35 条)。review-source早退分支保留(那是「别清空底下那本书的目录」,退出专注态时 activeTabId 会变、自会重跑)。
原始记录(2026-08-07 验收)
`tab.epubInfo.toc` 有 35 条,面板却显示「暂无目录」;新建标签再切回来即恢复。 根因:`MainApp.tsx:270-279` 的 effect 只依赖 `[activeTabId]`,且 `tab?.type === 'review-source'` 分支 **早退不写 `tocItems`**;而 `openFileByPath` 在 `closeTab()` 与 `addEpubTab()` 之间隔着 `await prepareEpub()` 这个真实异步边界。中间那次提交若落在 review tab 上,`tocItems` 停在 `[]` 后 **永不自愈**(不认 tab 内容变化)。 - 叠加 B1(目录是 EPUB 唯一导航入口)→ 这本书彻底打不开正文。 - 修法:effect 依赖补上 tab 的 epubInfo 身份(或 `addEpubTab` 后显式同步一次)。3. ✅ source_ref 的 fragment 从不剥离 —— 一个根因,三处断路(B3)—— 2026-08-07 已修
as-built:新增
src/lib/url.ts::stripFragment/fragmentOf,三处入口统一先剥再判。 实修出来是四处不是三处 —— 前三处剥 fragment 后,复习 📂 仍然"点了没反应":AddressBarReviewSegment.handleOpen不切 workspace 模块,tab 在 store 里 active 了、 画面还停在复习卡上(实测activeTabId:"2"但activeModule:"review")。已补setActiveModule('read')(三个分支都要,含网页分支;Library 的「回原文」早有同款处理, 见vocab/WordMemorySection.tsx::openSource的注释)。 原始记录里「先切模块把 no-op 伪装成跳转成功」这句只对「继续阅读」那处成立(MainApp.tsx的onOpenFile包了setActiveModule('read')),复习那处恰好相反 —— 是根本不切。
原始记录(2026-08-07 验收)
`reading_pages.source_ref` 对 EPUB 存 `/Users/…/book.epub#pgepubid00010`。Rust 侧 `classify_source_type` 明确先剥 fragment,**TS 侧三处消费点都漏了**: | 位置 | 后果 | |---|---| | `useNavigation.ts:110`(经 `HomePage.tsx:482`) | 「继续阅读」→ EPUB **整条通路死**;且 `MainApp.tsx:825-828` 先 `setActiveModule('read')` 再调用,把 no-op **伪装成跳转成功** | | `AddressBarReviewSegment.tsx:139` | 复习「打开原书」找不到**已经开着**的同一本书 | | `AddressBarReviewSegment.tsx:145` | 也开不了新的,且 `try` 块未进入 → **静默无日志** | - 证据:点击瞬间 `[warn] Only EPUB files are supported: …epub#pgepubid00010`;tab state 完全不变。 - 对照:「最近打开」(传 `resource_history.uri`,干净路径)**能正常打开**。 - 修法:抽 `stripFragment(path)` 在三处入口先剥再判。**一次改完三处**,别只改一处。4. ✅ 位置恢复(B4)—— 「恢复到章」2026-08-07 已修;章内偏移 2026-08-13 已修
章内 as-built:
epub-intra-chapter-position-plan.md§7.5。 migration v32epub_positions(一本书一行,local-only,不进同步矩阵)。存什么 = 实测拍板,不是拍脑袋(这条是本轮唯一的真决定,实现只是它的推论): 原计划倾向「存最近可见的锚点 id」,数完两本书的锚点密度后否掉了 —— 两本书的 锚点密度与段落密度正好相反:City of God(Gutenberg)最长章 332 个 id / 618 字符一个(锚点很好); Romance(Standard Ebooks)每章只有一个 id(章首那个
<section>)/ 53,936 字符一个, 锚点方案在它身上退化成「恢复到章」= 等于没做;而<p>密度反过来(1,368 : 327)。 故存block_idx(章内第 N 个文本块)+block_frac(块内比例):块序号同样是 DOM 结构量 (与锚点一样抗重排版),但两本书都够密。存像素被 B5 那 +85% 的重排版直接否掉。为什么是新表而不是
reading_pages加两列:那张表的行只在存词/划线时经ensure_source_for_url产生,而本条的场景恰恰是只读不存词(实测最长章 47 屏)。 顺带保证不 touchlast_opened_at→ 下面那条「刻意的判断」继续成立,「继续阅读」排序零变化。两个实施时才浮现、值得记住的坑(详见 as-built §7.5): ① 保存的武装条件要「用户动过」且「真的滚了」—— 只判前者会把 ⌘F 这类按键但没滚 算进来,用「章首」盖掉真实位置;只判后者会把加载期的原生锚点滚动算进来,同样自我覆盖。 ② 滚动监听必须挂在
initEpubAnchor而非syncEpubTocAnchor—— 后者要tocAnchors非空, 而不带锚点的书那张表恒为空,挂错地方 = 最需要这功能的那类书一次都不记。 (章级)as-built:新增src/lib/epubStart.ts::resolveEpubStartHref(2026-08-13 随章内偏移改名resolveEpubStart,返回{href, pos})(回落链:调用方给的锚点 → 库里最近读过的锚点 →start_index跳过封面 →chapters[0])+ Rust 命令get_epub_last_read_ref(按substr前缀匹配,不用LIKE—— 路径里的%/_是通配符)
OpenPayload.epub.startHref。零 migration,位置信息本来就在source_ref的 fragment 里。 实测:该书库里两行(08-07#pgepubid00010/ 08-05#pgepubid00021),取到新的那行并精确落点; Moby Dick(无 reading_pages 行)优雅回落到start_index=1、url 不带#、无报错。⚠️ 原「仍未解决的两块」现已全部闭环:
章内滚动偏移:需要新列 → 独立立项(红线 #11:→ ✅ 2026-08-13 已修(见本条顶部;v32 用掉了,下一个新迁移从 v33 起)。schema.sql已冻结,下一个迁移从 v32 起)。TOC 条目不带→ ✅ 2026-08-12 已修(见下方红框)。#锚点的书(目录直接指向整份 xhtml):source_ref退化成纯路径, 还原不出章节,只能落start_index。🔴 2026-08-12 更正:上面那条「不带锚点的书」的影响面被严重低估了。 它写的是「还原不出章节、只能落 start_index」,实测远不止——
source_ref没有 fragment 会让整本书的所有章节塌缩成一条reading_pages:各章的词全挂一行、name卡在第一次存词那章、快照文件互相覆盖(「回原文」标题第 I 章 / 内容第 IX 章)、 「继续阅读」永远停在第一章。实测书 =joseph-conrad-ford-madox-ford_romance.epub(Standard Ebooks 版,目录条目形如href="text/chapter-9.xhtml")。 已修:ipc.js::epubSourceRef在 anchor 为空时回落 spine 序号形式#ch{N}(epubStart.ts::hrefForAnchor的LEGACY_SPINE_ANCHOR分支本就认这个形态,消费端零改动)
- 新增不变式单测
src/lib/epubSourceRef.test.ts(13 例)。 as-built 见data-hygiene-identity-plan.md§5.5。 教训:记录已知缺陷时,「症状」和「影响面」要分开写 —— 这条只记了症状(位置恢复), 于是后来读它的人(包括我)都以为它只是个体验小瑕疵。一个刻意的判断:没有让 epub 章节导航去 touch
last_opened_at。做了恢复会更准 (现在恢复到的是「最后存词的那一章」而非严格「最后停留的那一章」),但会改变 「继续阅读」列表的排序行为 —— 那是 B9 的粒度问题,不在本 sprint 里顺手做。
原始记录(2026-08-07 验收)
`addEpubTab` 永远取 `chapters[0]`;章内滚动偏移全仓无持久化;`MainApp.tsx:278` 让 epub **跳过** `logResourceOpen`,故章节导航既不刷 `resource_history` 也不 touch `reading_pages.last_opened_at` (那两处的行只在存词/划线经 `ensure_source_for_url` 时才产生)。 - 与 B3 合起来 = 「继续阅读」这个承诺目前是空头支票。 - 注意:真要做章内偏移持久化会需要新列 → **那是独立立项**,本条先只做到「恢复到章」。5. ✅ 跨文件锚点导航定位失准(B5)—— 2026-08-08 已修
as-built:
epub-anchor-precision-handoff.md。零 migration。 原假设被实测推翻:分相埋点测出位移里排版占 98.7%、CEFR/短语高亮只占 1.3%, 所以「关掉 CEFR 再走一遍」这个判别步骤没必要做。真凶 =settings.js::loadSettings对rb-cache://页的整页重排版(680px 窄栏 + Georgia +line-height:1.8+ 字号 + padding), 把文档高度撑 +85%(35,243 → 65,223),而原生锚点滚动发生在这之前。 修法 = 新增content-script/features/epub-anchor.js,排版落地后按 fragment 重新定位一次 (高亮注入完再补一次尾差),带两道让位闸(用户滚动意图 / 位置已被别人改动)。 另修正原稿两处:① 「去程 ✅」不成立,去程实测也偏 ≈1 屏,两个方向都坏、都已覆盖; ② 「cross-file 导航 URL 不带锚点」是错的,url一路带着#FNanchor_25_25。
原始记录(2026-08-07 验收)
脚注 `[1]` 去程 ✅ 正确落到 h-8 的对应条目;**回程**(脚注里的回链 → `h-0#FNanchor_1_1`) 落在目标**上方约 2 屏**(CONTENTS 表中部),要手动下滚才能回到原句。 - 机制已定位到**对称性差异**:同文件锚点走 `MainApp.tsx:475-481` 的 `evalInWebview(…scrollIntoView…)` (页面已 settle,准);跨文件走**原生导航 + 浏览器原生锚点滚动**,发生在 content-script 注入 CEFR 下划线 span / 字体 settle **之前**,锚点上方内容随后变高 → 停在目标上方。 - **未细分**是「高亮注入回流」还是「字体 settle」→ 实施时先用「关掉 CEFR 高亮再走一遍」判别。 - 修法方向:原生导航完成 + content-script ready 后,按 URL hash 再定位一次。6. ✅ 跨文件跳转后 TOC 高亮丢失 + 目录不自动滚到当前项(B6)—— 2026-08-08 已修
as-built:
epub-anchor-precision-handoff.md。零 migration。 推导抽进新文件src/lib/epubToc.ts(目录锚点推导的唯一决策处,13 条单测),做成两级: ① fragment 恰是目录锚点 → 精确命中;② 否则退到同文件第一条(保证 href 在目录里真实存在 →tocIdx ≥ 0→ ‹ › 不会跳回全书开头)。⚠️ 里那条兜底未删,按你写的保留了。 但只做②不够:本书h-0一个文件装了 7 条目录,恒取第 0 条 →「下一章」= 第 1 条 = 往回跳,只是把错误换小一号。所以再加一步——页内脚本在定位稳定后把 URL fragment 改写成上方最近的那个目录锚点(replaceState,不进历史栈,已实测rb-cache://上允许), 让①真正命中。零 Rust 改动、零新事件(复用tracking.js里已被 patch 的replaceState→reportNavigationState回路)。自动滚动 =TocPanel当前项scrollIntoView({block:'nearest'})。 顺带:effectiveSourceUrl走tocHref,故脚注往返后存的词也拿到精确章节锚点 → B4 恢复到章更准。 仍是近似的一处:滚动时高亮不跟着走(校正只在导航那一拍跑一次)→ scroll-spy 见epub-toc-quality-handoff.md§2 项 D。
原始记录(2026-08-07 验收)
- 高亮丢失:`MainApp.tsx:317-321` 以 `base === spineBase && hash === urlHash` 找 TOC 条目; 落到 `#Footnote_1_1` 时匹配失败 → 回落 `spineHref`(**不带 `#`**)→ `TocPanel.tsx:45` 的 `item.href === currentHref` 全不命中 → **一条都不高亮**。 - 不自动滚动:`TocPanel.tsx` 全文**无 `scrollIntoView`** → 长目录(本书 35 条)里当前条目在折叠线以下时看不见。⚠️ 2026-08-07 Sprint 1 之后影响面变大了,修 B6 时一并处理:
currentTocHref现在有两个 消费方 —— TocPanel 高亮,外加 AddressBar 上新的「上一章/下一章」(B1③,EPUB tab 的 ‹ ›)。 后者用epubToc.findIndex(t => t.href === currentTocHref)定位。B6 一旦发生(tocMatch失败 →currentTocHref回落成不带#的spineHref),tocIdx= −1 → 「上一章」置灰、 「下一章」跳到目录第一条 = 回到全书开头。 「−1 → 下一章 = toc[0]」这条兜底是刻意的(服务「刚开书、锚点还没回传」),不要直接删; 正确修法是修好tocHref的推导(让它在脚注锚点下也能命中 TOC 条目), 或在 tocIdx < 0 时退一步按 base 文件匹配再定位。
6.5. ✅ EPUB 3 nav.xhtml 无回落 → 目录整个空 + ‹ › 全灰(B')—— 2026-08-08 已修
as-built:
epub-toc-quality-handoff.md(B' + A + C + D 四项,零 migration)。parse_opf现在同时记properties="nav"的 manifest item(按空格分隔 token 比对, 不用contains("nav")——后者会把properties="navigation-aid"误当导航文档); 新增parse_nav_xhtml解析<nav epub:type="toc">下的嵌套<ol>,<ol>深度 = level。 取舍:ncx 优先、nav 回落(EPUB 3 规范本身以 nav 为准,这里刻意反过来)——风险不对称: 带 ncx 的书走的仍是原路径,EpubChapter.title不动、reading_pages.title存量一致性不受影响; 且 ncx 解析器经了六轮实测而 nav 解析器是新的。ncx 存在却解析不出条目时仍回落 nav。 实测(自造 fixture~/Downloads/EPUB3 Nav Only Testbook.epub,纯 EPUB 3 无 ncx): 目录 6 条、level[0,0,1,1,0,0]、fragment 保留、章节标题回填、start_index正确跳封面、 ‹ › 两颗可用(disabled 文案 0 命中)。回归验 Moby Dick 146 条不变。
原始记录(2026-08-08 B5/B6 会话挖到)
`epub.rs` 解析 manifest 时只认 `media-type == "application/x-dtbncx+xml"`,**没有 EPUB 3 导航文档(`properties="nav"`)的回落分支**。纯 EPUB 3 且不带 legacy ncx 的书 → `toc = []` → 目录面板空 → `tocIdx = −1` 且 `epubToc[0] = undefined` → **‹ › 两颗全灰** = 又一个 B1「开书即死路」, 且这次连唯一出路都没有。Gutenberg 全系带 ncx,故 B1-B6 六轮实测一次都没暴露;近年商业 EPUB 多为 EPUB 3。📌 修复时确认:本地 5 本书(City of God / Moby Dick / Romeo & Juliet / Romance / Ashton-Kirk) 全部无 nav.xhtml —— 印证「六轮实测未暴露」不是运气,是本地样本里根本没有 EPUB 3 书。 也因此修复前的空目录状态无法在本机自然复现,fixture 是自造的。
⚠️ 同轮证伪,别再照旧稿写:「解析器过滤掉 526 个
[Pg NNN]翻页锚点」结论对、归因错。 实测toc.ncx:<navPoint>35 个、<pageTarget>542 个,542 条[Pg N]全在<pageTarget>, 而parse_nav_points只扫<navPoint>—— 仓里不存在任何[Pg启发式过滤, 「给过滤规则加测」是伪任务。
6.6. ✅ 目录质量三项:标签剥页码(A)· 滚动联动高亮(D)· 长目录折叠(C)—— 2026-08-08 已修
as-built:
epub-toc-quality-handoff.md。三项与 6.5 同轮,零 migration。
- A 剥尾随页码:
epubToc.ts::cleanTocLabel(),只吃结尾的(\s*\[\d+\])+,剥空则回落原文。 只在TocPanel渲染时剥,Rust 侧TocEntry.label一个字不动——它经EpubChapter.title→__RB_EPUB_META.tocLabel→effectiveSourceTitle()落进reading_pages.title,改源头会让存量行与新行标题不一致。全仓样本里只有 City of God 有 2 条命中。- D 滚动联动:
epub-anchor.js抽出nearestTocAnchorAbove()(纯读),挂 150ms 节流的scroll监听,复用 B6 的replaceState回路。与 B5 的_userMoved让位闸互不相干 (那道闸管"还要不要抢滚动条"= 写,这里只读)。⚠️ 实测踩到的坑:一度写成"算不出上方锚点 就维持现状",结果settings.js给 body 加了 48px padding,滚回最顶时首个锚点top=48 > 0恒走这一支 → 上一章的高亮永久留在那儿。正解是回落list[0](与导航那一拍、 与resolveTocEntry同文件兜底同一口径)。TocPanel侧加 1.5s 让位闸:用户正在手动翻目录时不抢它的滚动条。- C 按 level 折叠:树形推导在
epubToc.ts(buildTocTree/hiddenTocRows/tocAncestors, 14 条新单测),TocPanel组头挂箭头、叶子挂等宽占位保持对齐。默认全展开。 当前项的祖先自动展开(否则 B6 的scrollIntoView会滚到隐藏元素上)。⚠️ C 的范围认知更正(交接稿的假设是错的):原稿把 Moby Dick(146 条)当作 C 的用例, 但实测它 141 条是 level 0 —— 按 level 折叠只能收掉 5 条,解决不了那面墙。 真正受益的是「一个 Part 挂几十章」的书:Romance 8/5/34(三层)、Ashton-Kirk 8/26、R&J 11/24。 Moby Dick 那类扁平长目录是另一个问题(要搜索/过滤或虚拟滚动)。 → ✅ 2026-08-08 由 B7-3 目录过滤解决(实测 146 → 15 行),不再需要虚拟滚动。
6.7. ✅ 切换 EPUB tab 后目录暂时不高亮 —— 2026-08-08 已修
归因更正:不是「留着上一本书的值」,是被清空——
[activeTabId, activeEpubInfo]那个 effect 里写死了setCurrentTocHref(''), 只清不算。于是切到另一本 EPUB 后目录一条都不高亮,要等第一次导航/滚动才自愈。 修法:推导抽成epubToc.ts::resolveTocLocation(info, url, fallbackIdx)—— 由「当前 url + 书的 spine/目录」算出{chapterIdx, matched, spineBase, tocHref, tocLabel}, listener 与 effect 共用同一份(此前 listener 里一份完整推导、effect 里一份'', 正是两处各写各的才漂的)。effect 改成重算而非清空。 ⚠️ 抽取时注意保住matched:chapterIdx在没匹配到章节文件时只是回落值, 据它回写tab.epubCurrentIdx会把「不知道」写成「在第 0 章」(原代码用matchIdx >= 0守着,抽函数时差点丢掉)。+5 条单测。review-source早退分支保留不动(那是「别清空底下那本书的目录」)。
7. ✅ 全书搜索 + ⌘F 桥 + 扁平长目录过滤(B7)—— 2026-08-08 已做
as-built:
epub-fullbook-search-handoff.md。三项同轮,零 migration。
- B7-1 全书搜索:新 Rust 命令
search_epub_fullbook(commands/epub/search.rs,新模块) 扫盘上已解压的章节文件,不建索引不落库。FindMode 加「本章 / 全书」切换 + 按章分组结果列表。 计数口径 = 「本章 i/j · 全书 M」:j取页内find.js真实高亮数(prev/next走的就是它),M取磁盘扫描数,两个数由不同实现算出,刻意不宣称前者含于后者(原因见 handoff §2)。- B7-2 ⌘F 桥:
content-script/events.js加 ⌘F keydown(沿 ⌘+/-/0 的report_zoom_shortcut先例) → 新命令report_find_shortcut。要害是它比 zoom 多一步main.set_focus(): 只 emit 事件的话面板会开,但原生 key 焦点仍在 content webview,inputRef.focus()只拿到 DOM 焦点, 敲的字照旧被正文吃掉 —— 面板开了打不了字,比不响应更费解。- B7-3 目录过滤:
epubToc.ts::filteredTocRows+ TocPanel 顶部过滤框。 过滤时临时全展开、忽略折叠态(collapsed本身不动,清空后原样恢复)—— 反面做法是让折叠继续生效,那样折起来的组会藏掉命中项,用户看到「没有匹配」但其实匹配上了。 命中项保留祖先链作上下文,但不放出后代(命中一个组头就放几十章 = 那面墙又回来了)。实测(2026-08-08,The City of God +
/debug探针,非看屏幕): 磁盘独立算出真值 h-0=56 / 全书=188(8 个文件有命中),app 报「本章 1/56 · 全书 188 处」、 8 个分组、188 个<mark>—— 逐项精确吻合。跨章跳转点 FOOTNOTES 组首条 → url h-0→h-8、epubCurrentIdx9、计数变「本章 1/13 · 全书 188 处」(13 = 磁盘 h-8 的 13)。 目录过滤BOOK→ 35→15 行(含 1 条祖先上下文行),清空回 35 + 13 个箭头复现。✅ 2026-08-12 验证矩阵 26 项全部跑完(交接稿 §5.5,逐项写了证据)。其中三项此前从未被执行: B4 跨章跳转的黄色比生词高亮早 7.4 秒出现(
immediate实证); B5scroll:false分支(页面停在锚点 y=38162,对照scroll:true的 y=8730); B10 片段配额截断(the→ 全书 26380 处 / 结果行正好 200 / 提示出现, 26380 与磁盘独立复算逐项吻合)。 ⚠️ 矩阵里 B5 原写「用目录切章」在当前 UI 下不可能 —— 左右辅助面板互斥, 开目录即关查找面板;scroll:false的真实入口是 ‹ › 上一章/下一章(同一条onTocNavigate)。⚠️ 两个开发期真 bug,都是实机才暴露、单测抓不到(详见 handoff §3): ①
EpubChapter.href自带 cache_base 前缀,而它的文档注释写着 "path relative to cache_base" (注释是错的,已改)。照注释又 join 一次 cache_base → 路径不存在 →continue静默吞掉 → 全书总数恒为 0。单测测的是search_html算法本身,路径拼接没进测试。 ② 片段加粗用了String.slice(),但 Rust 给的match_start是字符数、JSslice走 UTF-16 code unit → emoji / 增补平面汉字会加粗错位置。已抽splitSnippet()按 code point 切。📌 顺带发现(未修,见下条 7.1):ncx 路径不解 HTML 实体,目录里
&原样显示。
7.1. ✅ ncx 目录标签不解 HTML 实体 → & 原样显示 —— 2026-08-08 已修(方案 A:展示层)
修法:
epubToc.ts::decodeTocEntities接进cleanTocLabel。落点选它是因为cleanTocLabel的三个调用方(TocPanel 渲染 / FindMode 分组名 /filteredTocRows匹配) 全是展示路径、无写入路径 —— 一处改动三处同时生效,reading_pages.title一字节不动。 语义对齐 Rustnav.rs::decode_entities。刻意不走innerHTML借浏览器解析(那等于把 目录标签当 HTML 执行,是白送的注入面)。连带修好:搜T & T此前零命中,现在能搜到。+6 单测。⚠️ 仍存在的代价(已知并接受):
reading_pages.title里还是&, 即笔记 / 来源页标题不会变干净。彻底修 = 源头解码 + 一次性存量标题迁移, 该等哪天有别的理由必须迁移标题时搭车做 —— 为这个低频缺陷单独动存量数据不划算。 下面保留原始诊断,供将来真要做源头修复时参考。
原始诊断(2026-08-08 B7 实测顺带发现)
`parse_ncx` 路径**不调** `decode_entities`(它只用在 `nav.rs` 的 EPUB 3 路 + 新的 `search.rs`)。 于是 ncx 里的 `- 不是疏漏,是 6.6 设计决定④刻意留的:「
decode_entities只用在 nav 这条新路上, 不回头套到parse_ncx—— nav 路此前恒产空目录、不存在存量行,随便定;ncx 路有存量」。 但那条决定没记录它的可见后果,所以这里补一条。 - 修它的代价就是当初推迟的那个代价:ncx label 经
EpubChapter.title→__RB_EPUB_META.tocLabel→effectiveSourceTitle()落进reading_pages.title, 在源头解实体会让存量行与新行标题不一致。要么只在展示层解(同cleanTocLabel的位置, 即TocPanel渲染时 —— 但那样reading_pages.title里仍是&), 要么连带做一次存量标题迁移。两条路都要先想清楚,别顺手改。 - 频率低:The City of God 全书 35 条目录里只有 1 条命中。别的书未普查。
- 附带影响:B7-3 的过滤比对走
cleanTocLabel(label),也不解实体 → 搜T & T搜不到 (得搜T & T)。修了上面这条它自动好。
7.2. ✅ 旧查询词的高亮偶尔不消失 —— 2026-08-12 已修(WKWebView 重绘失效,非状态 bug)
根因:CSS.highlights.delete() 后 WKWebView 不把原先画过的区域标脏 —— 注册表已空 (探针实证 afterWipe hlKeys=[]、done ranges=0)、计数行已对,但黄色像素要等别的东西 触发重绘才消失。这解释了 08-08 那三条状态假设为什么全是 ❌(状态一直是对的)、 为什么「几秒后自己好」、以及为什么间歇 —— 只在「没有任何东西顺带触发重绘」时才看得见 (新词 0 命中,或首个命中已在视口内、scrollIntoView 不移动;用户那次 the→vital 是后者)。
- 复现配方(一次即中):高频词(2785 处)→ 立刻换成 0 命中的词 → 0.8s 内截图。 08-08 抓不到是配方不对(那轮每步都有新高亮画上,等于自带重绘)。
- 修法:
find.js::_forceHighlightRepaint——<html>的 opacity 设0.9999、跨帧还原 (rAF + 100ms timer 兜底)。必须跨帧:同一拍设完又改回会被样式重算合并掉,等于没变。 只在CSS.highlights.size > 0时走,避免每次按键白加一次样式重算。 - 复验:同书同章同配方,修前满屏残留 / 修后干净(截图 A/B)。
_diag探针已删干净。 - 仍然有效的附带结论:find 执行本身 6-15ms,不是「点下一个卡顿」的原因。
- 详见
epub-fullbook-search-handoff.md§已结案。
7.3. ✅ 关查找面板(点工具栏按钮 / 切「排版」,非 Esc)不清高亮 —— 2026-08-12 已修
此前只有 Esc / 切 tab 走 FindMode.clearAndClose()(它才 invoke __rb_clearFind)。 点「页面工具」按钮走 toggleRightPanel('tools')、切「排版」走 setToolsMode —— 两条都只是 把 FindMode 卸载掉:查找 UI 没了、页面黄色原样留着,且界面上再没有任何入口能清掉它。 用户裁定:必须清 —— 高亮没有来源解释,用户不知道那片黄块是哪来的。
- 修法:
FindMode加一个卸载 effect,query 非空时 invoke 一次__rb_clearFind。 读queryRef而非闭包query(空依赖 effect 捕获首帧的'');「非空才清」这条守卫 同时挡住 dev StrictMode 的 mount→unmount→mount 误清刚打开的面板。 - 不影响切模块:
RightPanel挂在常驻 read shell 上、只由rightPanel决定存亡 (切模块靠 display 隐藏),所以 Read↔Discover 来回切不会丢高亮。 - 实测两条路径(City of God h-3,
the= 2785 处):① 点按钮关面板 → 干净; ② 切「排版」→ 干净 —— ②不伴随 content webview resize,等于同时压测了 §7.2 的 强制重绘(否则状态清了、像素还在)。
11. ✅ 两处「从未压测」的场景已压测 —— 长章滚动 ✅ / 表格 ✅ / 图片仍缺样本(2026-08-12)
验收稿 §4 表里 #4「长章滚动流畅度」与 #5「表格溢出」四轮 sprint 一直挂着未压测,本轮补完。
表格:不成立(不溢出、不裁切)。 全书 3 张表(h-0 两张 2 列 / h-7 一张 6 列 × 87 行价目表)。
- 默认栏宽:表格完整落在 680px 阅读栏内,横向滚动测试
scrollX恒 0(既没有横向溢出, 也不是被overflow:hidden悄悄裁掉 —— 两者靠"能不能横向滚"就能分开)。 - 极端叠加 200% 字号 + 560px 窄栏:单元格内换行,仍不溢出。
table.middle3无固定宽度,是流式的。 - ⚠️ 图片场景仍未压到,别记成"已验证":这本书除封面 SVG 外 0 张
<img>,压不到。 需要另找带插图的书(技术类 / 童书)才算数。
长章滚动:流畅度没问题。 最长章 h-6 = 232KB / 可滚 65977 物理像素 ≈ 47 屏 / 3627 元素 / 2792 个待发现词高亮。
- 用户实测(连续甩动 + 切章后立刻甩动):无掉帧、无空白块、高亮落地无肉眼跳动。
- 探针四项佐证:① 滚动指令往返 4–5ms,管线运行期与跑完后无差(高亮管线是分块 yield 的, 没有占死主线程);② 连续 20 次快滚后目录联动仍精确 —— 且命中的是同名
ARGUMENT里正确的那一个 (用"活动行的下一行 = BOOK THIRTEENTH"证实);③ 高亮落地造成的正文位移仅 14 逻辑像素(0.02 屏); ④ 最长章 vs 最短章的高亮等待只差 1.5–3s(见下条)。
12. 🟢 CEFR / 待发现词高亮被短语 LLM 往返堵在后面 —— 前 ~5 秒正文一片素净(2026-08-12 实测)
不是 EPUB 专属:网页阅读走同一个管线。测长章时顺带挖到的。
content-script/index.js 的管线 job 里三步同一个 job、顺序固定:
js
await analyzePhrasesOnPage(); // 短语判定 = LLM 网络往返,日志实测 4–7.6s
await highlightVocabulary(); // 本地,便宜
await highlightDiscoverable(); // 本地,便宜于是本地那两步被堵在网络往返之后,三者要么都没有、要么一起出现。实测采样(每 0.35s 一次): 0 → 5.6s 全章 .rb-discover 恒为 0,6.0s 突然有,6.4s 到 2504。
判据是章长无关:最短章 h-10(19KB / 3.5 屏)首次出现 4.7s,最长章 h-6(232KB / 47 屏)≈ 6–7.7s —— 内容差 47 倍、等待只差 1.5–3s,说明这几秒基本是那个网络往返的固定成本,不是渲染量。
🔴 更正(2026-08-12 当日):上一版这里写的修法「把两步提到前面即可、对短语零影响」是错的 (同日 commit b7388f8 的 message 里也这么写了,以此处为准)。用户提示"当时是不得已"后回查, index.js 那段注释白纸黑字写着理由:
统一 AI 短语分析(策略 3 phrase-first,judge-before-paint)必须在 vocab 之前:完整文本节点 → 满召回
坐实:match_phrases_in_node_texts(texts: Vec<String>) 是按文本节点送进 Rust 匹配的。 vocab 一旦 replaceChild 把词包进 <span>,那个文本节点被切成三段,跨边界的短语再也匹配不到 ——与 find.js「匹配不跨文本节点」同一套物理。所以那个"顺手一换"会静默少召回一批短语: 不报错、只是短语变少,是最难自查的一类回归。这个顺序是承重的,别动。
而且这几秒是当时知情接受的代价(原注释:「增强层等几秒、正文不阻塞,全局『分析中』提示兜底」), 提示也确实存在:discovery.ts::phraseAnalyzing = '正在分析此页…(短语 · 生词 · 待发现词)' (phrase-analysis-status 事件 → DiscoveryPanel)。
仍然成立的只有「现象 + 量」(0→5.6s 恒 0、6.4s 到 2504、章长几乎无关)。真要改,候选是:
(a) 静态档先画、LLM 判定后移:
idiomaticity:always档不需要 LLM(本地 matcher 毫秒级), 可以先跑完 always + vocab + discovery,把需要 LLM 的context档挪到最后。 代价 = context 档那批短语落在 vocab 之后,召回会掉(它们恰恰是"看情况"的那一档)。最有希望的一条。⚠️ (a) 不是纯调度优化——它直接翻 D5(2026-08-26 复核补记)。 「静态档先画」= 把
always档不经 LLM 端到用户面前,而phrase-unified-llm-refactor-plan.md的 D5(用户拍板) 恰恰是「不分好/次档:开 = 全量 LLM,关 = 不画短语,静态 floor 不再端出」。 原文把 (a) 写成调度改动,会让后来者以为不涉及产品决策。动它之前先走 D5 的翻案流程。D5 现状:仍然生效,但两条支柱只剩一条。
- 支柱①(仍成立):短语非组合性是 occurrence 级问题,type 级静态标签构造上收敛不到可信 —— gate-A 残留 FP 全是同短语不同句的陷阱(
all along正因此从 always 降 context, 见本文件「floor 清理 round-3」条)。 - 支柱②(已失效):原论据还有一条是「静态档没过 LLM,点击时会被第二个 judge(
explain_phrase) 推翻 → 当着用户面撤高亮」。explain_phrase在同一次重构里就删干净了,现在点击读 span 上 缓存的data-rb-pos/data-rb-sense(popup.js::applyPhraseSenseFromSpan),没有第二个 judge, 也就不存在撤销打脸。静态档今天的代价只剩「画得没那么准」,不再是「自相矛盾」。
另有一条 D5 拍板时不存在的支持论据(2026-08-26 起才成立):Settings 已改成 「一个同意闸 + 功能子项」(见 CHANGELOG 同日条目),静态档因此有了干净的表达位置—— 「短语高亮」子项可以在同意闸关闭时仍然工作(纯本地、零外发)。D5 拍板时开关只有 「全量 LLM / 不画」两态,没地方放这种中间形态。
翻案前置:重跑
phrase-eval-log.md的 gate-A,拿到always档 (v24 起 2776 条)当前的 precision 数字。没有这个数字就是在拿印象翻一个拍过板的决策。- 支柱①(仍成立):短语非组合性是 occurrence 级问题,type 级静态标签构造上收敛不到可信 —— gate-A 残留 FP 全是同短语不同句的陷阱(
(b) 快照 + 迟贴:先 snapshot 文本节点(不改 DOM)→ 立刻跑 vocab/discovery → LLM 回来后贴短语。 代价 = 贴的时候 DOM 已被切碎,短语定位得写一套跨节点 Range(find.js 的
collectRanges不跨节点), 且短语要可点击(find 的 CSS Custom Highlight 不可点)。工程量明显大于收益。(c) 什么都不改,只把「正在分析此页…」从 Discovery 面板挪到阅读面:现在这个提示只在面板里, 而读者多半没开面板。最便宜、也最诚实。
用户实测反馈"感觉高亮一直都在"其实是错觉,但没造成体验问题 —— 落地时位移只有 14px、 无回流无闪烁,抓不到那一瞬。故本条降为 🟢,属"想清楚再动",不是待修缺陷。
8. 🟢 改名/移动 → 划线全丢 + 孤儿缓存不回收(B8)
epub.rs:33 path_hash = sha256(完整路径)[..8] → 改名后缓存目录与 rb-cache:// URL 全变, 按来源页归属的 page_annotations 失联。A/B 实测:改名副本该章 .rb-annotation = 0, 同时刻旧 hash 标签页同一章 = 2。且旧解压目录永不回收(本书每改一次名泄漏 2.1MB)。
- 两半可分开修:归属改为按内容指纹/稳定书标识;缓存加 LRU 或启动清理。
- 优先级低(用户不常改名),但移动文件到别的目录同样触发,别只按「改名」估算发生率。
📌 2026-08-11 更新——本条的范围已经变小,且身份键已收敛到一处。 同日的数据卫生修复(
data-hygiene-identity-plan.md) 删掉了prepare_epub里那个第二个笔记本 owner,EPUB 现与网页共用save_word → auto_create_note_for_domain单一通路,身份键统一为notes::extract_domain(文件路径)。 ⇒ 本条要改的只剩一个地方(那个键的取值),不必再担心「改了 A 处 B 处还按旧键走」。 ⚠️ 但难度没变:epub/mod.rs::path_hash的缓存目录键与extract_domain的笔记本键 是两套独立的路径派生,改成内容指纹时两处都要动,且要考虑存量迁移 (已有的reading_pages.cached_file_path指向旧 hash 目录)。 另:canonical_source_url(本次新增)依赖cached_file_path反查,若缓存键变了, 存量行的反查会失效 → 一并回归。
9. ✅ 列表元数据取值不讲究(B9)—— 三条都已修(2026-08-11,2026-08-12 复核确认)
本条一直挂着 🟢,但 2026-08-11 的数据卫生轮(commit
31e0195)已把三条全带走了, 2026-08-12 逐条实测复核:
- EPUB 标题:
useNavigation.ts现在传epubInfo.title?.trim() || fileName, DB 实查resource_history=The City of God, Volume I/Romance(不再是带作者带编号的文件名)。 存量行靠log_resource_open的title = COALESCE(?4, title)下次打开即自愈。- 裸 URL 当标题:
resourceDisplayTitle统一兜底,绝不回落entry.uri; 实查 text 来源那两行标题都是真实标题。- 「继续阅读」粒度:
get_continue_reading已改按书归并(BOOK_KEY= 剥#锚点的路径 +ROW_NUMBER() OVER (PARTITION BY …),LIMIT在归并之后),每本只出最近读的那一章、saved_word_count改成全书累计去重;配套单测continue_reading_tests专门断言跨章累计 (光测「一本书只出一条」抓不到相关子查询绑错外层那个静默错误)。
10. ✅ TOC 被右面板驱逐后不恢复 —— 每次 ⌘F 都要手动重开目录(N3)—— 2026-08-12 已修
⚠️ 这条 2026-08-06 验收就发现了(
epub-longform-acceptance-handoff.md§4.1 的 N3), 但当时没抄进 backlog —— 上面那 9 条覆盖的是 N1/N2/N4/N5/N6,唯独漏了 N3。 2026-08-12 做 B7 验证矩阵时从另一头又撞上同一件事(B5 那条「面板开着时用目录切章」 在当前 UI 下根本不可能发生),才发现它没被跟踪。
usePanelStore 的 openRightPanel/openTools 显式写 leftPanel: null(单辅助面板互斥), 而 closeRightPanel 只清右侧、不恢复左侧。后果:
- 读书时按 ⌘F → 目录没了;关掉查找 → 目录不回来,得手动再开一次。
- 连带 B7 的一个可用性缺口:查找面板与目录无法同时存在,所以「一边看命中一边按目录跳章」 这个自然动作做不到(只能用 ‹ › 上一章/下一章)。
判断(沿用验收稿):TOC 与其它左面板不是一类东西 —— Sites/History/RSS 是瞬时导航(点完就走), 自动弹回是噪音;TOC 是文档结构框架,读书时半常驻。Kindle / Apple Books / 浏览器 PDF 阅读器都不会让查找框吃掉目录侧栏。 as-built(usePanelStore):新增 preemptedLeftPanel: 'toc' | null —— 类型就写死 'toc', 这条恢复语义只对目录成立,不靠注释约束。抽两个共用 patch: preemptLeft()(4 个调用点:openRightPanel / toggleRightPanel / openTools / toggleTools)
restoredLeftPanel()(3 个关闭点)—— 写七遍必漂。与lastLeftPanel不是一回事 (那个服务 ⌘B「打开上次用过的」、记的是偏好且从不自动触发)。
四条不显然的边界,都已实测:
- 跨右面板切换不能冲掉记忆:从「查找」切到「精读」会再走一次
preemptLeft, 那时leftPanel本来就是 null —— 无条件写 null 就等于忘了先前抢的是目录。 故只在leftPanel === 'toc'时才覆盖。 - 用户手动动过左面板 → 记忆作废(4 个手动入口全清):他已经表过态,再"还"一个 目录回来就是跟人抢方向盘。
- 恢复前要确认目录还有内容可显示:抢占期间可能切到了网页 tab(FindMode 切 tab 时 自己
clearAndClose→ 正好走关闭路径),否则会恢复出一个只有标题、body 全空的面板。 判据与 AddressBar 的isFileTab同源,经useTabsStore.getState()读一次。 ⚠️ 依赖方向单向:useTabsStore不引用 panel store,反向引用会成环。 - 没有抢占记录时
restoredLeftPanel返回当前值而非 null:useReviewStore::restoreChrome退出专注复习时先openLeftPanel(snap)再closeRightPanel(),返回 null 会把刚恢复的左面板抹掉。
实测 6 例(/debug 探针读 store,City of God):开工具→目录让位且记下 → 关工具→目录自己回来 · 工具→精读→关精读 仍回来 · 抢占中手动开「历史」→ 记忆作废、关历史不冒出目录 · 抢占中切到网页 tab → 不在网页上恢复空目录 · 切回书 → 目录回来(那是既有的 useContentTools 按 tab 类型开合,与本改动同向不打架)。
🔴 连带揪出一个潜伏冲突:一次 Esc 被处理两遍(用户实测「⌘F 后按 Esc 目录没回来」, 而点面板头的 × 却回得来 —— 差异恰好把它照出来)。
FindMode.handleKey的 Escape 分支只preventDefault()、不stopPropagation(), 而 document 上还挂着useKeyboardShortcuts的全局 Escape 阶梯 (rightPanel==='tools'就关它并 return,否则onToggleSidebar()+closeLeftPanel())。 React handler 先跑并关掉 tools → 同一个原生事件冒到 document 时那个条件已不成立 → 直落closeLeftPanel(),把刚恢复的目录又关掉。 在恢复逻辑落地前这条 else 分支恒为空转(左边本来就没东西),所以它一直没显形。 修法 = FindMode 的 Escape 补e.stopPropagation();同款处理TocPanel过滤框早就有了。 📌 教训:给「原本什么都不做」的分支加上真实效果时,要回头查有没有别人依赖过它的空转。
⚠️ 顺带记:本轮手动验证一度被
FindMode的IDLE_COLLAPSE_MS = 30000(空查询闲置 30s 自动clearAndClose)误导过 —— 请用户"按 ⌘F 后停住"再回报,等回报到达时面板早自己关了, 读到的left=toc / right=None与「⌘F 根本没到达」完全同形。 以后凡是要人工保持查找面板状态的验证,先让面板里有查询词(非空即取消闲置计时)。
没选的另一条路(成本高):让 TOC 不占「左辅助面板」这个互斥槽位。
❌ 已评估否决——文章级 LLM 简化复述(graded recap)
2026-06-30 评估后决定不做,记此防止以后当新点子重新讨论。
想法:reader 内容经 LLM 一键生成"难度适中的简化复述/概要",自然嵌入页面高亮生词/短语, 生成文支持高亮查词 + 朗读 + 跟读。
否决理由:用户拍板本功能目标 = 掌握单词。在这个目标下,它打不过已落地的 SRS + ⑥cloze(真实句挖空回忆)+ ④个性化例句(句级新语境)——三者更轻、更省、spaced。 recap 真正不可替代的价值是另一个目标("读懂偏难文章 + 篇章可理解输入"),不是掌握单词; 且它在整条 LLM roadmap 里三项负担最高:篇章级幻觉风险、全文 input 成本且不可跨用户摊薄、 引入合成内容稀释"用户自带真实内容"身份。跟读机器复述文属炫技。
复活条件:仅当目标重定义为"帮读懂偏难文章"时再议——且届时首选更轻的对照方案 「整篇导读 TL;DR + 本篇生词小结」(锚定原文、不产合成文、成本低一量级、幻觉面小), 原始"生成复述"仅作其验证有人用之后的备选;并只在
difficulty.ts判定"对你偏难"时兜底露出。