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>
This commit is contained in:
1162
docs/superpowers/plans/2026-07-09-wecom-delta-mode.md
Normal file
1162
docs/superpowers/plans/2026-07-09-wecom-delta-mode.md
Normal file
File diff suppressed because it is too large
Load Diff
604
docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md
Normal file
604
docs/superpowers/plans/2026-07-14-wecom-diversity-dedup.md
Normal file
@@ -0,0 +1,604 @@
|
||||
# 企微早报多样性与去重 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`(默认 False;env `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/y;baseline 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: Task1–2
|
||||
- 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 名本周已 shown;board_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 FAIL(generate 尚未写 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=selected;delta=函数返回或并行计算最终列表)调用 `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` → id;github → 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)`;**不含**完整 why(why 由后续 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 4–5: 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 不足 limit;picked 含已推;BACKFILL=0 → 结果不含已推 link
|
||||
...
|
||||
|
||||
def test_strip_relax_prefix(self):
|
||||
self.assertEqual(
|
||||
strip_relax_window_prefix("放宽窗口:苹果起诉 OpenAI"),
|
||||
"苹果起诉 OpenAI",
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2–4: 实现并跑 `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 唯一列表主人 | T2–T3 |
|
||||
| 1.3 真相表 | T1, T3–T5 落盘字段 |
|
||||
| 2.1 board_select | T2 |
|
||||
| 2.2 featured_resolve | T4 |
|
||||
| 3.1 narrative_axis + 软 theme | T5 |
|
||||
| 3.2 取消放宽 | T6 |
|
||||
| 4.x 配置/降级/测试 | T1–T7 |
|
||||
|
||||
无 TBD;Commit 信息均为中文描述体。
|
||||
|
||||
## 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` 连续做完,设检查点
|
||||
|
||||
要哪个?
|
||||
@@ -0,0 +1,271 @@
|
||||
# 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、新闻禁放宽 与访谈一致
|
||||
Reference in New Issue
Block a user