diff --git a/prd-ai-daily-cross-day-dedup.md b/prd-ai-daily-cross-day-dedup.md new file mode 100644 index 0000000..6eb1614 --- /dev/null +++ b/prd-ai-daily-cross-day-dedup.md @@ -0,0 +1,173 @@ +# 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 源,利用 `` 字段过滤掉发布日期超过 `N` 天的条目: + +```python +CROSS_DAY_FILTER_DAYS = { + "InfoQ AI": 3, # 只保留 3 天内发布的 InfoQ 文章 + "MIT科技评论AI": 5, + # 量子位、橘鸦等不需要过滤,因为它们的 feed 本身已经当天 +} +``` + +在 RSS fetcher 解析 `` 后,如果 `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 中过滤。 +- **历史数据回填**:不追溯清理已有的已发布日报,只影响从部署日开始的运行。