Files
daily-robots/docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md
yumao 6ea2a4e4c6 feat: 早报系统重构与功能增强
- 新增常驻调度器 daily/scheduler.py + run-scheduler.ps1(定时生成/推送)
- 新增 daily/bridge_manager.py:Windows 兼容的 Cursor SDK 桥接
- 新增 skills/daily-featured-pick 首推 Skill 与叙事轴/去重逻辑
- 新闻抓取窗口、GitHub 搜索、企微 delta 模式等多项改进
- 补充设计文档与 superpowers 计划/规范
- 新增对应测试(scheduler、featured_pick、github_search、news_fetch_window 等)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:12:00 +08:00

272 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Design: 企微早报多样性与去重
Generated: 2026-07-14
Repo: daily-robots
Status: APPROVED
Mode: Builder
Related: `docs/design-wecom-delta-mode.md`Delta 列表模式)
Revision: office-hours A —— 拆分展示历史、单一列表主人、历史真相表2026-07-14
## Problem Statement
近两日企微早报(如 2026-07-13 / 07-14骨架相同
- **今日首推**连续两天同为 `headroom`
- **开场主题**同属「上下文压缩 + 视频/Skills」腔调
- **AI 时讯**出现「放宽窗口」旧闻凑数标注
- **Skills / GitHub 各榜**(尤其新兴榜)周内大量重复展示
读者需要「今天新信息」,而不是换日期的复印机。
## Decisions已确认
| 决策点 | 选择 |
|--------|------|
| 实现路径 | **A管线选择器**代码硬保证去重LLM 只写开场/理由/摘要;中文化仍走现有 `localize` |
| Skills「同一类」 | 暂不管;沿用现有 `group_skills_by_source`**已知残留**:同 source 换 skill id 仍可能周内再出现) |
| 周去重后不足 Top N | **深池补满**,仍保证周内未出现;池空则短榜,不破周约束 |
| 首推改推候选 | **先展示榜(池 A再 raw 深池(池 B**;一月内首推不重复 |
| 开场主题 | **近 7 天禁主题/句式(软)+ 叙事轴与近 3 天不同(硬,代码选轴)** |
| 取消「放宽窗口」 | **禁止旧闻/已推凑数**;不够则深度检索补新闻;禁止任何「放宽」文案标注;仍不够则短列表 |
| 展示历史 vs 异动基准 | **必须拆开**`movement_baseline``wecom_shown_keys` |
| full/delta 列表主人 | **唯一主人** = `board_select`(含 full 与 delta 的 movespad禁止二次独立选榜 |
## Explicit Non-Goals
| 项 | 状态 |
|----|------|
| 语义级 Skill「同一类」分类 | ❌ 本期不做 |
| 同日 Skills Trending ↔ Hot 互斥 | ❌ 本期不做 |
| 独立 editorial 微服务 | ❌ 不做 |
| 改写 `localize` 为脚本机翻 | ❌ 保持 LLM + 缓存 |
| 编辑指定首推豁免改推(`FEATURED_FORCE` | ❌ 本期不做 |
| 「开场关键短语」硬匹配算法 | ❌ 本期不做(仅 prompt 软约束;不进硬成功标准) |
## Recommended Approach: 管线选择器(路径 A
在现有 `daily generate` 内增加选条/裁决层,不新起进程:
```
采集 raw 榜 + 新闻
→ build movement_baselineraw Top compare_depth —— 仅供次日「新入榜」,禁止写展示历史)
→ board_select读 wecom_shown_keys 周历史;周去重 + 深池;输出当日最终展示列表)
· full直接取 board_select 结果前 N
· delta在 board_select 候选池内做 movespadpad 也只从该池/同规则深池取,不再另起一套历史)
→ featured_resolve与昨日首推相同则改推池 A = 本 run 最终展示 keys
→ research why先定人再写 why_today
→ news_select禁放宽凑数深检索补满剥「放宽」前缀
→ editorial代码选 narrative_axisprompt 附近 7 天 theme 软禁)
→ 渲染 wecom
→ 写回 wecom_shown_keys = 最终进入企微正文的榜条目 keyspost-render
→ 其余 history首推月、axis写入 data.json 约定字段
```
**LLM 负责**`opening` / `theme_line`、首推 `why_today`、新闻与榜单项中文摘要(`localize`)。
**代码负责**:谁上榜、首推换谁、周/月去重、`narrative_axis` 选取、是否允许旧闻。
### Approaches Considered
| | A 管线选择器(采用) | B 偏 LLM 约束 | C 独立 editorial 服务 |
|--|--|--|--|
| 优点 | 可测;与 pushed-links 模式一致 | 改 prompt 快 | 边界清晰 |
| 缺点 | 需动 generate / featured / news / format | 易漏、难测 | 过重 |
---
## Section 1 — 总览、列表主人、历史真相表
### 1.1 两种「基准」禁止混用
| 字段 | 含义 | 写入时机 | 读者 |
|------|------|----------|------|
| `movement_baseline` | **Raw** 各榜 Top `compare_depth`(现网语义不变) | `build_llm_input` / 采集后尽早 | `build_movement_context`(新入榜) |
| `wecom_shown_keys` | **读者实际见到**的各榜 key 集合(及可选 rank | **wecom 渲染完成之后** | `board_select` 周去重delta pad测试 |
**禁止**:把 `wecom_shown_keys` 写入或覆写 `movement_baseline`
**禁止**:让 `load_recent_board_keys` 继续读 `movement_baseline` 充当「已展示」——应改为读近 N 日 `wecom_shown_keys`(可保留函数名,换数据源;或新建 `load_recent_shown_keys`)。
### 1.2 唯一列表主人
`board_select`(模块可挂在 `daily/board_select.py` 或扩 `delta.py`)是各榜**最终展示行**的唯一生产者:
| 模式 | 行为 |
|------|------|
| `full` | `board_select(raw, shown_history) →` 至多 N 条,直接渲染 |
| `delta` | 先算相对 `movement_baseline` 的 moves展示 = `moves`(已在候选内)∪ `pad`**pad 候选必须来自同一周去重池**(与 full 同一套 `board_select` 规则),不得再读 raw baseline 当「已展示」 |
交互影响(非「完全正交」):周去重会减少可展示重复项 → delta 日可能更短、silent/gate 行为可能变化。`DAILY_WECOM_MODE` 枚举语义不变,但列表密度会变。
### 1.3 历史真相表(单一来源)
全部落在 `output/{date}.data.json`(新闻 pushed-links 例外,沿用现网 cache
| 字段路径 | 窗口 | Key 规则 | 写者 | 读者 |
|----------|------|----------|------|------|
| `data.movement_baseline` | 次日对比用 | raw 条目切片 | `build_movement_baseline` | movement |
| `data.wecom_shown_keys.{board}` | 滚动 7 天(读近 7 日文件) | Skills与现网 `_skill_keys_in_board_item` / `skill_id` 一致GitHub`owner/repo` | post-render persist | `board_select` / pad |
| `data.featured_pick_key` | 滚动 30 天 | skill id 或 `owner/repo` | `featured_resolve` 成功后 | 月去重 |
| `data.narrative_axis` | 滚动 3 天 | 枚举字符串 | 代码 `pick_narrative_axis` | Step 1 约束 / 校验 |
| `data.theme_line` / trends opening | 近 7 日供 prompt | 原文 | editorial 落盘 | Step 1 软禁(不硬匹配) |
| `CACHE_DIR/pushed-news-links.json` | `DAILY_NEWS_DEDUP_DAYS` | 规范化 URL | 推送成功后 | news filter |
不另建平行 CACHE「board-history.json」避免双源漂移。冷启动缺文件 = 空集合。
### 1.4 数据流挂点
| 逻辑 | 挂点 |
|------|------|
| `movement_baseline` | 现网raw 榜入库时(不变) |
| `board_select` | 渲染前;输出写入供 Agent/`llm_input` 与 wecom 共用的最终列表字段 |
| delta pad | **调用同一周去重历史**`wecom_shown_keys`),不再独立解释 `movement_baseline` 为展示史 |
| `featured_resolve` | **先于** why 检索;池 A = 本 run `board_select`delta 则为本 run 最终展示列表) |
| 新闻 | 所有 prepare 路径关 backfillresearch SKILL 改文案规则;后处理剥「放宽」 |
| `narrative_axis` | 代码先选轴再注入 Step 1LLM 不得另选冲突轴 |
| `wecom_shown_keys` 写回 | `replace_wecom_*` / `build_wecom_report` 之后,与最终正文列表一致 |
---
## Section 2 — 各榜选条 + 今日首推改推
### 2.1 `board_select`(五榜共用)
适用:`skills_trending` / `skills_hot` / `github_trending` / `github_emerging` / `github_topic`
```
输入:当日 raw 池pool ≥ DAILY_BOARD_POOL_SIZE
历史:近 DAILY_BOARD_DEDUP_DAYS 的 wecom_shown_keys[board]
输出:至多 N 条N = 现有 wecom Top 配置)
1. 现有整理Skillssource 合并GitHubrepo key
2. 滤掉近 7 天该榜 wecom_shown_keys
3. 按原排名取前 N
4. 不足 → 继续扫深池,仍排除周历史,直到满 N 或池空
5. 池空仍不足 → 短榜;日志 board_short:{board}:{n};不回填周内已展示条目
```
分榜独立历史Trending 出过的 skillHot 仍可出。
Post-render将**实际写入企微的** keys 写入当日 `wecom_shown_keys`测试断言history ⊆ / == 渲染列表,**≠** `movement_baseline`)。
### 2.2 `featured_resolve`
**触发**:本 run 拟用首推身份与**前一天** `featured_pick_key`(或等价 data 字段)相同。
身份函数skill → `skill_id`github → `owner/repo`
`DAILY_FEATURED_PICK` 与自动首推;本期不豁免。无昨日文件 → 不改推。
**顺序(硬)**:定候选 → 再 `research`/`why_today`(禁止先写旧条目 why 再改人却不重写)。
**候选**
1. **池 A**:本 run **最终会展示**的 Skills + GitHub 榜条目(与 `wecom_shown_keys` 同源结构)
2. **池 B**raw 深池中尚未进入本 run 展示者
**过滤**:近 30 天 `featured_pick_key`;排除冲突项自身。
**抽取**`hash(date_str + "featured")` 可复现;测试可注入 RNG。先 A 后 B仍空 → 保留原首推 + `featured_fallback_exhausted`
**落盘**`data.featured_pick_key`
---
## Section 3 — 开场主题 + AI 时讯
### 3.1 开场主题
| 机制 | 强度 | 规则 |
|------|------|------|
| `narrative_axis` | **硬** | 代码 `pick_narrative_axis(used_last_N)` 从剩余枚举选取;注入 promptLLM 输出须等于该轴;冲突则重试 1 次,再失败则**强制覆写为代码所选轴**再落盘(保证成功标准可测) |
| theme/opening 软禁 | **软** | prompt 附近 7 天 `theme_line`/opening 摘要;禁止复述;**无** n-gram 硬匹配;**不**列入硬成功标准 |
**叙事轴枚举**
`政策监管` · `模型发布` · `工具链/Agent` · `芯片算力` · `开源生态` · `应用落地` · `安全/诉讼`
`opening` 首句证据须来自当日数据;首推改推后须跟新首推或当日主轴新闻。
### 3.2 AI 时讯:取消「放宽窗口」
目标条数 = 现网配置之和(如 `DAILY_WECOM_AI_NEWS` + tech/CN 等文档不写死「15」。
1. **所有 prepare 路径**关闭「不够塞回已推/旧条」(`DAILY_NEWS_BACKFILL=0` 默认);`pushed-news-links` 过滤保留。
2. 不够 → 深度检索补新闻https link、未 pushed、可核实**补入年龄上限** = `DAILY_AI_NEWS_HOURS`(与主窗一致),禁止借 research 变相放宽到任意旧闻。
3. 改 research SKILL删除「放宽至 48h 并注明」;后处理剥 `放宽窗口`/`放宽至` 前缀或丢弃。
4. 仍不足 → 短列表 + `news_short:{n}`
中文化:`daily/localize.py`(不变)。
---
## Section 4 — 配置、错误处理、测试
### 4.1 环境变量
| 变量 | 默认 | 含义 |
|------|------|------|
| `DAILY_BOARD_DEDUP_DAYS` | `7` | 读 `wecom_shown_keys` 的滚动天数 |
| `DAILY_BOARD_POOL_SIZE` | ≥50 / 与现有 skill pool 对齐 | 深池扫描深度 |
| `DAILY_FEATURED_DEDUP_DAYS` | `30` | 今日首推月去重 |
| `DAILY_THEME_BAN_DAYS` | `7` | 软禁:注入 prompt 的 theme 天数 |
| `DAILY_NARRATIVE_AXIS_DAYS` | `3` | 叙事轴互斥窗 |
| `DAILY_NEWS_BACKFILL` | `0` | `0`=禁止旧闻凑数 |
| `DAILY_NEWS_DEDUP_DAYS` | 已有 `7` | pushed-links |
`DAILY_DELTA_PAD_LOOKBACK_DAYS` 应与 `DAILY_BOARD_DEDUP_DAYS` 对齐,且 **pad 与 board_select 共用 `wecom_shown_keys`**(窗口对齐不够,数据源必须同一)。
### 4.2 错误与降级
| 情况 | 行为 |
|------|------|
| 无 `wecom_shown_keys` 历史 | 空集合,正常满榜 |
| 周去重后深池不足 | 短榜 + `board_short` |
| 首推冲突且 A/B 空 | 保留原首推 + `featured_fallback_exhausted` |
| LLM 轴与代码轴冲突 | 覆写为代码轴 |
| 深检索仍不足时讯 | 短列表;禁止 backfill |
| history 读写失败 | 当次按空历史 + error 日志 |
### 4.3 测试pytest
1. `board_select`:假 `wecom_shown_keys` + 深池 → 无周交集;深池补满;不足短榜
2. **回归钉死**:写回后 `wecom_shown_keys` ≠ 用 `movement_baseline` 推导的集合(构造 raw Top 与展示 Top 故意不同)
3. deltapad 不引入近 7 日 `wecom_shown_keys` 内 key
4. `featured_resolve`:先定人再 whyA 优先 B月未见可注入 RNG
5. news`BACKFILL=0`;剥「放宽*」research 补入不超 hours 窗
6. `pick_narrative_axis`:近 3 天互斥;落盘轴 == 代码轴
7. 既有 delta / pushed_links / wecom 回归不挂
### 4.4 成功标准(硬)
- 连续两天:**首推 key 不同**(除非 `featured_fallback_exhausted`
- 同一榜近 7 日 `wecom_shown_keys`**无重复 key**(池足够时)
- 时讯:无「放宽*」标注;无 backfill 已推 link
- 近 3 天 `narrative_axis`**两两不同**(代码保证)
软标准不闸门opening 读感不像连续复印。
---
## Implementation Sketch非计划明细
1. `data.json` 增加 `wecom_shown_keys`;改 `load_recent_*` 数据源
2. `board_select` + 让 delta pad 共用
3. post-render persist shown keys
4. `featured_resolve` 时序修正
5. news backfill off + SKILL + 剥前缀
6. `pick_narrative_axis` + prompt 注入
7. 测试如上
正式任务拆解 → `writing-plans`
## Office-hours Review Notes
- 对抗审阅质量约 4/10 → 本修订处理三大硬伤(存储拆分、列表主人、真相表)。
- 未纳入本期(原选项 B首推质量加权、关键短语硬匹配。
- 已知残留source 级「同类」周内可再现。
## Spec Self-Review
- [x] `movement_baseline``wecom_shown_keys` 职责分离写死
- [x] 单一列表主人 + full/delta 交互说明
- [x] 历史真相表无「与/或」双源
- [x] 轴硬 / 短语软;成功标准不含无法验证的短语匹配
- [x] 周不足=深池、首推=A→B、新闻禁放宽 与访谈一致