174 lines
8.1 KiB
Markdown
174 lines
8.1 KiB
Markdown
# AI日报跨天去重修复 PRD
|
||
|
||
**日期:** 2026-06-08
|
||
**状态:** 草稿
|
||
**相关仓库:** `ephron_ren/ai-daily-report`
|
||
|
||
---
|
||
|
||
## 1. 问题陈述
|
||
|
||
AI日报 pipeline 的重复新闻问题,经排查由三个缺陷叠加导致:
|
||
|
||
### 1.1 无跨天去重(主因,影响面 ~50%)
|
||
|
||
pipeline 是纯 daily function,每次运行从零开始:采集 → 当天内去重 → 改写 → 发布。没有任何机制检查某条新闻的 URL 或内容是否已在之前某天的日报中发过。
|
||
|
||
**证据:** 对比 6/7 和 6/8 的发布内容,约 50-60% 的条目重复。这些重复几乎全部来自 InfoQ RSS(每天固定 15 条)和 MIT Tech Review RSS(每天固定 10 条),它们的 feed 会持续包含多天前的文章。URL 完全相同(`canonicalize_url` 已剥离 tracking params),但因为跨天,没有数据可对比。
|
||
|
||
### 1.2 同天内 title 相似度阈值过于严格(次因,影响面 2-5 条/天)
|
||
|
||
`dedupe.py` 中的 `_possible_duplicates()` 使用 `difflib.SequenceMatcher` 阈值 0.65 判断标题是否相似。对于中英文混排的标题,这个阈值过高,导致同一天内不同来源报道同一新闻时无法被识别为候选重复。
|
||
|
||
**例子(6/8):**
|
||
- Item 49:「OpenAI定制芯片核心成员Clive Chan跳槽至Anthropic」(来源 X)
|
||
- Item 50:「OpenAI芯片核心叛逃Anthropic!就在量产前夜」(来源 量子位)
|
||
- → 同一件事,normalize 后阈值约 0.55-0.60,未被 Stage 2 标记为 possible duplicate,Stage 3 语义去重也看不到。
|
||
|
||
### 1.3 AI HOT 与 RSS 同源(影响有限)
|
||
|
||
AI HOT 聚合的内容与 pipeline 单独抓取的 RSS 有重叠。同 URL 的情况已被当天 URL 去重兜底(`_2` 后缀),但同一事件不同 URL 的文章无法覆盖。
|
||
|
||
---
|
||
|
||
## 2. 目标
|
||
|
||
1. **消除跨天重复**:同一 URL 的新闻在发布过后,后续日报不再收录
|
||
2. **提升同天去重召回率**:修复 title 相似度阈值,让 Stage 3 语义去重能覆盖更多跨来源重复
|
||
3. **零误杀**:去重机制只能移除真正重复的内容,不能因为标题或 URL 相似而误删独立新闻
|
||
|
||
---
|
||
|
||
## 3. 方案设计
|
||
|
||
### 3.1 跨天去重(Stage 2.5 — 新增 stage)
|
||
|
||
在 Stage 2(硬去重)和 Stage 3(语义去重)之间插入一个新的 **Stage 2.5:跨天去重**。
|
||
|
||
**机制:** 维护一个历史 URL 集合文件(`~/.hermes/scripts/ai_morning_out/published_urls.json`),每次 publish 后追加当天所有已发布 items 的 `canonical_url`。每次运行 Stage 2.5 时加载该文件,过滤掉 `canonical_url` 已存在于历史集合中的 items。
|
||
|
||
**文件格式(`published_urls.json`):**
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"urls": {
|
||
"https://infoq.com/news/2026/06/google-litertlm-gemma4": {
|
||
"first_seen": "2026-06-07",
|
||
"last_published": "2026-06-07",
|
||
"titles": ["Google LiteRT-LM 利用 Gemma 4 多令牌预测实现 2.2 倍推理加速"]
|
||
},
|
||
...
|
||
},
|
||
"updated_at": "2026-06-08T10:02:00+08:00"
|
||
}
|
||
```
|
||
|
||
**设计要点:**
|
||
- URL 作为 dedup key(`canonical_url`),和 Stage 2 的 URL 去重逻辑一致
|
||
- 只记录**已发布**的 items(即跑完 Stage 7 assemble 后,最终出现在 blog_markdown.md 中的 items)
|
||
- 写入时机:Stage 8 publish 成功后追加到历史文件
|
||
- 不对标题做跨天语义去重(防误杀),仅对 URL
|
||
- 配置化:可通过 `sources.json` 或 `pipeline.json` 控制「跨天去重窗口」,默认 7 天
|
||
- 空 canonical_url 的 item 跳过跨天去重
|
||
|
||
**为什么只对 URL 不对 title 做跨天?** 同一事件在不同天可能有不同角度的报道(例如第一天简短报道发布,第二天深度分析),不同 URL 说明是不同文章,不应视为重复。
|
||
|
||
### 3.2 标题相似度阈值下调
|
||
|
||
**改动:`dedupe.py` → `_possible_duplicates()`**
|
||
|
||
将 title 相似度阈值从 `0.65` 下调至 **`0.50`**,并针对中英文混排场景做优化:
|
||
|
||
```python
|
||
# 旧
|
||
ratio >= 0.65
|
||
|
||
# 新
|
||
ratio >= 0.50
|
||
```
|
||
|
||
**原因:** 下调后更多候选对进入 Stage 3 语义去重,由 LLM 判断是否真的重复。Stage 3 已有 `max_deletion_ratio=0.5` 的安全阀,不会误删过多。
|
||
|
||
同时考虑增加一种基于**共享 token 比例**的辅助判断(不必完全替换 edit distance):
|
||
- 计算两个 normalize 后标题的 token 交集大小/并集大小(Jaccard 相似度)
|
||
- 如果 Jaccard ≥ 0.4 且 edit distance ≥ 0.4,则标记为 possible duplicate
|
||
|
||
### 3.3 rss.py 增加 published_at 过滤
|
||
|
||
**改动:`sources/rss.py` → `fetch_rss()`**
|
||
|
||
对于 RSS 源,利用 `<pubDate>` 字段过滤掉发布日期超过 `N` 天的条目:
|
||
|
||
```python
|
||
CROSS_DAY_FILTER_DAYS = {
|
||
"InfoQ AI": 3, # 只保留 3 天内发布的 InfoQ 文章
|
||
"MIT科技评论AI": 5,
|
||
# 量子位、橘鸦等不需要过滤,因为它们的 feed 本身已经当天
|
||
}
|
||
```
|
||
|
||
在 RSS fetcher 解析 `<pubDate>` 后,如果 `published_at` 距今超过配置的天数,直接丢弃。这能**从源头上减少** InfoQ/MIT 的陈旧条目进入 pipeline。
|
||
|
||
**注意:** 这个过滤是跨天去重的补充优化,不是替代。即使 RSS 过滤后仍有少量陈旧条目,Stage 2.5 的 URL 去重会兜底。
|
||
|
||
---
|
||
|
||
## 4. 实现计划
|
||
|
||
### Phase 1:Stage 2.5 跨天去重
|
||
|
||
| 步骤 | 文件 | 改动 |
|
||
|---|---|---|
|
||
| 1.1 | `ai_daily_report/models.py` | 新增 `PublishedUrls` 数据类(version, urls, updated_at) |
|
||
| 1.2 | `ai_daily_report/dedupe.py` | 新增 `cross_day_dedup_items(items, published_urls, max_age_days=7)` 函数 |
|
||
| 1.3 | `ai_daily_report/pipeline.py` | 修改 `run_stage0_to_stage2()` 接受 `published_urls` 参数;新增 `run_stage0_to_stage2_5()`(含 stage 2.5) |
|
||
| 1.4 | `ai_daily_report/publish.py` | 修改 `publish_markdown()` 返回已发布的 items 列表;新增 `update_published_urls()` 写入历史文件 |
|
||
| 1.5 | `ai_daily_report/runner.py` | 加载 `published_urls.json` 传入 pipeline;publish 成功后更新 |
|
||
| 1.6 | 新文件 | `published_urls.json` 初始为空文件 |
|
||
| 1.7 | `config/pipeline.json` | 新增 `cross_day_dedup` 配置段(`enabled`, `max_age_days`, `history_path`) |
|
||
|
||
### Phase 2:标题相似度优化
|
||
|
||
| 步骤 | 文件 | 改动 |
|
||
|---|---|---|
|
||
| 2.1 | `ai_daily_report/dedupe.py` | 将 `_possible_duplicates()` 的阈值从 0.65 改为 0.50;增加 Jaccard 相似度辅助判断 |
|
||
| 2.2 | 测试 | 更新 `_possible_duplicates` 的单元测试阈值 |
|
||
|
||
### Phase 3:RSS 源过期过滤
|
||
|
||
| 步骤 | 文件 | 改动 |
|
||
|---|---|---|
|
||
| 3.1 | `ai_daily_report/sources/rss.py` | 在 `fetch_rss()` 中按 `published_at` 过滤,丢弃超期条目 |
|
||
| 3.2 | `config/sources.json` | 为 InfoQ、MIT 等 RSS 源增加 `max_item_age_days` 字段 |
|
||
|
||
---
|
||
|
||
## 5. 风险与缓解
|
||
|
||
| 风险 | 缓解 |
|
||
|---|---|
|
||
| 跨天去重误杀不同 URL 的同事件新闻 | 只对 URL 做 key,不碰 title。不同 URL 说明是不同的文章,保留。 |
|
||
| title 阈值下调后 Stage 3 误删 | `max_deletion_ratio=0.5` 安全阀保底;LLM 判断语义重复而非关键词匹配 |
|
||
| `published_urls.json` 文件损坏 | JSON 解析失败时回退到无跨天去重(log warning),不影响当天发布 |
|
||
| 历史文件无限增长 | 默认保留 7 天窗口,超过自动清理;或限制文件大小上限 |
|
||
| 跨天去重后某天稿件太少 | 不改变。宁可少发也不发重复内容。 |
|
||
|
||
---
|
||
|
||
## 6. 验收标准
|
||
|
||
1. **6/7 的重复文章(如 Google LiteRT-LM、Claude Code Dynamic Workflows、Dropbox Nova 等)在 6/9 的日报中不应出现**
|
||
2. **同一天内同一新闻的跨来源报道(如 量子位 + X + TechCrunch 报道同一事件)应在 Stage 3 语义去重中被识别并去重**
|
||
3. **`published_urls.json` 在 publish 成功后正确更新,内容格式符合预期**
|
||
4. **RSS 源中超过配置天数的文章不再出现在采集结果中**
|
||
5. **所有现有测试用例仍能通过**
|
||
6. **手动运行一次全流程(dry-run 或 draft 模式)验证去重效果**
|
||
|
||
---
|
||
|
||
## 7. 未纳入范围
|
||
|
||
- **跨天语义去重**:不比较不同天 items 的标题/摘要相似度,仅用 URL 去重。防止误判。
|
||
- **增量采集**:不改变采集方式。RSS feed 全量拉取,在后面 stage 中过滤。
|
||
- **历史数据回填**:不追溯清理已有的已发布日报,只影响从部署日开始的运行。
|