feat(reader): add async article summary jobs

This commit is contained in:
root
2026-04-10 16:37:43 +08:00
parent 87d18e4263
commit 2053cccfef
8 changed files with 1269 additions and 15 deletions
+757
View File
@@ -0,0 +1,757 @@
# 单篇总结异步 job 最小版落地方案
## 1. 背景与问题定义
当前 reader 已经把日更 FreshRSS 主流程做成了带 `run-state.json` 的 runtime 模型:
- `src/summary_mcp/runtime/state_models.py`
- `src/summary_mcp/runtime/run_store.py`
- `src/summary_mcp/runtime/query_service.py`
- `src/summary_mcp/workflows/freshrss_pipeline.py`
这条主链路已经具备:
- run / stage / artifact 的结构化状态
- MCP 查询接口:`get_run_status` / `list_runs` / `list_run_artifacts`
- 结果读取接口:`get_delivery_payload` / `get_run_report`
但“单篇总结”这条线目前还是同步调用:
- 核心逻辑:`src/summary_mcp/workflows/article_summary.py`
- MCP 暴露:`src/summary_mcp/server.py` 中的 `generate_article_summaries`
- CLI 辅助:`scripts/run_article_summaries.py`
现状问题已经很明确:
- 在 OpenClaw → MCP tool 这条链路里,`generate_article_summaries` 可能因为 tool 调用时长而 timeout
- 但 reader 项目本体在 `.venv` 下直接跑 article summary,大约 37.5 秒即可成功
- 这说明问题不一定在 summary 业务本身,而更可能在“同步工具调用 + 上层等待模型”这个包装层
所以目标不是先继续调 timeout,而是把单篇总结也纳入 **真正异步、可轮询、可落盘、可恢复基本状态** 的最小 job 模型里。
---
## 2. 为什么同步 MCP 不适合这一步
`generate_article_summaries` 当前在 `server.py` 里直接同步执行 `summarize_selected_articles(...)`,调用方必须一直阻塞等待,直到:
1. 读取 extracted payload
2. 调用 LLM 生成总结
3. 可选 repair retry
4. 渲染 Markdown
5. 写文件完成
6. MCP tool 返回生成路径
这个模式对“几十秒级、依赖外部 LLM、可能重试”的任务不稳,核心问题有三层:
### 2.1 tool 调用时长不可控
`run_loop_payload()` 内部会发起外部 HTTP 请求,还可能做 repair retry。即便单次平均 37.5 秒,也已经接近很多上层编排系统的心理和技术超时边界。
### 2.2 调用方看不到中间状态
现在如果卡住,调用方只能等:
- 不知道是在读输入
- 不知道是在调 LLM
- 不知道是在重试
- 不知道是否已经写出部分结果
这也是同步接口最烦的点:失败时只能看到“tool timeout / tool failed”,而不是“业务跑到哪一步了”。
### 2.3 与 reader 已有 runtime 风格不一致
FreshRSS 主流程已经是“run truth + status query + artifact read”的思路,而单篇总结仍然是黑箱同步函数。继续维持两套风格,只会让 SOP 更复杂:
- 日报主链路用 `run_id`
- 单篇总结却要么同步等,要么退回 CLI fallback
这不利于后续把 `reader-digest-flow` 稳定成正式 SOP。
---
## 3. 本轮目标:最小真异步,不做大而全
这次只做 **单篇总结异步 job 最小版**,目标是:
> 让 OpenClaw 或其他调用方能先“启动单篇总结 job”,立即拿到 `job_id`,再通过状态接口轮询,最后读取输出文件/结果。
### 3.1 本轮必须做到的范围
1. 新增单篇总结 job 的 start/status/result 最小接口
2. job 真正在后台执行,而不是 MCP 请求线程里阻塞到完成
3. 状态落盘到文件,遵循 reader 当前 runtime 风格
4. 复用现有 `summarize_selected_articles` 逻辑,不重写业务
5. 输出仍然是现有 Markdown 文件,不改知识内容 schema
### 3.2 本轮明确不做
1. **不做通用队列系统**
2. **不做数据库**
3. **不做多 worker / 分布式调度**
4. **不做取消 job / kill job**
5. **不做并发配额控制**
6. **不做 resume/retry from stage**
7. **不把 article summary 一次性并入 freshrss `resume_run` 体系**
8. **不改 summary prompt / validator / 输出格式**
9. **不处理批量高吞吐场景优化**
一句话:这轮只解“同步 tool 容易 timeout,但业务本身能跑完”这个问题,不顺手扩成任务调度平台。
---
## 4. 接入当前 reader 结构的建议
### 4.1 复用现有 runtime 设计,但单独建 article summary job 命名空间
不建议把 article summary job 粗暴塞进现有 `freshrss_daily_digest` run 查询里混用一个 schema;更合适的是:
- 复用 `RunState / StageState / ArtifactRecord / RunStore` 这套思维
- 但给单篇总结定义独立 workflow 名称与存储目录
建议:
- workflow: `article_summary_job`
- run_type: `article_summary`
- output root: `outputs/freshrss/article_summary_jobs/<job_id>/`
这样有几个好处:
- 不污染 `outputs/freshrss/rerun/`
- 语义清楚:这是独立 job,不是假装自己是日报 rerun
- 查询和排查时更直观
### 4.2 job 与结果 Markdown 解耦
job 目录只负责:
- 状态文件
- 输入快照
- artifact 索引
- 执行报告
真正生成的总结 Markdown,仍然可以写到用户指定的 `output_dir`(或默认 `single_summaries/`)。
这样不破坏当前下游 SOP:
- `reader-digest-flow` 依然从原来的单篇总结输出目录拿 `.md`
- job 目录只提供状态与索引,不强迫下游改结果路径约定
---
## 5. 新增工具 / API 设计
本轮建议新增 3 个 MCP tool,名字尽量和当前 runtime 风格一致。
## 5.1 `start_article_summary_job`
### 作用
启动一个后台 job,立即返回 `job_id`,不等待总结完成。
### 输入建议
```json
{
"extracted_path": "outputs/freshrss/rerun/<run_id>/extracted/item-01.extracted.json",
"selected_ids": ["12345"],
"output_dir": "outputs/freshrss/single_summaries/2026-04-10",
"max_retries": 2,
"timeout_seconds": 120,
"llm_api_key": null,
"llm_model": null,
"llm_api_url": null
}
```
### 返回建议
```json
{
"job_id": "article-summary-20260410-144500-ab12cd34",
"workflow": "article_summary_job",
"run_type": "article_summary",
"status": "running",
"output_dir": "outputs/freshrss/article_summary_jobs/article-summary-20260410-144500-ab12cd34",
"message": "Article summary job started successfully. Use get_article_summary_job_status to poll progress."
}
```
### 约束建议
- `selected_ids` 第一版允许多个,但建议由 OpenClaw 每次只传一篇或少量篇,避免一个 job 干太多事
- `extracted_path` 必须存在,否则直接拒绝启动
- `output_dir` 不传则按当前默认逻辑推导
---
## 5.2 `get_article_summary_job_status`
### 作用
查询 job 当前状态、阶段、进度、错误摘要、已注册 artifact。
### 输入
```json
{
"job_id": "article-summary-20260410-144500-ab12cd34"
}
```
### 返回建议
```json
{
"job_id": "article-summary-20260410-144500-ab12cd34",
"workflow": "article_summary_job",
"run_type": "article_summary",
"status": "running",
"current_stage": "generate_markdown",
"started_at": "2026-04-10T14:45:00+08:00",
"updated_at": "2026-04-10T14:45:23+08:00",
"finished_at": null,
"output_dir": "outputs/freshrss/article_summary_jobs/article-summary-20260410-144500-ab12cd34",
"progress": {
"completed_stage_count": 2,
"running_stage_count": 1,
"failed_stage_count": 0,
"pending_stage_count": 1,
"total_stage_count": 4
},
"artifacts": [...],
"error_summary": null
}
```
---
## 5.3 `get_article_summary_job_result`
### 作用
当 job 成功后,返回结构化结果,供 OpenClaw 继续下游 IMA 沉淀。
### 输入
```json
{
"job_id": "article-summary-20260410-144500-ab12cd34"
}
```
### 返回建议
```json
{
"job_id": "article-summary-20260410-144500-ab12cd34",
"status": "success",
"written_paths": [
"outputs/freshrss/single_summaries/2026-04-10/某篇文章标题.md"
],
"artifact": {
"name": "job_result",
"path": "outputs/freshrss/article_summary_jobs/article-summary-20260410-144500-ab12cd34/result.json",
"kind": "json",
"stage": "write_result"
},
"result": {
"selected_ids": ["12345"],
"written_paths": [
"outputs/freshrss/single_summaries/2026-04-10/某篇文章标题.md"
]
}
}
```
### 行为建议
- 若 job 还没完成,返回当前状态 + 提示“not ready”
- 若 job 失败,返回失败摘要,不硬抛文件不存在异常
---
## 6. job 状态文件设计
建议直接复用现有 `RunState` 模型,不另造一套 schema。
job 目录示例:
```text
outputs/freshrss/article_summary_jobs/
article-summary-20260410-144500-ab12cd34/
run-state.json
input.json
result.json
job-report.json
```
## 6.1 `run-state.json`
建议直接沿用当前字段:
```json
{
"run_id": "article-summary-20260410-144500-ab12cd34",
"workflow": "article_summary_job",
"run_type": "article_summary",
"status": "running",
"current_stage": "generate_markdown",
"started_at": "2026-04-10T14:45:00+08:00",
"updated_at": "2026-04-10T14:45:23+08:00",
"finished_at": null,
"input": {
"extracted_path": "outputs/freshrss/rerun/<run_id>/extracted/item-01.extracted.json",
"selected_ids": ["12345"],
"output_dir": "outputs/freshrss/single_summaries/2026-04-10",
"max_retries": 2,
"timeout_seconds": 120
},
"stages": [...],
"artifacts": [...],
"error": null,
"recovery": {
"resumable": false,
"resume_from_stage": null,
"last_success_stage": "load_input"
}
}
```
### 第一版 stage 建议
建议只切 4 个 stage,够看即可:
1. `prepare_job`
- 校验输入
- 解析路径
- 写 `input.json`
2. `load_input`
- 读取 extracted payload
- 确认 `selected_ids` 可匹配条目
3. `generate_markdown`
- 调 `summarize_selected_articles(...)`
- 这是主要耗时阶段
4. `write_result`
- 写 `result.json`
- 注册输出 artifact
这里不要把 LLM 调用再拆更多细 stage,否则最小版反而过度设计。
---
## 6.2 `input.json`
作用:保留启动请求快照,便于排查。
建议内容与 `run-state.input` 基本一致。
---
## 6.3 `result.json`
成功时写:
```json
{
"job_id": "article-summary-20260410-144500-ab12cd34",
"selected_ids": ["12345"],
"written_paths": [
"outputs/freshrss/single_summaries/2026-04-10/某篇文章标题.md"
],
"completed_at": "2026-04-10T14:45:41+08:00"
}
```
失败时可以不写,或只写失败快照都行。最小版建议:
- 成功写 `result.json`
- 失败只依赖 `run-state.json`
避免双份失败状态不一致。
---
## 7. 执行模型建议:优先子进程,不建议线程
### 7.1 推荐:子进程后台执行
最小真异步推荐模型:
- `start_article_summary_job` 负责:
- 创建 job 目录
- 初始化 `run-state.json`
- 通过 `subprocess.Popen(...)` 启动一个独立 Python 进程执行 job runner
- 立即返回 `job_id`
后台 runner 再去:
- 读取 `input.json`
- 用 `RunStore` 更新状态
- 调用 `summarize_selected_articles(...)`
- 写 `result.json`
- finish/fail run
### 7.2 为什么不推荐线程
虽然线程实现看起来更省事,但不适合作为 reader 的正式最小异步落地:
1. **MCP server 进程重启后线程直接丢失**
2. 线程状态不天然可恢复,容易出现“状态文件还在 running,但线程没了”
3. 未来要做健康检查/孤儿 job 检测时,线程模型更难收口
### 7.3 为什么子进程更贴当前项目风格
reader 现在本来就偏“文件产物 + runtime 状态真相”风格。子进程模式有天然优势:
- 和 CLI/fallback 思维一致
- 进程边界清楚
- `run-state.json` 由实际执行者写,职责清晰
- 将来如果要做 orphan detection / stale running job 修复,也容易补
### 7.4 本轮不做进程管理增强
最小版里,不要求:
- 记录 PID 后做 kill/cancel
- 自动清理僵尸进程
- 守护进程/worker 池
但建议在 `input.json` 或 `run-state.input` 里附带:
- `launcher_pid`
- `runner_command`
方便排障。
---
## 8. 与现有 `summarize_selected_articles` 的复用关系
核心原则:**不重写总结业务,只包一层 job runner。**
### 8.1 直接复用的部分
`src/summary_mcp/workflows/article_summary.py` 已经做了:
- 读取 extracted payload
- 根据 `selected_ids` 找条目
- 调用 `run_loop_payload(...)`
- 渲染 Markdown
- 写到 `output_dir`
- 返回 `list[Path]`
这些都继续用。
### 8.2 最小新增建议
建议只新增一层 runtime/service,例如:
- `src/summary_mcp/runtime/article_summary_jobs.py`
职责:
- 生成 `job_id`
- 创建 job 目录
- 初始化 `RunStore`
- 启动 runner 子进程
- 提供 status/result 查询
- 在 runner 里调用 `summarize_selected_articles`
### 8.3 是否需要改 `summarize_selected_articles`
最小版尽量少改,只建议加两类低风险增强:
1. **可选输入校验增强**
- 如果 `selected_ids` 一个都匹配不到,显式报错
- 避免“成功返回空列表”却让 job 看起来像成功
2. **可选 hook / telemetry(非必须)**
- 如果后面需要更细粒度写 stage 输出,可再加
- 但第一版没必要为了观测性重构函数
结论:
- 第一版优先保持 `summarize_selected_articles` 基本不动
- job 层只把它作为黑盒业务函数调用
---
## 9. 建议新增代码组织
建议新增文件:
```text
src/summary_mcp/runtime/article_summary_jobs.py
scripts/run_article_summary_job.py
```
### 9.1 `article_summary_jobs.py`
建议包含:
- `start_article_summary_job(...)`
- `run_article_summary_job(...)`
- `get_article_summary_job_status(...)`
- `get_article_summary_job_result(...)`
- 若干私有 helper:
- job id 生成
- job dir 解析
- result artifact 读取
### 9.2 `scripts/run_article_summary_job.py`
作用:作为子进程 runner 入口。
例如:
```bash
python scripts/run_article_summary_job.py --job-id article-summary-...
```
runner 只做一件事:
- 根据 `job_id` 找到 job 目录和 `input.json`
- 真正执行 job
这样避免在 `Popen("python -c ...")` 里塞长字符串,也方便本地调试。
---
## 10. 对 `reader-digest-flow` SOP 的影响
这块是重点,因为老大的实际痛点就在这。
### 10.1 现状 SOP
当前 skill 已经有硬规则:
- 优先走 MCP `generate_article_summaries`
- MCP timeout 时,立刻 fallback 到 reader 本地 `.venv`
这个 fallback 现在是必要的,但它本质是在补“单篇总结没有正式异步接口”。
### 10.2 引入异步 job 后的推荐 SOP
建议调整为:
1. OpenClaw 在用户确认保留文章后
2. 调 `start_article_summary_job`
3. 拿到 `job_id`
4. 轮询 `get_article_summary_job_status`
5. 成功后调 `get_article_summary_job_result`
6. 再继续 IMA 格式化与上传
### 10.3 对 skill 文档的影响
`reader-digest-flow` 需要后续补一条新规则:
- 单篇总结的正式生产路径从“同步 MCP + 本地 fallback”升级为“异步 job + 状态轮询”
- 本地 `.venv` CLI fallback 仍保留,但降级为:
- job 启动失败
- job runner 异常
- reader 服务端出现系统性问题时的应急路径
### 10.4 用户体验改善
异步 job 后,OpenClaw 可给出更像正式系统的反馈:
- “已启动单篇总结任务,正在生成”
- “当前状态:generate_markdown”
- “已完成,准备继续沉淀到 IMA”
而不是现在的:
- 直接卡住几十秒
- 然后 tool timeout
- 再走一套 fallback
---
## 11. 验证方案
本轮验证不要追求大而全,按 4 层做就够。
### 11.1 单元级验证
目标:确保 job 状态文件和结果文件行为正确。
建议覆盖:
1. `start_article_summary_job` 能创建 job 目录与 `run-state.json`
2. 输入路径不存在时,启动直接失败
3. `get_article_summary_job_status` 能正确读取状态
4. job 成功后 `get_article_summary_job_result` 返回 `written_paths`
5. job 失败后状态为 `failed`,并带错误摘要
### 11.2 本地集成验证
用一个真实 extracted 文件跑:
1. 启动 job
2. 轮询 status
3. 成功后检查:
- `result.json` 存在
- Markdown 文件存在
- 路径正确
### 11.3 OpenClaw 链路验证
在实际 `reader-digest-flow` 环节,用一篇已确认保留的文章做:
1. 启动 async job
2. 等待成功
3. 继续做 IMA markdown 整理与上传
4. 确认整个链路不再因为同步 tool timeout 中断
### 11.4 异常验证
至少测 3 类异常:
1. `selected_ids` 不存在
2. LLM 接口失败 / 超时
3. runner 进程异常退出
预期:
- `run-state.json` 最终为 `failed`
- `error_summary` 可读
- 调用方能明确知道失败,而不是只看到 transport timeout
---
## 12. 风险与回滚方案
## 12.1 风险
### 风险 1:后台子进程成功启动,但状态长期卡在 running
常见原因:
- runner 进程崩了
- server 重启时某些路径没写全
- 子进程命令不对
缓解:
- runner 启动前就先写 `run-state.json`
- runner 一进来先更新 `prepare_job` / `load_input`
- 后续可加“stale running 超时判定”,但第一版先不做自动修复
### 风险 2:复用旧函数导致“空输出也算成功”
当前 `summarize_selected_articles()` 如果没匹配到文章或全部失败,存在返回空列表的可能。
缓解:
- job runner 里把“`written_paths` 为空”视为失败
- 或者顺手在 `summarize_selected_articles` 里补显式校验
### 风险 3:同一时刻大量 job 并发,LLM 调用被打爆
第一版不解决系统级并发控制。
缓解:
- SOP 层先按单篇/少量串行用
- skill 层避免一口气启动很多 job
### 风险 4:状态目录与结果目录分离,排查时容易迷路
缓解:
- 在 `result.json` 和 artifact 元数据里明确记录 `written_paths`
- 在 `run-state.input.output_dir` 中保留结果目录
---
## 12.2 回滚方案
这个方案很好回滚,因为是“新增,不替换”。
### 回滚原则
- 保留现有 `generate_article_summaries`
- 保留现有 `scripts/run_article_summaries.py`
- 新增 async job 接口如果不稳定,直接停止在 OpenClaw 层使用即可
### 回滚路径
1. 停止调用 `start_article_summary_job`
2. 恢复回原 SOP:
- 先尝试同步 MCP `generate_article_summaries`
- 失败则本地 `.venv` fallback
3. async job 相关代码保留但不作为正式入口
也就是说,这轮改动不会堵死当前生产路径,风险可控。
---
## 13. 实施顺序(建议按这个顺序落地)
### Phase A:先把方案落地成最小代码骨架
1. 新增 `src/summary_mcp/runtime/article_summary_jobs.py`
2. 新增 job 目录常量与 helper
3. 新增 `scripts/run_article_summary_job.py`
4. 实现 runner 内部对 `summarize_selected_articles` 的调用
5. 先本地命令验证 job 可跑通
### Phase B:再把 MCP 接口接上
6. 在 `server.py` 新增:
- `start_article_summary_job`
- `get_article_summary_job_status`
- `get_article_summary_job_result`
7. 本地 MCP 调用验证
### Phase C:最后接 OpenClaw SOP
8. 更新 `reader-digest-flow` skill,把正式路径切到 async job
9. 保留本地 `.venv` fallback 作为应急方案
10. 跑一次真实日报保留文章沉淀闭环
---
## 14. 推荐的最小返回/状态语义
为了跟现有 runtime 风格一致,建议继续用:
- `status`: `running` / `success` / `failed`
- `current_stage`: 当前 stage 名
- `artifacts`: 注册产物列表
- `error_summary`: 结构化错误
第一版不必引入:
- `queued`
- `cancelled`
- `retrying`
- `paused`
避免状态机一开始就复杂化。
---
## 15. 结论 / 拍板建议
拍板建议很直接:
1. **这件事值得做,而且优先级高**,因为它正好卡在当前日报 SOP 的真实痛点上
2. **不建议继续先修同步 timeout**,因为同步模型本身就不适合几十秒级、外部 LLM 驱动的任务
3. **最小真异步应采用“文件状态 + 后台子进程 + start/status/result 三接口”**
4. **业务层严格复用 `summarize_selected_articles`**,不要为异步化重写 summary 核心逻辑
5. **先把单篇总结 job 做成独立小 runtime 命名空间**,不要急着并入 freshrss 主 run 的 resume 体系
如果只允许做一轮最小落地,我建议就做到:
- `start_article_summary_job`
- `get_article_summary_job_status`
- `get_article_summary_job_result`
- `outputs/freshrss/article_summary_jobs/<job_id>/run-state.json`
- 子进程 runner
这套已经足够把当前 OpenClaw timeout 问题从“同步等待”改成“正式异步轮询”,并且几乎不碰无关模块。