主题
推荐系统算法全景
AI 驱动的高质量英文阅读资源自动发现 → 维护 → 个性化推荐系统。 最后更新:2026-08-04(补 §4.7 待发现词价值排序 = roadmap 3-2 打分规则)。纯 RB + Supabase,无 RVH。 关联:
docs/database-schema.md(表结构)、docs/plans/archive/tier-aware-recommendation-sorting-plan.md(分级排序)、docs/plans/archive/recommend-graded-supply-side-plan.md(供给侧补强)、docs/plans/archive/article-decision-card-plan.md(决策卡 Phase 1/2)。
0. 一句话定位
不贪大而全,质量过关、稳定、安全——用 LLM 定期自动发现并审核优质英文阅读站点, 抓取其文章、评估 CEFR 难度入池,淘汰失效源;客户端按用户水平做 i+1 个性化排序。
系统分三个子系统,靠一个 Supabase 资源池解耦:
┌──────────── 服务端(Supabase Edge + pg_cron,全局共享)────────────┐
│ │
[A] 站点发现与维护 [B] 文章抓取与评级 │
discover-sites analyze-articles │
(周更) (日更) │
│ │ │
└────────┬─────────────────┴───────────┐ │
▼ ▼ │
┌─────────────────────────────────────────────┐ │
│ 资源池(Supabase 表) │ │
│ recommended_sites / _feeds / _articles │ │
└─────────────────────────────────────────────┘ │
└─────────────────────────┬───────────────────────────────────────┘
│ REST 拉取(refresh_remote_data)
▼
┌──────────── 客户端(RB 桌面端,每用户私有)────────────┐
│ [C] 个性化推荐 │
│ 本地镜像 → 用户水平估计 → i+1 排序 → HomePage 展示 │
└─────────────────────────────────────────────────────────┘| 子系统 | 在哪跑 | 频率 | 职责 | 关键文件 |
|---|---|---|---|---|
| A 发现与维护 | Supabase Edge | 周更(cron) | 发现新站 / 健康检查 / 淘汰失效 / 评 band | supabase/functions/discover-sites/index.ts |
| B 抓取与评级 | Supabase Edge | 日更(cron) | 抓 RSS / 选样 / 评 CEFR / 7 天过期 | supabase/functions/analyze-articles/index.ts |
| 资源池 | Supabase PG | — | 站点/feed/文章三表,全局共享只读池 | supabase/sql/sync-tables.sql |
| C 个性化 | RB 客户端 | 实时 | 镜像 / 估水平 / i+1 排序 / 分桶轮换 / 决策卡 | recommend.rs · recommendRank.ts · reading.rs · HomePage.tsx |
0.1 两个必须先建立的心智模型
理解整套推荐,先抓住两条"分层"——它们贯穿全文,也是 Phase 2 设计的地基。
心智 A:推荐分两层——「全局通用」在服务端,「个性化」在客户端
推荐不是服务端针对某个用户算的。服务端(子系统 A/B)对所有用户产出同一份通用资源池(哪些站好、哪些文章值得读、客观难度多少);个性化完全在客户端,用只存在本地的信号(你的水平 / 兴趣 / 在学词 / 词表)对这份通用池做二次排序与呈现。
服务端(一次算,服务所有人) 客户端(每台设备各自算,用户数据不出设备)
站点/文章 + 客观 CEFR + 摘要/正文 ──REST──▶ 你的水平估计 → i+1 排序 → 决策卡(相对难度/阅读时间/在学词)为什么这样分:user-agnostic 的贵活(LLM 评级、抓正文、算 word_count)只该算一次、摊薄到所有人;per-user 的活("这篇对你多难""这篇有几个你在学的词")依赖你的私有词表,放客户端既天然个性化、又让词表永不离开设备(隐私)。改推荐时先问自己:这个信号是 user-agnostic 还是 per-user? 前者进服务端资源池,后者进客户端。
心智 B:难度是「两个轴」,不是一个数——客观难度 vs 对你而言的难度
一篇文章的"难度"其实是两个不同问题,用两种算法、落在卡上两个位置,彼此不换算:
| 轴 | 谁算 | 回答 | 算法 | 卡上位置 |
|---|---|---|---|---|
| 客观 CEFR | 服务端 LLM(读正文摘录) | 这篇本身多难(人人相同) | LLM 语体判断(语法/习语/抽象度) | 中性 CEFR 徽标(常驻) |
| 词汇覆盖难度 | 客户端(全文词 × dict CEFR × 你的词表) | 这篇对你多难 | 覆盖率算法(见 §4.6) | 相对难度档(轻松/刚好/偏难) |
常见误解:「客观 CEFR = B1,那 i+1 就是把 B1 当推荐结果」。错。B1 只是客观徽标;相对难度档是独立用覆盖率算的——同一篇 B1 文章,对词汇量大的人是"刚好",对小的人是"偏难"。两轴回答不同问题,不需要、也不应该强行比较。
为什么词汇轴放客户端:词汇覆盖要看全文所有词 + 词形归一(lemmatizer),而 lemmatizer 只在客户端(Rust/预装库)有——服务端(Deno)没有,也不该再养一个(跨端漂移风险,红线 #9)。语体轴反过来:摘录就够判(语体在全文里均质),所以交给读摘录的 LLM。摘录对词汇轴不准(漏后文难词)、对语体轴够用——这正是两轴分工的根据。
1. 资源池(数据模型)
三张 Supabase 表,public read / service write(RLS),客户端只读拉取本地镜像。
| 表 | 角色 | 生命周期 | 关键列 |
|---|---|---|---|
recommended_sites | 站点池 | 发现进 / 轮换淘汰(is_active=false) | quality_score(1-5) · cefr_band · is_active · last_evaluated_at · has_rss/rss_url |
recommended_feeds | RSS 源池 | 随父站进/出(按 rss_url 关联) | tag(=category) · cefr_band(继承父站) · is_active |
recommended_articles | 文章池 | 日更写入 / 7 天 TTL 自动过期删除 | cefr_level · category · recommendation_reason · expires_at · word_count(阅读时间)· body_text(客户端算覆盖难度/在学词,Phase 2) |
band vs level:
cefr_band是站点/源的难度档(grounded 审核给);cefr_level是单篇文章的难度(analyze 逐篇给)。 文章池的 A1/A2 供给由后者决定,与源 band 独立。
2. 子系统 A — 站点发现与维护(discover-sites)
2026-06-16 Phase 2 池模型反转:废弃旧
POOL_CAP=100churn 模型(池满轮换淘汰垫底)。 新模型 = 稳定核心 + 罕见高门槛增补 + 只淘汰确认失效,加站与淘汰解耦。 治理状态入 Postgres:recommend_config(阈值)/recommended_blocklist(持久拒绝记忆)/recommended_sites.is_pinned(白名单)+.admitted_at(最小存活期)/recommend_audit_log(决策审计)。 详见docs/plans/archive/recommend-agent-evolution-phase2-plan.md。
入口:Deno.serve。每轮永远先跑 health-check(维护稳定核心),仅当 active 池 < pool_target_size(默认 80)才再跑 discover(保守增补)。加站/淘汰解耦、无上限 churn。
┌─────────────────────────┐
cron / 手动 ───▶ │ discover-sites │
invoke │ ?mode= / ?dry= │
└───────────┬─────────────┘
│
┌───────────────────────┼───────────────────────┐
▼ ▼ ▼
mode=backfill-bands [永远] HEALTH [池<target] DISCOVER
(手动补 band) 健康巡检,只淘汰确认失效 双源发现+高门槛增补
│ │ │
▼ ▼ ▼
只回填 cefr_band 见 §2.2 见 §2.1
(mode=health / discover 可强制单跑;dry=1 预览不写库;预算闸:本月 LLM 花费 ≥ 配置则整轮跳过)2.1 DISCOVER 模式(池 < pool_target_size 时保守增补)——双源 + 三层管线
设计哲学:LLM 提名可能幻觉,所以"生成"与"采纳"之间夹两道接地(grounded)关卡, 审核只信抓回来的真实样本、不信模型记忆。Phase 2 起候选来自并列双源(治幻觉同时不丢全局发现)。
[L1 双源生成] 候选 = 两源并集(_source 记入审计)
│ · S1 discoverNewSites — LLM 记忆(reasoner, DISCOVERY_LLM_MODEL):全局/新站,跳出局部最优
│ · S2 harvestSeedLinkCandidates — 种子外链(grounded):抓 seed_link_sources 个 top 站首页,
│ 取**一跳**外部可注册域名(≠自身/社交/CDN/池内/黑名单),reasoner **只在真实域名里挑/标**
│ (classifySeedCandidates,防幻觉 guard:输出域名必须在候选集内)。绝不递归外链的外链。
│ 并集 → 按可注册域名去重 + 过滤(vs 现有池 + recommended_blocklist)
▼
[L2 机械校验] validateSites — 逐站:URL 可达?抓首页 <title>/<meta desc> 采样;
│ RSS 校验+修复/自动发现;抓 feed 前 3 条 item;★算 cleanReadingScore(script/广告域/iframe 密度 + 正文占比)
│ 输出 ValidatedCandidate[](真实样本 + cleanScore)
▼
[L3 接地多维审核] auditSites — 快模型(LLM_MODEL,非 reasoner),只凭真实样本 + cleanScore 信号:
│ · keep(排除视频/音频、娱乐、非英文、付费墙、垃圾、广告饱和)
│ · tier + cefr_band(A2/B1/B2/C1) + 纠正 category + quality_score 1-5
│ · ★多维分:reading_value / language_authenticity / content_form / ad_noise(各 1-5)
▼
[高门槛 admit] 仅 quality_score ≥ min_admit_quality(默认 4)才入池;
│ keep=true→admit(写 admitted_at + audit discover_admit + 来源/多维分入 score_detail)
│ keep=false→reject(域名入 recommended_blocklist[auto_reject] + audit discover_reject)
│ 低于门槛→reject(仅 audit,不拉黑——非硬拒)
▼
[落库] upsert recommended_sites(onConflict=url,幂等,admitted_at=now)
+ 若 has_rss:upsert recommended_feeds(feed 继承父站 band)2.2 HEALTH 模式(每轮巡检,只淘汰确认失效;替代旧 ROTATION 换血)
[取巡检集] health_check_batch 个 quality_score 最低 / last_evaluated_at 最旧的 active 站
│ ★Phase 2 过滤:is_pinned=false(白名单跳过)+ admitted_at 过 min_survival_days(最小存活期 anti-thrash)
▼
[健康探测] checkSiteHealth(2 次重试):unreachable / http_error(5xx/429/403) / ok(抓样本)
▼
[接地裁决] judgeHealthFromSamples — 快模型,只凭真实样本:
│ still_active=false 仅当:域名停放/出售、报错/占位、垃圾、无内容、非英文、明显废弃
│ ★保守降级:http_error 永不停用;只有"确认不可达"或显式 still_active=false 才停
▼
[淘汰] still_active=false → is_active=false(连带停用同 rss_url 的 feed)+ 域名入 recommended_blocklist[eliminated_dead]
所有评估站更新 quality_score + last_evaluated_at;逐站写 audit(rate / eliminate)
▲ 无"补位换血"——加站与淘汰解耦,缺口由下一轮 below-target discover 自然补
?mode=health/?mode=discover:强制单跑某一支。?dry=1:health 返回裁决报告 / discover 返回候选预览,均不写库。?mode=backfill-bands:分块(每批 12)auditcefr_band IS NULL的 active 站,只回填 band,幂等可重跑。 预算闸:handler 启动查本月SUM(llm_call_log.cost_usd) ≥ monthly_llm_budget_usd→ 整轮跳过 + 记edge_run_log(budget_skipped)。
2.3 质量评分说明
quality_score无数学公式——L1 生成时 LLM 给 1-5,L3 多维审核 / health 巡检时据真实样本重判覆盖。- 被拒/被淘汰站点有持久记忆(Phase 2):
recommended_blocklist记 auto_reject(审核拒)/ eliminated_dead(确认死站),discover 提名 + analyze 抓取前置查表跳过 → 止住重复提名/重复审核(旧痛点已解)。 - publisher 去重未实现:靠 L1 prompt「不与现有列表重复」+ 候选按可注册域名去重 + L3 审核兜底。
3. 子系统 B — 文章抓取与评级(analyze-articles)
日更,从 feed 池抓文章、选样、评 CEFR、写文章池、清过期。
[1 读源] select url,title,cefr_band from recommended_feeds
▼
[2 并发抓取] fetchRSSFeed × 所有源(10s/源,每源最多 20 条;RSS+Atom;摘要截 500)
│ 每篇打上 feed_band(= 源的 cefr_band);★同时记 content:encoded / Atom content(若源自带全文)
▼
[3 选样] publisher 级 round-robin,TARGET=20 ★band 感知(2026-06-15 修)
│ · 按 registrable domain 分组、组内按 published_at 新→旧
│ · graded 组(任一 feed band ≤ B1)排最前 → 每轮必采(防分级源饿死)
│ · rest 组(native/未知)按 30min 时间偏移轮换(防靠后源永久饿死)
│ · round 0 起每组取最新 1 篇,填满 20 即停
▼
[3.5 抓正文] ★Phase 2(2026-07-29):只对选中的 20 篇抓正文(extractBody,并发、单篇失败静默)
│ · 优先用 content:encoded 全文(免抓 URL);否则 fetch 文章 URL + deno-dom 启发式抽正文
│ (去 script/style/nav/header/footer/aside/form 噪声块,取 <article>/<main>/<body> 的 <p> 段落)
│ —— 原计划 @mozilla/readability,但其依赖链的 linkedom→canvas 原生模块 Supabase 云端 bundler 打不了,改 deno-dom(WASM、Deno 原生、无 canvas)
│ · < 120 词视为失败 → 回落(该篇仍用 summary,body_text/word_count 下发 null)
│ · 选后按 url 去重(同文章多 feed 收录 → 批量 upsert onConflict 21000 防护)
│ · 产出:body_text(干净正文,下发客户端)+ word_count(阅读时间)+ excerpt(前 2000 词,喂 LLM)
│ · 只抓选中 20 篇(非全部 ~400 候选)——省 20× 网络
▼
[4 评级] analyzeWithLLM(单次调用评全部 20 篇)
│ 每篇喂:Title + Source level:<feed_band|unknown> + ★Text = excerpt(正文前 2000 词) ‖ 回落 summary(200 字)
│ prompt:CEFR 锚点(A1=最基础/A2=日常简单句~2000词/B1=连贯/B2+=抽象)
│ + 「Source level 作强先验;分级读物(level 1/2/3、儿童新闻)就是 A1/A2,勿因是新闻就抬到 B1」
│ ★Phase 2 起看正文摘录 → 语体感知的 CEFR(原只看 title+summary,见 §7);抓取失败的篇仍回落 summary + band 先验
▼
[5 落库] upsert recommended_articles(onConflict=url;cefr_level/word_count/body_text 每次覆盖重评)
▼
[6 清理] delete where expires_at < now()(默认 7 天 TTL)
▼
[摘要] "Analyzed N ... ; bodies OK/20; upserted M"(bodies 计数 = 成功抓到正文的篇数,观测抓取健康度)为什么 A1/A2 长期偏薄(根因已修,见供给侧 plan):①选样位置饿死(新分级源永不被采样)→ band 优先修; ②CEFR 只看标题→偏 B1(News for Kids A2 被评 B1)→ feed band 先验修 + Phase 2 正文摘录进一步修。
成本(Phase 2 后,日更一次):喂 LLM 从 200 字摘要升到 ~2000 词摘录 × 20 篇 ≈ 41k input tokens/次 → 低成本模型 ~$0.007/次 ≈ 月 +$0.2,可忽略;真成本在 20 篇/次的正文抓取(网络/超时)+ Deno readability。有月度预算闸兜底。摘录 vs 全文进 LLM 的 CEFR 偏差留独立验证任务(见 backlog)。
4. 子系统 C — 客户端个性化推荐
服务端池是全局共享、level-blind 的;个性化全在客户端,按单个用户的水平 + 兴趣 + 在学词做。
4.1 取数:本地镜像(recommend.rs)
refresh_remote_data(启动/刷新触发)REST 拉 Supabase → 本地 SQLite 镜像(is_default=1): fetch_public_sites / fetch_recommended_feeds / fetch_recommended_articles(只拉 expires_at>now 的,limit 20)/ fetch_recommended_excluded_words。 cefr_band/cefr_level/word_count/body_text(Phase 2)全程透传入本地 recommended_articles。get_cached_recommendations(level, limit) 读本地镜像,按 CEFR 距离稳定兜底排序(前端再二次个性化排)。
4.2 用户水平估计(reading.rs compute_estimated_level)
按用户已学词(notebook ∩ vocabulary)的 CEFR 累计分布,取首个达标档;够不到则停在 A1:
| 档 | 累计阈值(该档及以上已学词数) |
|---|---|
| C2 | 20 |
| C1 | 50 |
| B2 | 100 |
| B1 | 200 |
| A2 | 100 |
| A1 | 50(兜底) |
输出 estimated_level + total_vocab(驱动门控)。无截止期,自然到达。
4.3 文章排序(recommendRank.ts scoreArticle/rankArticles)
门控:若 无 level 或 total_vocab < MIN_VOCAB_FOR_TIER(=10) → 不排序,保留 Rust 原序(不给新手施压)
否则每篇打分:
diff = cefrRank(文章) - cefrRank(用户)
cefrFit = diff==1 ?1.0 : diff==0 ?0.7 : diff<=-1 ?0.4 : diff==2 ?0.3 : 0.0 ← i+1 甜区峰值
interestHit = 文章 category∈top_tags 或 domain∈top_domains ? 1 : 0
learnWeight = min(命中在学词数, 3) / 3
score = 0.55·cefrFit + 0.25·interestHit + 0.20·learnWeight
按 score 降序,等分 tie-break = 原 Rust 下标(保 recency)too-easy 给 0.4(非 0):简单文章仍出现、只排后,不施压。
4.4 站点/Feed 分桶排序(orderByBandThenRotate + bandBucket)
门控失败 → 退回整体 rotateArray(老行为)
否则按 band 分 3 桶,桶内各自按 dayIndex 轮换,再拼接:
d = cefrRank(band) - cefrRank(用户)
fit : band 非空 且 d ≤ 1(可理解)
neutral: band 空 或 d == 2
stretch: d ≥ 3
顺序 = rotate(fit) ++ rotate(neutral) ++ rotate(stretch)低水平用户 → 分级站顶前但每天轮换;B2+ 用户 → fit 桶天然是母语站,保持母语优先。
4.5 展示层(HomePage.tsx)
- 每日轮换:
dayIndex = floor(now/86400000),rotateArray按天偏移。 - 文章决策卡
RecommendedArticleCard(roadmap 3-1):客观 CEFR 徽标(常驻)+ 相对难度档(彩色,仅"刚好"显绿)+ 阅读时间(Phase 2)+ 摘要两行 + 在学词 chips(安静 display,不可点)/ 兴趣理由(同槽一条)。 - 相对难度档:有正文覆盖直方图 → 词汇覆盖档(
coverageTier,见 §4.6,精确);否则回落difficultyTier的 CEFR 减法档(对你刚好/略有挑战/偏简单)。门控失败(词汇量 < 10)回退纯客观 CEFR 徽标。 - 阅读时间 ⑧+(Phase 2,
readingMinutes):word_count ÷ CEFR 分级 wpm(难→慢),缺 word_count(旧数据/抓取失败)则不显。 - 个性化理由 ⑨(
buildReason)优先级:在学词命中 → 兴趣 tag 命中 → 文章全局recommendation_reason→ 无。 - 切片:文章 5/展开 20;站点·feed 4/展开 15。
4.6 词汇覆盖难度 + 全文级在学词复现(Phase 2,reading.rs::analyze_article_bodies)
Phase 2 新增的 per-user 正文分析——心智 B 的词汇轴落地。HomePage 拿到带 body_text 的推荐文章后,批量调 analyze_article_bodies(纯读 Rust 命令),一次遍历产出两样:
[载入一次] 在学词集合(notebook 未掌握 − known − reference)
+ known_words 集合
+ 预装库 word→primary_cefr_level(~12k 行入内存 HashMap,免逐 token 查库)
▼
[逐篇·逐运行词] tokenize(body) → lemmatizer::normalize 归一(红线 #9,口径同查词/复习)
│ · 在学词命中:归一后 ∈ 在学词集 → 收集(去重、cap 3)= 全文级在学词复现(chips 数据源)
│ · 覆盖直方图(仅正文):归一后能在 dict 查到 CEFR → graded_total++;
│ 若 ∉ known_words → 按其客观档累加 a1..c2(known_words 词无条件视为已掌握、不入超纲桶)
│ 查不到 CEFR 的 OOV/专名 → 不入分母("能评级的词里"论覆盖)
▼
返回 { id, hits[], coverage: {graded_total, a1..c2} | null(无正文时)}前端 coverageTier(bands, userLevel, totalVocab)(difficulty.ts)据此算档:
门控:totalVocab < 10 或无 userLevel → null(回落纯客观徽标,不给新手施压)
超纲 = Σ (客观档 > userLevel 的 a1..c2) ← bands 已排除 known_words
覆盖难度 = 超纲 / graded_total
< 2% → 轻松
2–5%(≈98% 已知,i+1 甜区)→ 刚好(绿)
> 5% → 偏难为什么比 §4.3 的 CEFR 减法准:减法把整篇压成一个 band 再减用户 band(粗);覆盖率数的是全文每个运行词相对你的词表的真实超纲比例(细)。且用 dict 客观 CEFR 判"已掌握"(B1 用户默认认得所有 A1–B1 词),绕开旧「生词比例」靠 notebook 收录完整度的噪声坑(老用户已知常见词没进本子 → 每篇都误判偏难)。阈值对齐二语习得 ~95–98% 词汇覆盖甜区,可后续按遥测校准。
4.7 待发现词价值排序(roadmap 3-2,discoveryRank.ts)
口径说明:这一节推荐的不是资源而是词 —— 它不经资源池、不碰 §1-§3 的服务端链路。 放这里是因为它与 §4.3/§4.6 同属「客户端个性化排序」,且刻意沿用同一套先例(排序放前端), 对照读最省力。as-built
docs/plans/archive/discovery-topn-ranking-plan.md。
DiscoveryPanel「潜在词」频道原先排的是字母序,不展开只看得到 abandon…arbitrary 那批(候选池实测 12,072 词,默认启用 B1+B2 = 7,632)。改成四维加权排序 + 前台只露 Top-9,全表降级为「全部候选词」折叠区(折叠区仍按字母序 —— 那里是通览找词,不是决策)。
四维已各自归一到 [0,1] 再乘权重(归一后权重数值可直接横向比较):
score = 2.0 × min(page_freq, 5)/5 页内频次 ← 本文反复出现 = 读懂这篇的关键词
+ 1.5 × min(encounter_count, 5)/5 跨页复现 ← 多篇里碰到 = 对这个用户长期有用
+ 1.0 × generalValue(frequency_rank) 通用价值
+ 0.5 × (in_heading ? 1 : 0) 标题位置 ← 只作微调,打平手时才起作用
generalValue(r) = r 为 NULL → 0.4 ;否则 clamp(1 - r/20000, 0, 1) (r 越小越常用 → 分越高)
tie-break(确定性):score 降 → frequency_rank 升(NULL 视为最不常用,排后)→ word 升四个设计要点:
- 封顶 5(
FREQ_CAP/ENC_CAP):不封顶,长文里刷 30 次的词会把整张榜锁死;而「出现 5 次」与「出现 30 次」对"值得先看"其实是同一档信息。 - NULL 降级 0.4(略低于中位,不是中位):
frequency_rank预装库填充率 87%、B1/B2 档仅 68–77%,缺值系统性偏冷门词,给中位会高估它们。 - 「掌握状态」是过滤器不是维度:候选集由 Rust
get_discoverable_words生成时已排除learning_entries/known_words(外加vocab_scope的纯专名谓词),roadmap 原文的这一维在实现时已退化。⚠️ 2026-08-31 前这里还排除reference_words,那张黑名单已退役。 - tie-break 必须确定性:面板每次重开顺序要一致,否则用户觉得列表在自己乱跳;也让 vitest 能锁死顺序。
两个必须知道的前置(漏了会出 bug,非风格问题):
- Top-N 要先按启用 CEFR 档过滤再排序(
useContentTools.rankedDiscoveryWords)。content-script emit 全 6 档候选,但只有启用档的词才真在页面上包了.rb-discoverspan —— 未启用档的词进榜会得到一个点了没反应的 chip。 - meta 索引键要统一小写。content-script 传的是 lowercase,Rust 回的是
vocabulary.word原形(NOCASE PK,大小写可能不同);不归一会让所有 DB 信号静默丢失、退化成纯页内排序。
为什么打分在前端(同 §4.3 的理由):信号分裂在两侧 —— 页内频次 / 标题位置只有 content-script 知道(随 discovery-words-found 事件传来),frequency_rank / 跨页复现只有本地库知道(get_discovery_word_meta)。前端是唯一汇合点,顺带让打分能被 vitest 直测。
跨页复现的数据源 = migration v31 word_encounters(local-only 聚合表)。本仓此前零数据源:word_page_links 是 JOIN learning_entries(只记已存词的来源页)、reading_log 只存聚合、无 per-page 词索引。选聚合表而非 (word,url) 明细表:行数被候选池封顶(≤ ~12k),不随阅读量线性增长(明细表在重度用户身上是 ~1M 行/年)。代价 = last_page_url 单值去重闸分不清回访与新页,A→B→A 会重复计数 → UI 文案只说「读到过 N 次」,绝不说「N 篇文章」。
权重是凭产品直觉给的初值,没有数据支撑 —— 这正是同期埋 CTR 三事件(discovery_list_shown / _word_click / _words_saved,见 §4.1 之外的 lib/telemetry.ts)的目的,攒够遥测后回看。已知的调参陷阱:实测 Wikipedia Latin 页 Top-9 混进了 ad("AD 800")/ cite(引用模板)/ york(专名),这不是排序 bug 而是权重在如实工作,病根在候选池卫生 —— 修法不是往 reference_words 里加词(那张黑名单 2026-08-31 已退役,实测它 193 行里 17 行是误杀),而是 db/vocab_scope.rs 的运行时谓词 + L-C 的预装库补标;用调权重去压它们会连带压掉真正有用的常用词。
5. 调度与成本
| 任务 | cron(UTC) | 北京时间 | 触发 |
|---|---|---|---|
| analyze-articles | 0 22 * * * 日更 | 次日 06:00 | net.http_post → Edge |
| discover-sites | 0 23 * * 0 周日 | 周一 07:00 | net.http_post → Edge |
- 装在
supabase/sql/cron-setup.sql(pg_cron + pg_net,单一真相源),Dashboard 手动执行。 - 超时 280s;查执行:
cron.job_run_details/net._http_response。 - 成本(DeepSeek):analyze ~$0.03/日 + discover ~$0.01/周 ≈ 月 < $2。
6. LLM 配置
| 用途 | 模型来源 | 备注 |
|---|---|---|
| 发现/补位提名(reasoner) | DISCOVERY_LLM_MODEL → LLM_MODEL → provider 默认 | 全局摊薄批处理,可上高端(deepseek-reasoner)保质量 |
| 审核/健康裁决/文章评级(快) | LLM_MODEL → provider 默认 | 读文本非推理任务,用快模型 |
| provider | LLM_PROVIDER(默认 deepseek) | openai/deepseek/anthropic,6 个 LLM function 共用 key |
secret 矩阵 + 部署见
.claude/skills/edge-deploy。
7. 当前已知局限
| 局限 | 影响 | 现状 |
|---|---|---|
| ✅ Phase 2(2026-07-29)抓正文 readability,LLM 读摘录评级 + 客户端全文覆盖难度;抓取失败的篇仍回落 summary + band 先验 | ||
| readability 抓取失败率(部分站封 bot / 无 content:encoded) | 该篇无 word_count / 覆盖难度,卡面降级到 Phase 1 客观档 | 单篇静默降级、不 break 整批;bodies OK/20 计数观测健康度 |
| publisher 去重未实现 | 同一出版方可能多次入池 | 靠 LLM prompt + 审核兜底 |
| 跨源语义去重缺失 | 同一事件多篇可能都进推荐 | 未做 |
✅ Phase 2 recommended_blocklist 已解 | ||
| 分级源生态稀少 | A1/A2 文章供给天然薄 | 供给侧补强线持续 |
✅ Phase 1 llm_call_log/edge_run_log + Phase 2 recommend_audit_log 已解 |
8. 演进方向(agent 化设想)
完整总设计已定稿 →
docs/plans/archive/recommend-agent-evolution-plan.md(横跨 RB edge + readVocab-admin 后台,分 3 阶段多会话)。 本节为方向概览;优先级已校准为 质量 + 稳定("不贪大"淡化)。以下按 ROI 排序,非承诺。 进度(2026-06-16):Phase 1(共享 LLM/审计层)+ Phase 2(池模型改造)已落地——§8.1 可观测、§8.2 白/黑名单、稳定核心池均已实现(见上 §2 + plan §进度)。Phase 3(readVocab-admin 运营后台)待新会话。
8.1 可观测性 / 审计层(高 ROI,地基)—— ✅ 已实现
llm_call_log(每次 LLM 调用 token/cost/latency,Phase 1)+ edge_run_log(run 级,含 budget_skipped)+ recommend_audit_log(discover_admit/discover_reject/rate/eliminate + 理由 + 多维 score_detail + 来源,Phase 2)。同时是"agent 的记忆"。
8.2 资源池白/黑名单(高 ROI,稳定+安全)—— ✅ 已实现
- 白名单:
recommended_sites.is_pinned,health-check 跳过——保护人工锚点站。 - 黑名单:
recommended_blocklist(域名 + 理由 + source),discover 提名 + analyze 抓取前置查表跳过;审核拒(auto_reject)/确认死站(eliminated_dead) 自动写入。 - 最小存活期:
recommended_sites.admitted_at+recommend_config.min_survival_days,入池后 N 天内不被淘汰(anti-thrash)。
8.3 质量纵深(中 ROI)
publisher 级去重 + 跨源语义去重(同事件多篇折叠)+ 文章正文抓取再评级(治 §7 评级不准)。
8.4 Supabase 部署约束 —— 该不该"真 agent 化"?
你的直觉对:Supabase 适合现在这种「cron → 无状态 function → Postgres」的批处理形态,但对「自由调度的常驻 agent」是有约束的。
| 约束 | 说明 | 影响 |
|---|---|---|
| Edge function 有墙钟上限 | 单次调用 ~150-400s(cron 设 280s) | 多步骤、迭代式 agent 循环塞不进一次调用 |
| 调度只能 pg_cron 固定表达式 | 无事件驱动/动态自调度 | agent 不能"我觉得该现在复查某站"→ 工具无法自由调度 |
| 无常驻 worker / 无跨步记忆 | 每次都是全新无状态调用 | agent 的"计划/记忆"得外置到 Postgres |
| 无内建工具生态 / 成本观测 | 能 fetch + 调 LLM,但无 MCP、无 token 计量 | 要自己造 framework |
三条路:
- 留在 Supabase,做"伪 agent"(推荐):把状态/记忆/审计放进 Postgres(§8.1 的审计表 = agent 记忆),function 读历史做决策;再加一张 任务队列表让 function 能"入队后续工作"(伪动态调度)。便宜、稳、安全,契合"周期性策展小池"的本质。
- 编排外置:把调度/多步循环搬到常驻 agent runtime(小 worker / workflow 引擎 / 甚至桌面端定时触发),Supabase 退化为数据层 + LLM 代理。更强,但运维/成本/不稳定性上升。
- 混合:Supabase cron 当心跳 + Postgres 当状态 + 队列表当伪调度。介于两者。
建议:鉴于目标是「不贪大、稳、安全」,走路线 1——别追求完整自治 agent。当前 cron-batch 形态正是"小而精的周期策展"的最优解;完整 agent 自治是过度工程,反而引入不稳定与成本,与目标相悖。先补 §8.1 审计 + §8.2 白/黑名单,就已经从"脚本管线"进化成"可观测、有记忆、可治理"的半 agent 系统了。