Files
dsp 8693762281 feat: 推荐引擎(热度排序 + 关注流 + 冷启动曝光)
## 背景

hot_score 在此之前是一个死字段:全库唯一赋值点是上传时的 `v.hot_score = 0.0`,
没有任何代码给它算过值。`order_by("-hot_score", "-published_at", "-id")` 实际
退化为按发布时间排序 —— 平台没有推荐,feed 就是纯时间倒序:老而优质的内容永久
沉底,新上传但无人互动的霸屏。同时 VideoPlayStat(完播数据)只用来给创作者看板
出报表,最强的质量信号完全没参与排序。

## 评分公式

  engagement = 3×赞 + 4×评论 + 5×收藏 + 6×分享 + 0.2×播放
  quality    = 1 + 完播率                     (∈[1,2])
  score      = (engagement × quality + 8) / (发布小时数 + 2) ^ 1.2

- 互动权重按行为成本排序(分享>收藏>评论>点赞)
- view_count 只给 0.2:它衡量曝光而非质量,且曝光会被排序放大(得分高→曝光多
  →得分更高)。实测权重 1.0 时一条 9000 播放的视频有 64% 分数来自播放量本身,
  足以让"高赞低完播"的标题党和"高完播"的优质内容打平;调到 0.2 后分差从
  3.4% 拉大到 29%
- 完播率作乘数而非加数:区分"被划过去"和"被看完"
- +8 冷启动曝光预算:没有它新视频互动为 0 → 排最后 → 没人看 → 永远没互动,
  创作者发第一条就石沉大海。8 分约"几条赞"量级,且同样随时间衰减

## 复合游标(含一个真实数据丢失 bug 的修复)

推荐流按 (hot_score DESC, id DESC) 排序,游标必须含两个键。但游标里存的是
上一页最后一行的**分值快照**,而 hot_score 会随时间衰减、也会被后台重算改写:

  - 分值变小 → score < c_score 恒真 → 该行被重复返回
  - 分值变大 → 两个分支都不成立 → 该行及后续被永久跳过

这是真实可复现的丢数据,不是理论问题(翻页中途恰好触发一次重算就能命中)。
解法 resolve_boundary():用 id 查出锚点行的当前分值作为分界;锚点被删除时
退化为按 id 续页。

## 三种流

GET /videos/feed?mode=recommend|following|latest
- recommend 默认,热度排序,复合游标
- following 只看已关注作者,需登录(否则 401),未关注任何人时返回空**不回落全站**
- latest 纯时间倒序
非法 mode 回落到 recommend,响应体回显实际生效值

## 重算调度

feed 首次翻页时节流触发(TTL 300s,单 worker 线程池,不阻塞响应、失败不影响
返回)+ 管理命令 `manage.py recompute_hot_scores`(--dry-run/--top/--max-age-days)
+ 部署后初始化。翻页时不触发,避免游标锚点分值抖动。

## 客户端

- Web: feed 顶部悬浮 Tab(推荐/关注/最新),未登录隐藏关注 Tab;侧边栏「推荐」
  改为反映当前流;空态按流给不同提示
- Android: 对齐的三 Tab(含空态下的 Tab,避免关注流为空时用户被困)

## 验证

- 后端 49 passed,其中推荐专项 19 条锁"排序质量"而非"接口 200":
  权重方向、时间衰减、完播加成、冷启动预算、游标精度往返、分值漂移不丢行、
  锚点删除、同分场景、关注流隔离、非法 mode 回落
- 真实数据端到端(distinct 数据:爆款/标题党/零互动新号/沉底老内容):
  推荐首位=爆款;零互动新号进第 4 位(有曝光);标题党被高完播内容压住
- 浏览器 E2E(web/e2e/feed-tabs.mjs):三 Tab 渲染、推荐/最新首位确实不同、
  关注流只含已关注作者、切回恢复
- Android/Web 构建 + typecheck 全绿
- 文档 docs/ranking.md 说明公式依据、漂移问题与调参入口
2026-09-11 17:40:16 +08:00

62 lines
1.8 KiB
YAML

name: CI
on:
push:
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: dsp/backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install deps
run: |
pip install "Django>=5.2,<5.3" "djangorestframework>=3.15,<3.16" "adrf>=0.1.9" \
"psycopg[binary]>=3.2,<4.0" "redis>=5.0,<6.0" "channels>=4.1,<5.0" "channels-redis>=4.2" \
"daphne>=4.1" "django-cors-headers>=4.4" "django-redis>=5.4" "Pillow>=10.4" \
"python-decouple>=3.8" "argon2-cffi>=23.1" "PyJWT>=2.9" \
"pytest" "pytest-asyncio" "pytest-django" "httpx"
- name: Tests (DSP_TEST=1, zero external deps)
run: DSP_TEST=1 python -m pytest tests/ -q
- name: 推荐算法自检(热度排序 + 复合游标边界)
run: DSP_TEST=1 python -m pytest tests/test_ranking.py -q
web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: dsp/web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx vue-tsc -b
- name: Unit tests (401 单飞刷新 / 契约回归)
run: npm test
- run: npm run build
android:
runs-on: ubuntu-latest
defaults:
run:
working-directory: dsp/android
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: android-actions/setup-android@v3
- name: Install SDK platform (compileSdk=34)
run: sdkmanager "platforms;android-34" "build-tools;34.0.0"
- name: Unit tests (JSON 契约回归,无需模拟器)
run: ./gradlew testDebugUnitTest --no-daemon