Skip to content

推荐系统算法全景

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)发现新站 / 健康检查 / 淘汰失效 / 评 bandsupabase/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=falsequality_score(1-5) · cefr_band · is_active · last_evaluated_at · has_rss/rss_url
recommended_feedsRSS 源池随父站进/出(按 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 levelcefr_band站点/源的难度档(grounded 审核给);cefr_level单篇文章的难度(analyze 逐篇给)。 文章池的 A1/A2 供给由后者决定,与源 band 独立。


2. 子系统 A — 站点发现与维护(discover-sites)

2026-06-16 Phase 2 池模型反转:废弃旧 POOL_CAP=100 churn 模型(池满轮换淘汰垫底)。 新模型 = 稳定核心 + 罕见高门槛增补 + 只淘汰确认失效,加站与淘汰解耦。 治理状态入 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)audit cefr_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_wordscefr_band/cefr_level/word_count/body_text(Phase 2)全程透传入本地 recommended_articlesget_cached_recommendations(level, limit) 读本地镜像,按 CEFR 距离稳定兜底排序(前端再二次个性化排)。

4.2 用户水平估计(reading.rs compute_estimated_level

按用户已学词(notebook ∩ vocabulary)的 CEFR 累计分布,取首个达标档;够不到则停在 A1:

累计阈值(该档及以上已学词数)
C220
C150
B2100
B1200
A2100
A150(兜底)

输出 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 升

四个设计要点:

  • 封顶 5FREQ_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,非风格问题):

  1. Top-N 要先按启用 CEFR 档过滤再排序useContentTools.rankedDiscoveryWords)。content-script emit 全 6 档候选,但只有启用档的词才真在页面上包了 .rb-discover span —— 未启用档的词进榜会得到一个点了没反应的 chip。
  2. 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_linksJOIN 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-articles0 22 * * * 日更次日 06:00net.http_post → Edge
discover-sites0 23 * * 0 周日周一 07:00net.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_MODELLLM_MODEL → provider 默认全局摊薄批处理,可上高端(deepseek-reasoner)保质量
审核/健康裁决/文章评级(快)LLM_MODEL → provider 默认读文本非推理任务,用快模型
providerLLM_PROVIDER(默认 deepseek)openai/deepseek/anthropic,6 个 LLM function 共用 key

secret 矩阵 + 部署见 .claude/skills/edge-deploy


7. 当前已知局限

局限影响现状
analyze 只看 title+summary、不抓正文CEFR 评级先天不准✅ 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 文章供给天然薄供给侧补强线持续
无审计/成本/LLM 日志持久化出问题难回溯、成本靠估算✅ 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

三条路:

  1. 留在 Supabase,做"伪 agent"(推荐):把状态/记忆/审计放进 Postgres(§8.1 的审计表 = agent 记忆),function 读历史做决策;再加一张 任务队列表让 function 能"入队后续工作"(伪动态调度)。便宜、稳、安全,契合"周期性策展小池"的本质。
  2. 编排外置:把调度/多步循环搬到常驻 agent runtime(小 worker / workflow 引擎 / 甚至桌面端定时触发),Supabase 退化为数据层 + LLM 代理。更强,但运维/成本/不稳定性上升。
  3. 混合:Supabase cron 当心跳 + Postgres 当状态 + 队列表当伪调度。介于两者。

建议:鉴于目标是「不贪大、稳、安全」,走路线 1——别追求完整自治 agent。当前 cron-batch 形态正是"小而精的周期策展"的最优解;完整 agent 自治是过度工程,反而引入不稳定与成本,与目标相悖。先补 §8.1 审计 + §8.2 白/黑名单,就已经从"脚本管线"进化成"可观测、有记忆、可治理"的半 agent 系统了。