# 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 的 moves∪pad);禁止二次独立选榜 | ## Explicit Non-Goals | 项 | 状态 | |----|------| | 语义级 Skill「同一类」分类 | ❌ 本期不做 | | 同日 Skills Trending ↔ Hot 互斥 | ❌ 本期不做 | | 独立 editorial 微服务 | ❌ 不做 | | 改写 `localize` 为脚本机翻 | ❌ 保持 LLM + 缓存 | | 编辑指定首推豁免改推(`FEATURED_FORCE`) | ❌ 本期不做 | | 「开场关键短语」硬匹配算法 | ❌ 本期不做(仅 prompt 软约束;不进硬成功标准) | ## Recommended Approach: 管线选择器(路径 A) 在现有 `daily generate` 内增加选条/裁决层,不新起进程: ``` 采集 raw 榜 + 新闻 → build movement_baseline(raw Top compare_depth —— 仅供次日「新入榜」,禁止写展示历史) → board_select(读 wecom_shown_keys 周历史;周去重 + 深池;输出当日最终展示列表) · full:直接取 board_select 结果前 N · delta:在 board_select 候选池内做 moves∪pad(pad 也只从该池/同规则深池取,不再另起一套历史) → featured_resolve(与昨日首推相同则改推;池 A = 本 run 最终展示 keys) → research why(先定人,再写 why_today) → news_select(禁放宽凑数;深检索补满;剥「放宽」前缀) → editorial(代码选 narrative_axis;prompt 附近 7 天 theme 软禁) → 渲染 wecom → 写回 wecom_shown_keys = 最终进入企微正文的榜条目 keys(post-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 路径关 backfill;research SKILL 改文案规则;后处理剥「放宽」 | | `narrative_axis` | 代码先选轴再注入 Step 1;LLM 不得另选冲突轴 | | `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. 现有整理(Skills:source 合并;GitHub:repo key) 2. 滤掉近 7 天该榜 wecom_shown_keys 3. 按原排名取前 N 4. 不足 → 继续扫深池,仍排除周历史,直到满 N 或池空 5. 池空仍不足 → 短榜;日志 board_short:{board}:{n};不回填周内已展示条目 ``` 分榜独立历史:Trending 出过的 skill,Hot 仍可出。 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)` 从剩余枚举选取;注入 prompt;LLM 输出须等于该轴;冲突则重试 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. delta:pad 不引入近 7 日 `wecom_shown_keys` 内 key 4. `featured_resolve`:先定人再 why;A 优先 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、新闻禁放宽 与访谈一致