Files
daily-robots/docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.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

605 lines
24 KiB
Markdown
Raw Permalink 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.

# 企微早报多样性与去重 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现企微早报硬去重多样性:五榜周去重(深池补满)、首推与昨日相同则改推(月去重)、叙事轴代码互斥、取消新闻「放宽窗口」凑数;且 `movement_baseline``wecom_shown_keys` 严格分离。
**Architecture:**`daily generate` 管线加代码选择器:`board_select` 为 full/delta 唯一列表主人;周历史只读写 `data.wecom_shown_keys`post-render`movement_baseline` 仍为 raw Top compare`featured_resolve` 先定人再 research`pick_narrative_axis` 代码选轴注入 Agent Step1新闻关 backfill + 剥「放宽」前缀。
**Tech Stack:** Python 3.13+、现有 `unittest`/`pytest``daily/delta.py` / `format_wecom.py` / `featured_pick.py` / `news/fetch.py``output/*.data.json`
**Spec:** `docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md`Status: APPROVED
## Global Constraints
- `movement_baseline` **禁止**被展示历史覆写;周去重只读 `wecom_shown_keys`
- full/delta **唯一列表主人** = `board_select`delta 的 pad 共用同一套 shown 历史)
- `DAILY_BOARD_DEDUP_DAYS` 默认 `7`;与 pad lookback 对齐且数据源同一
- `DAILY_FEATURED_DEDUP_DAYS` 默认 `30`
- `DAILY_NARRATIVE_AXIS_DAYS` 默认 `3`;轴枚举固定 7 个(见 Task 5
- `DAILY_NEWS_BACKFILL` 默认 `0`(禁止旧闻凑数)
- research 补新闻年龄上限 = `DAILY_AI_NEWS_HOURS`(不得变相 48h 放宽)
- 不做:语义「同一类」、同日 Trending↔Hot 互斥、`FEATURED_FORCE`、关键短语硬匹配
- Commit 信息正文用中文(若本任务含 Commit 步)
---
## File Structure
| 文件 | 职责 |
|------|------|
| `daily/config.py` | 新 env`board_dedup_days``board_pool_size``featured_dedup_days``theme_ban_days``narrative_axis_days``news_backfill_enabled` |
| `daily/board_history.py` | **新建**`load_recent_shown_keys` / `extract_shown_keys` / `attach_wecom_shown_keys`(读写 `data.wecom_shown_keys` |
| `daily/board_select.py` | **新建**`board_select(...)` 周过滤+深池 |
| `daily/delta.py` | `load_recent_board_keys` 改为委托 `load_recent_shown_keys`(保留函数名兼容);**不**改 `build_movement_baseline` |
| `daily/format_wecom.py` | pad 使用 shown keys可选返回最终展示 items 供写回 |
| `daily/featured_pick.py` | `featured_identity_key``featured_resolve`、先定人再 research |
| `daily/narrative_axis.py` | **新建**`NARRATIVE_AXES``pick_narrative_axis``load_recent_axes` |
| `daily/news/fetch.py` | `_apply_pushed_dedup_with_backfill` 尊重 `news_backfill_enabled()`;默认不塞回 |
| `daily/news/research.py` + `skills/daily-ai-news-research/SKILL.md` | 禁放宽文案;补入不超时窗 |
| `daily/text_utils.py``daily/news/sanitize.py` | `strip_news_relax_prefix(desc)` |
| `daily/agent_workflow.py` | Step1 注入 axis + 近 7 日 theme 软禁;强制覆写冲突轴 |
| `daily/generate.py` | 串联select → featured → editorial → render → persist shown/key/axis |
| `daily/report_data.py` | data.json 可携 `wecom_shown_keys` / `featured_pick_key` / `narrative_axis`(写回可由 generate 合并) |
| `.env.example` | 文档化新变量 |
| `skills/daily-agent/SKILL.md` | `narrative_axis` 必填且等于输入指定轴 |
| `tests/test_board_select.py` | **新建** |
| `tests/test_board_history.py` | **新建** |
| `tests/test_featured_resolve.py` | **新建** |
| `tests/test_narrative_axis.py` | **新建** |
| `tests/test_news_relax.py` | **新建** |
| `tests/test_wecom_delta.py` | 回归pad 不读 movement 当展示史 |
---
### Task 1: Config + shown-keys 历史层
**Files:**
- Modify: `daily/config.py`(文件末尾追加)
- Create: `daily/board_history.py`
- Modify: `daily/delta.py``load_recent_board_keys` 改委托)
- Test: `tests/test_board_history.py`
- Modify: `.env.example`
**Interfaces:**
- Consumes: `OUTPUT_DIR`、现有 `delta.skill_id` / repo key 约定
- Produces:
- `board_dedup_days() -> int`(默认 7
- `board_pool_size() -> int`(默认 `max(50, env WECOM_SKILL_POOL)`
- `featured_dedup_days() -> int`(默认 30
- `theme_ban_days() -> int`(默认 7
- `narrative_axis_days() -> int`(默认 3
- `news_backfill_enabled() -> bool`(默认 Falseenv `DAILY_NEWS_BACKFILL`
- `extract_shown_keys(board: str, items: list[dict]) -> list[str]`
- `load_recent_shown_keys(date_str: str, *, lookback_days: int | None = None) -> dict[str, set[str]]`
- `merge_wecom_shown_into_data(data: dict, shown: dict[str, list[str]]) -> dict`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_board_history.py
from __future__ import annotations
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from daily.board_history import extract_shown_keys, load_recent_shown_keys, merge_wecom_shown_into_data
from daily.config import board_dedup_days, news_backfill_enabled
class ConfigDiversityTests(unittest.TestCase):
def test_board_dedup_days_default(self):
with patch.dict(os.environ, {}, clear=True):
self.assertEqual(board_dedup_days(), 7)
def test_news_backfill_default_off(self):
with patch.dict(os.environ, {}, clear=True):
self.assertFalse(news_backfill_enabled())
class ShownKeysTests(unittest.TestCase):
def test_extract_github_repo_keys(self):
items = [{"repo": "a/b"}, {"repo": "c/d"}]
self.assertEqual(extract_shown_keys("github_trending", items), ["a/b", "c/d"])
def test_load_recent_reads_wecom_shown_not_baseline(self):
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp)
# 前日shown 只有 x/ybaseline raw 含 a/b —— 周去重只能看到 x/y
payload = {
"data": {
"date": "2026-07-13",
"movement_baseline": {
"github_trending": [{"repo": "a/b"}, {"repo": "x/y"}],
},
"wecom_shown_keys": {"github_trending": ["x/y"]},
}
}
(out / "2026-07-13.data.json").write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8"
)
with patch("daily.board_history.OUTPUT_DIR", out):
keys = load_recent_shown_keys("2026-07-14", lookback_days=7)
self.assertEqual(keys["github_trending"], {"x/y"})
self.assertNotIn("a/b", keys["github_trending"])
def test_merge_shown_does_not_touch_baseline(self):
data = {
"movement_baseline": {"github_trending": [{"repo": "raw/one"}]},
}
merged = merge_wecom_shown_into_data(
data, {"github_trending": ["shown/one"]}
)
self.assertEqual(
merged["movement_baseline"]["github_trending"][0]["repo"], "raw/one"
)
self.assertEqual(merged["wecom_shown_keys"]["github_trending"], ["shown/one"])
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_board_history.py -v`
Expected: FAIL模块/函数不存在)
- [ ] **Step 3: Implement config + board_history + delta 委托**
`daily/config.py` 追加:
```python
def board_dedup_days() -> int:
return max(1, env_int("DAILY_BOARD_DEDUP_DAYS", 7))
def board_pool_size() -> int:
fallback = env_int("DAILY_WECOM_SKILL_POOL", 50)
return max(1, env_int("DAILY_BOARD_POOL_SIZE", max(50, fallback)))
def featured_dedup_days() -> int:
return max(1, env_int("DAILY_FEATURED_DEDUP_DAYS", 30))
def theme_ban_days() -> int:
return max(1, env_int("DAILY_THEME_BAN_DAYS", 7))
def narrative_axis_days() -> int:
return max(1, env_int("DAILY_NARRATIVE_AXIS_DAYS", 3))
def news_backfill_enabled() -> bool:
return env_bool("DAILY_NEWS_BACKFILL", False)
```
新建 `daily/board_history.py`:实现 `BOARD_KEYS``delta.RECENT_BOARD_KEYS` 同五榜Skills 用 `delta.skill_id`GitHub 用 `repo``load_recent_shown_keys` **只**读各日 `data.wecom_shown_keys`,缺省空集,读写失败打 log 后当空集。
修改 `daily/delta.py``load_recent_board_keys`:改为
```python
def load_recent_board_keys(date_str: str, *, lookback_days: int | None = None) -> dict[str, set[str]]:
from daily.board_history import load_recent_shown_keys
from daily.config import board_dedup_days
days = lookback_days if lookback_days is not None else board_dedup_days()
return load_recent_shown_keys(date_str, lookback_days=days)
```
删除(或不再走)原「从 movement_baseline 抽 keys」逻辑避免 pad 继续把 raw Top 当展示史。
`.env.example` 追加注释块:
```env
# 多样性 / 去重(见 docs/superpowers/specs/2026-07-14-wecom-diversity-dedup-design.md
# DAILY_BOARD_DEDUP_DAYS=7
# DAILY_BOARD_POOL_SIZE=50
# DAILY_FEATURED_DEDUP_DAYS=30
# DAILY_THEME_BAN_DAYS=7
# DAILY_NARRATIVE_AXIS_DAYS=3
DAILY_NEWS_BACKFILL=0
```
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_board_history.py tests/test_wecom_delta.py -v`
Expected: `test_board_history` PASS既有 delta 测试若依赖「baseline 即 recent」行为按 Task 1 语义改断言为 shown_keys本 Task 内修回归,勿留红)。
- [ ] **Step 5: Commit**
```bash
git add daily/config.py daily/board_history.py daily/delta.py .env.example tests/test_board_history.py tests/test_wecom_delta.py
git commit -m "feat: 拆分 wecom_shown_keys 与 movement_baseline 历史层"
```
---
### Task 2: `board_select` 周去重 + 深池
**Files:**
- Create: `daily/board_select.py`
- Test: `tests/test_board_select.py`
**Interfaces:**
- Consumes: `extract_shown_keys` / `skill_id``group_skills_by_source`Skills 板)
- Produces:
- `board_select(*, board: str, items: list[dict], recent_keys: set[str], limit: int, pool_size: int, kind: Literal["skill","github"]) -> list[dict]`
- 日志短榜:`board_short:{board}:{n}``logging.getLogger(__name__).info`
- [ ] **Step 1: Write the failing test**
```python
# tests/test_board_select.py
from __future__ import annotations
import unittest
from daily.board_select import board_select
def _gh(repo: str) -> dict:
return {"repo": repo, "description": repo}
class BoardSelectTests(unittest.TestCase):
def test_filters_recent_and_keeps_order(self):
pool = [_gh(f"o/r{i}") for i in range(20)]
recent = {"o/r0", "o/r1", "o/r2"}
out = board_select(
board="github_trending",
items=pool,
recent_keys=recent,
limit=5,
pool_size=20,
kind="github",
)
keys = [x["repo"] for x in out]
self.assertEqual(keys, ["o/r3", "o/r4", "o/r5", "o/r6", "o/r7"])
def test_deep_pool_fills_after_filter(self):
pool = [_gh(f"o/r{i}") for i in range(8)]
recent = {f"o/r{i}" for i in range(6)} # 前 6 全封
out = board_select(
board="github_emerging",
items=pool,
recent_keys=recent,
limit=5,
pool_size=8,
kind="github",
)
self.assertEqual([x["repo"] for x in out], ["o/r6", "o/r7"]) # 短榜
def test_skill_uses_skill_id(self):
items = [
{"id": "a/b/s1", "source": "a/b", "title": "s1", "installs": 10},
{"id": "c/d/s2", "source": "c/d", "title": "s2", "installs": 9},
]
out = board_select(
board="skills_trending",
items=items,
recent_keys={"a/b/s1"},
limit=10,
pool_size=50,
kind="skill",
)
self.assertEqual([x["id"] for x in out], ["c/d/s2"])
```
- [ ] **Step 2: Run test to verify it fails**
Run: `pytest tests/test_board_select.py -v`
Expected: FAIL
- [ ] **Step 3: Implement `board_select`**
```python
# daily/board_select.py — 核心逻辑示意
def board_select(*, board, items, recent_keys, limit, pool_size, kind):
if kind == "skill":
from daily.skills_group import group_skills_by_source
pool = group_skills_by_source(items, limit=pool_size, pool_size=pool_size)
def key_fn(x): return skill_id(x)
else:
pool = items[: max(pool_size, limit)]
def key_fn(x): return str(x.get("repo") or "")
out = []
for item in pool:
k = key_fn(item)
if not k or k in recent_keys:
continue
out.append(item)
if len(out) >= limit:
break
if len(out) < limit:
logger.info("board_short:%s:%s", board, len(out))
return out
```
Skills输入可为未 group 的 raw函数内 group。GitHub输入为 repo 列表。
- [ ] **Step 4: Run tests — expect PASS**
Run: `pytest tests/test_board_select.py -v`
- [ ] **Step 5: Commit**
```bash
git add daily/board_select.py tests/test_board_select.py
git commit -m "feat: 实现 board_select 周去重与深池补满"
```
---
### Task 3: 接入 generate / format_wecom唯一列表主人 + post-render 写回)
**Files:**
- Modify: `daily/generate.py`(选榜、传入 pad、渲染后 merge shown
- Modify: `daily/format_wecom.py`delta pad 已通过改写后的 `load_recent_board_keys` 读 shown确保传入的 `*_pad` 池已是 `board_select` 深池结果)
- Modify: `daily/report_data.py`可选llm_input 切片改为 board_select 后列表,避免 Agent 看见未去重 Top
- Test: `tests/test_board_history.py` 增补「shown ≠ baseline 推导」集成断言;`tests/test_wecom_delta.py` pad 用例
**Interfaces:**
- Consumes: Task12
- Produces: 每次成功 generate 后 `output/{date}.data.json``data.wecom_shown_keys`
- [ ] **Step 1: Write / extend failing integration test**
```python
def test_persist_shown_keys_differs_from_baseline_keys(self):
# 构造raw trending 前 3 名本周已 shownboard_select 选出 3..
# movement_baseline 仍含 0..compare_depth
# 断言 data["wecom_shown_keys"]["github_trending"] 与 baseline repos 集合不等
...
```
(可用临时 `OUTPUT_DIR` + 调用抽取出的 `persist` 辅助,或测 `merge_wecom_shown_into_data` + `board_select` 组合。)
- [ ] **Step 2: Run — expect FAILgenerate 尚未写 shown**
- [ ] **Step 3: Wire generate**
`generate_report` 中,在组装 wecom 榜之前:
1. `recent = load_recent_shown_keys(date_str)`
2. 对五榜分别 `board_select(...)` 得到 `selected_*`limit=wecom_*pool=`board_pool_size()`
3. full渲染用 `selected_*`
4. delta`trending_pad`/`github_*_pad` = 同规则更大 pool 的 select 结果(或 raw 深池再 select`replace_wecom_board_sections(..., pad=True)` 内部 recent 已是 shown
5. 渲染后根据**最终写入正文的 items**full=selecteddelta=函数返回或并行计算最终列表)调用 `extract_shown_keys``merge_wecom_shown_into_data`,写回 data.json在现有 `save_json` 路径合并字段)
注意:`movement_baseline` 仍用 **raw** compare 切片构建(`report_data.build_llm_input` 现逻辑保留)。
`build_llm_input` 当前把未过滤 Top 塞进 Agent改为传入 `selected_*`(或另字段 `boards_for_wecom`),避免 opening 引用已周封杀的榜首。
辅助:在 `format_wecom` 增加 `resolve_wecom_board_items(...)` 返回最终 items dict供写回与 featured 池 A 共用,避免正文与 history 分叉。
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_board_select.py tests/test_board_history.py tests/test_wecom_delta.py -v`
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add daily/generate.py daily/format_wecom.py daily/report_data.py tests/
git commit -m "feat: generate 以 board_select 为唯一列表主人并写回 shown keys"
```
---
### Task 4: `featured_resolve`(先定人再 research
**Files:**
- Modify: `daily/featured_pick.py`
- Modify: `daily/generate.py`(调用顺序)
- Test: `tests/test_featured_resolve.py`
**Interfaces:**
- Consumes: 最终展示 items池 A、raw 深池(池 B、近 30 日 `featured_pick_key`
- Produces:
- `featured_identity_key(featured: dict) -> str``type==skill` → idgithub → repo兜底 url path
- `load_recent_featured_keys(date_str, days) -> set[str]`
- `featured_resolve(*, date_str, candidate: dict | None, pool_a: list[dict], pool_b: list[dict], rng: random.Random | None) -> tuple[dict | None, str | None]`
返回 `(resolved_seed_or_featured_stub, identity_key)`**不含**完整 whywhy 由后续 research 写)
-`apply_featured_pick`:先 resolve 身份(若与昨日冲突则换候选写入 query`research_featured_pick`
- [ ] **Step 1: Failing tests**
```python
# tests/test_featured_resolve.py
def test_same_as_yesterday_picks_from_pool_a(self):
yesterday_key = "headroomlabs-ai/headroom"
pool_a = [
{"repo": "headroomlabs-ai/headroom", "board": "github_topic"},
{"repo": "ollama/ollama", "board": "github_trending"},
]
rng = random.Random(0)
resolved, key = featured_resolve(
date_str="2026-07-14",
candidate={"type": "github", "url": "https://github.com/headroomlabs-ai/headroom", "title": "headroom"},
pool_a=pool_a,
pool_b=[],
recent_featured={yesterday_key},
yesterday_key=yesterday_key,
rng=rng,
)
self.assertNotEqual(key, yesterday_key)
self.assertEqual(key, "ollama/ollama")
def test_pool_a_before_pool_b(self):
...
def test_exhausted_keeps_original(self):
...
```
- [ ] **Step 2: Run — FAIL**
- [ ] **Step 3: Implement**
`featured_resolve`:若无 candidate 或与 `yesterday_key` 不同 → 原样返回。
冲突时:过滤 `recent_featured | {yesterday_key}`,先从 pool_a 建可选项(每项抽 identity`rng.choice`;空则 pool_b仍空 log `featured_fallback_exhausted` 并保留原 candidate。
`apply_featured_pick` / generate 流程:
1. 解析 env 得到初始 query/candidate
2. `featured_resolve`(此时池 A 已是 board_select 结果)
3. 若换人:用新 repo/skill 构造 config`research_featured_pick`
4. 写入 `llm_input["featured_pick"]` 与之后 data.`featured_pick_key`
随机默认:`random.Random(int(hashlib.sha256(f"{date_str}:featured".encode()).hexdigest()[:16], 16))`
- [ ] **Step 4: pytest PASS**
- [ ] **Step 5: Commit**
```bash
git add daily/featured_pick.py daily/generate.py tests/test_featured_resolve.py
git commit -m "feat: 首推与昨日冲突时改推并保证一月不重复"
```
---
### Task 5: `narrative_axis` 硬互斥 + Step1 软禁 theme
**Files:**
- Create: `daily/narrative_axis.py`
- Modify: `daily/agent_workflow.py``analyze_trends`
- Modify: `skills/daily-agent/SKILL.md`
- Modify: `daily/generate.py`(落盘 `narrative_axis`;注入 llm_input
- Test: `tests/test_narrative_axis.py`
**Interfaces:**
- Produces:
- `NARRATIVE_AXES: tuple[str, ...] = ("政策监管", "模型发布", "工具链/Agent", "芯片算力", "开源生态", "应用落地", "安全/诉讼")`
- `pick_narrative_axis(used: set[str], *, rng: random.Random | None = None) -> str`
- `load_recent_axes(date_str, days) -> list[str]`(近 N 日 data.`narrative_axis`
- `enforce_narrative_axis(trends: dict, axis: str) -> dict`(强制 trends["narrative_axis"]=axis
- [ ] **Step 1: Failing tests**
```python
def test_pick_excludes_used(self):
used = {"政策监管", "模型发布", "工具链/Agent"}
for _ in range(20):
axis = pick_narrative_axis(used, rng=random.Random(1))
self.assertNotIn(axis, used)
def test_enforce_overwrites_llm(self):
trends = {"narrative_axis": "开源生态", "opening": "..."}
out = enforce_narrative_axis(trends, "芯片算力")
self.assertEqual(out["narrative_axis"], "芯片算力")
```
- [ ] **Step 2: FAIL → Step 3 implement**
`analyze_trends`:计算 `axis = pick_narrative_axis(set(load_recent_axes(...)))`;把 `required_narrative_axis` 与近 `theme_ban_days` 的 theme/opening 摘要列表注入 system prompt要求 JSON 含 `narrative_axis` 且必须等于 required。解析后 `enforce_narrative_axis`
generate 将 axis 写入 data.json。
SKILL.md Step1 schema 增加 `narrative_axis` 字段说明。
- [ ] **Step 45: pytest + commit**
```bash
git commit -m "feat: 代码选定叙事轴并注入 Agent 开场约束"
```
---
### Task 6: 取消新闻「放宽窗口」
**Files:**
- Modify: `daily/news/fetch.py``_apply_pushed_dedup_with_backfill`
- Modify: `daily/news/research.py`(确认只 filter_unpushed不足不拉超窗
- Create: `daily/news/sanitize.py`(或放入 `text_utils`)— `strip_relax_window_prefix(text: str) -> str`
- Modify: `skills/daily-ai-news-research/SKILL.md`(删除「放宽至 48h 并注明」;改为不足则少返回、禁止标注)
- Modify: `daily/generate.py` / news finalize对 desc_short 剥前缀)
- Test: `tests/test_news_relax.py`;扩展 `tests/test_news_fetch_window.py`
**Interfaces:**
- `news_backfill_enabled() == False` 时:`_apply_pushed_dedup_with_backfill` 等价于只返回 `filter_unpushed_items(...)[:limit]`**不**再从 `picked` 塞回
- `strip_relax_window_prefix`:去掉开头的 `放宽窗口[:]?` / `放宽至[^:]*[:]`
- [ ] **Step 1: Failing tests**
```python
def test_backfill_disabled_does_not_reinsert_pushed(self):
# fresh 不足 limitpicked 含已推BACKFILL=0 → 结果不含已推 link
...
def test_strip_relax_prefix(self):
self.assertEqual(
strip_relax_window_prefix("放宽窗口:苹果起诉 OpenAI"),
"苹果起诉 OpenAI",
)
```
- [ ] **Step 24: 实现并跑 `pytest tests/test_news_relax.py tests/test_news_fetch_window.py -v`**
Research 路径SKUILL 改完后,代码侧对 items 统一 `strip`;不足时 log `news_short:{n}`,接受短列表。
- [ ] **Step 5: Commit**
```bash
git commit -m "fix: 关闭新闻放宽凑数并剥离放宽窗口文案"
```
---
### Task 7: 端到端回归与文档对齐
**Files:**
- Modify: 如有遗漏的 `.env.example` / SKILL
- Test: 全量相关测试
- [ ] **Step 1: 跑全套**
Run:
```bash
pytest tests/test_board_history.py tests/test_board_select.py tests/test_featured_resolve.py tests/test_narrative_axis.py tests/test_news_relax.py tests/test_news_fetch_window.py tests/test_wecom_delta.py tests/test_featured_pick.py -v
```
Expected: 全部 PASS
- [ ] **Step 2: Spec 对照清单(人工)**
| Spec 要求 | 任务 |
|-----------|------|
| wecom_shown_keys ≠ movement_baseline | T1, T3 |
| board_select 唯一主人 + delta pad 共用 shown | T2, T3 |
| 首推月去重 A→B、先定人再 why | T4 |
| narrative_axis 硬保证 | T5 |
| 禁放宽 backfill + 剥前缀 + hours 窗 | T6 |
| 成功标准可测 | 各测覆盖 |
- [ ] **Step 3: Commit若有收尾文档**
```bash
git add -u
git commit -m "test: 多样性去重全链路回归通过"
```
---
## Spec Coverage Self-Review
| Spec 节 | 计划任务 |
|---------|----------|
| 1.1 双基准分离 | T1 |
| 1.2 唯一列表主人 | T2T3 |
| 1.3 真相表 | T1, T3T5 落盘字段 |
| 2.1 board_select | T2 |
| 2.2 featured_resolve | T4 |
| 3.1 narrative_axis + 软 theme | T5 |
| 3.2 取消放宽 | T6 |
| 4.x 配置/降级/测试 | T1T7 |
无 TBDCommit 信息均为中文描述体。
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md`.
**两种执行方式:**
1. **Subagent-Driven推荐** — 每任务新开子代理,任务间审查
2. **Inline Execution** — 本会话按 `executing-plans` 连续做完,设检查点
要哪个?