Skip to content

Commit 27acbdd

Browse files
committed
feat(notify): structured artifact summaries in review notifications
Add _build_node_summary() — pure Python parsers for each node's official artifact (no LLM calls): - scout: title, angle, reference count, keywords (from topic.yaml) - researcher: verification stats, source count (from research.yaml) - writer: char count, section count (from article_edited.md) - director: style, cover, placement count (from visual_plan.json) - formatter: HTML size, image count (from formatted.html + images/) notify_review_needed() now accepts state dict and embeds structured summary in the [REVIEW] notification message.
1 parent 91355bb commit 27acbdd

2 files changed

Lines changed: 232 additions & 7 deletions

File tree

scripts/web/notify.py

Lines changed: 230 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@
22
ContentPipe — Discord 通知集成
33
44
通过 OpenClaw Gateway 的 message API 向 Discord 频道推送 Pipeline 事件。
5+
审核通知内嵌结构化摘要 + 操作指引,Agent 自动识别进入桥接模式。
56
"""
67

78
from __future__ import annotations
89

910
import os
1011
import json
1112
import httpx
12-
from typing import Optional
13+
from pathlib import Path
14+
from typing import Any, Dict, List, Optional
1315

1416
from gateway_auth import build_gateway_headers
1517
from logutil import get_logger
@@ -53,6 +55,209 @@ def _read_config_val(key: str, default: str = "") -> str:
5355
}
5456

5557

58+
# ── 产物摘要生成器(纯 Python 解析,不走 LLM)──────────────────────
59+
60+
def _build_node_summary(run_id: str, node: str, state: Dict[str, Any]) -> str:
61+
"""从 state / 正式产物文件生成人类可读摘要。
62+
63+
每个节点的正式产物格式不同,直接解析文件或 state 字段。
64+
返回多行字符串,用于嵌入通知消息。
65+
"""
66+
builders = {
67+
"scout": _summary_scout,
68+
"researcher": _summary_researcher,
69+
"writer": _summary_writer,
70+
"de_ai_editor": _summary_writer, # 复用 writer 摘要
71+
"director": _summary_director,
72+
"formatter": _summary_formatter,
73+
}
74+
builder = builders.get(node)
75+
if not builder:
76+
# fallback: 只显示标题
77+
title = state.get("topic", {}).get("title", "")
78+
return f"标题: {title}" if title else ""
79+
try:
80+
return builder(run_id, state)
81+
except Exception as e:
82+
logger.warning("Summary build failed for %s/%s: %s", run_id, node, e)
83+
title = state.get("topic", {}).get("title", "")
84+
return f"标题: {title}" if title else ""
85+
86+
87+
def _runs_dir() -> Path:
88+
return Path(__file__).parent.parent.parent / "output" / "runs"
89+
90+
91+
def _summary_scout(run_id: str, state: Dict[str, Any]) -> str:
92+
"""Scout 摘要: 标题、角度、参考文章数、关键词"""
93+
lines: List[str] = []
94+
# 优先从产物文件读取
95+
topic_path = _runs_dir() / run_id / "topic.yaml"
96+
topic_data = state.get("topic", {})
97+
if topic_path.exists():
98+
try:
99+
import yaml
100+
topic_data = yaml.safe_load(topic_path.read_text(encoding="utf-8")) or {}
101+
topic_data = topic_data.get("topic", topic_data)
102+
except Exception:
103+
pass
104+
105+
title = topic_data.get("title", "")
106+
if title:
107+
lines.append(f"📌 标题: {title}")
108+
109+
angle = topic_data.get("content_angle", "")
110+
if angle:
111+
lines.append(f"🎯 角度: {angle}")
112+
113+
# 参考文章数
114+
refs = state.get("reference_articles") or []
115+
if not refs:
116+
# 从 topic.yaml 中读
117+
raw = {}
118+
if topic_path.exists():
119+
try:
120+
import yaml
121+
raw = yaml.safe_load(topic_path.read_text(encoding="utf-8")) or {}
122+
except Exception:
123+
pass
124+
refs = raw.get("reference_articles") or raw.get("reference_index", {}).get("all_links", [])
125+
if refs:
126+
lines.append(f"📎 参考文章: {len(refs)} 篇")
127+
128+
# 关键词
129+
keywords = topic_data.get("required_keywords", []) or topic_data.get("keywords", [])
130+
if not keywords:
131+
ur = state.get("user_requirements", {}) if isinstance(state, dict) else {}
132+
keywords = ur.get("required_keywords", [])
133+
if keywords:
134+
lines.append(f"🏷️ 关键词: {', '.join(keywords[:6])}")
135+
136+
return "\n".join(lines)
137+
138+
139+
def _summary_researcher(run_id: str, state: Dict[str, Any]) -> str:
140+
"""Researcher 摘要: 核查结果数、研究问题数、引用来源数"""
141+
lines: List[str] = []
142+
research_path = _runs_dir() / run_id / "research.yaml"
143+
data: Dict[str, Any] = {}
144+
if research_path.exists():
145+
try:
146+
import yaml
147+
data = yaml.safe_load(research_path.read_text(encoding="utf-8")) or {}
148+
except Exception:
149+
pass
150+
151+
title = state.get("topic", {}).get("title", "")
152+
if title:
153+
lines.append(f"📌 标题: {title}")
154+
155+
verifications = data.get("verification_results", [])
156+
if verifications:
157+
verified = sum(1 for v in verifications if v.get("status") == "verified")
158+
lines.append(f"✅ 核查结果: {verified}/{len(verifications)} 条已验证")
159+
160+
findings = data.get("research_findings", [])
161+
if findings:
162+
lines.append(f"🔬 研究发现: {len(findings)} 条")
163+
164+
# 统计独立来源数
165+
sources_set: set = set()
166+
for v in verifications:
167+
for s in v.get("sources", []):
168+
url = s.get("url", "")
169+
if url:
170+
sources_set.add(url)
171+
for f in findings:
172+
for s in f.get("sources", []):
173+
url = s.get("url", "")
174+
if url:
175+
sources_set.add(url)
176+
if sources_set:
177+
lines.append(f"📚 引用来源: {len(sources_set)} 个")
178+
179+
return "\n".join(lines)
180+
181+
182+
def _summary_writer(run_id: str, state: Dict[str, Any]) -> str:
183+
"""Writer 摘要: 标题、字数、段落数"""
184+
lines: List[str] = []
185+
title = state.get("topic", {}).get("title", "")
186+
if title:
187+
lines.append(f"📌 标题: {title}")
188+
189+
# 读取正式正文
190+
for fname in ("article_edited.md", "article_draft.md"):
191+
article_path = _runs_dir() / run_id / fname
192+
if article_path.exists():
193+
try:
194+
text = article_path.read_text(encoding="utf-8")
195+
char_count = len(text.strip())
196+
# 段落数 = 非空行中以 ## 开头的数量
197+
sections = [l for l in text.splitlines() if l.strip().startswith("## ")]
198+
lines.append(f"📝 字数: {char_count} 字")
199+
if sections:
200+
lines.append(f"📑 章节: {len(sections)} 节")
201+
break
202+
except Exception:
203+
pass
204+
205+
return "\n".join(lines)
206+
207+
208+
def _summary_director(run_id: str, state: Dict[str, Any]) -> str:
209+
"""Director 摘要: 配图数量、封面、风格"""
210+
lines: List[str] = []
211+
vp_path = _runs_dir() / run_id / "visual_plan.json"
212+
data: Dict[str, Any] = {}
213+
if vp_path.exists():
214+
try:
215+
data = json.loads(vp_path.read_text(encoding="utf-8"))
216+
except Exception:
217+
pass
218+
219+
title = state.get("topic", {}).get("title", "")
220+
if title:
221+
lines.append(f"📌 标题: {title}")
222+
223+
style = data.get("style", "")
224+
if style:
225+
lines.append(f"🎨 风格: {style}")
226+
227+
cover = data.get("cover", {})
228+
if cover.get("title"):
229+
lines.append(f"🖼️ 封面: {cover['title']}")
230+
231+
placements = data.get("placements", [])
232+
if placements:
233+
lines.append(f"📸 配图: {len(placements)} 张")
234+
235+
return "\n".join(lines)
236+
237+
238+
def _summary_formatter(run_id: str, state: Dict[str, Any]) -> str:
239+
"""Formatter 摘要: HTML 大小、图片数"""
240+
lines: List[str] = []
241+
title = state.get("topic", {}).get("title", "")
242+
if title:
243+
lines.append(f"📌 标题: {title}")
244+
245+
html_path = _runs_dir() / run_id / "formatted.html"
246+
if html_path.exists():
247+
size_kb = html_path.stat().st_size / 1024
248+
lines.append(f"📐 HTML: {size_kb:.1f} KB")
249+
250+
images_dir = _runs_dir() / run_id / "images"
251+
if images_dir.exists():
252+
img_count = len([f for f in images_dir.iterdir() if f.suffix in (".png", ".jpg", ".jpeg", ".webp")])
253+
if img_count:
254+
lines.append(f"🖼️ 图片: {img_count} 张")
255+
256+
return "\n".join(lines)
257+
258+
259+
# ── Discord 通知 ────────────────────────────────────────────────────
260+
56261
async def notify_discord(
57262
message: str,
58263
*,
@@ -107,15 +312,36 @@ async def notify_node_complete(run_id: str, node: str, title: str = "", summary:
107312
await notify_discord(msg, run_id=run_id, node=node)
108313

109314

110-
async def notify_review_needed(run_id: str, node: str, output_summary: str = ""):
111-
"""需要人工审核通知(内嵌审核指引,agent 自动识别进入桥接模式)"""
315+
async def notify_review_needed(
316+
run_id: str,
317+
node: str,
318+
output_summary: str = "",
319+
state: Optional[Dict[str, Any]] = None,
320+
):
321+
"""需要人工审核通知(内嵌结构化摘要 + 审核指引)。
322+
323+
Args:
324+
run_id: Run ID
325+
node: 当前节点 ID
326+
output_summary: 旧的纯文本摘要(兼容 fallback)
327+
state: Pipeline state dict(有则生成结构化摘要)
328+
"""
112329
emoji = NODE_EMOJI.get(node, "📌")
113330
lines = [
114331
f"⏸️ **{emoji} {node} 等待审核** `[REVIEW]`",
115332
f"`run_id: {run_id}` · `node: {node}`",
116333
]
117-
if output_summary:
334+
335+
# 结构化摘要(纯 Python 解析产物文件)
336+
if state:
337+
summary = _build_node_summary(run_id, node, state)
338+
if summary:
339+
lines.append("")
340+
for sl in summary.splitlines():
341+
lines.append(sl)
342+
elif output_summary:
118343
lines.append(f"> {output_summary[:300]}")
344+
119345
lines.append("")
120346
lines.append(f"💬 直接回复审核意见 → `contentpipe_chat({run_id})`")
121347
lines.append(f"✅ 说「通过/OK」→ `contentpipe_approve({run_id})`")

scripts/web/routes/api.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1796,11 +1796,10 @@ async def _execute_pipeline(run_id: str):
17961796
state["_node_done"] = True
17971797
_save_state(state)
17981798
emit_review_needed(run_id, node_id, node_id)
1799-
# Discord 通知
1799+
# Discord 通知(含结构化产物摘要)
18001800
try:
18011801
from web.notify import notify_review_needed as _discord_notify
1802-
title = state.get("topic", {}).get("title", "")
1803-
await _discord_notify(run_id, node_id, title[:200])
1802+
await _discord_notify(run_id, node_id, state=state)
18041803
except Exception:
18051804
pass
18061805
return # 暂停,等用户 approve

0 commit comments

Comments
 (0)