Files
reader/plans/article-summary-async-job-plan.md

758 lines
20 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.
# 单篇总结异步 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 问题从“同步等待”改成“正式异步轮询”,并且几乎不碰无关模块。