|
2 | 2 | ContentPipe — Discord 通知集成 |
3 | 3 |
|
4 | 4 | 通过 OpenClaw Gateway 的 message API 向 Discord 频道推送 Pipeline 事件。 |
| 5 | +审核通知内嵌结构化摘要 + 操作指引,Agent 自动识别进入桥接模式。 |
5 | 6 | """ |
6 | 7 |
|
7 | 8 | from __future__ import annotations |
8 | 9 |
|
9 | 10 | import os |
10 | 11 | import json |
11 | 12 | import httpx |
12 | | -from typing import Optional |
| 13 | +from pathlib import Path |
| 14 | +from typing import Any, Dict, List, Optional |
13 | 15 |
|
14 | 16 | from gateway_auth import build_gateway_headers |
15 | 17 | from logutil import get_logger |
@@ -53,6 +55,209 @@ def _read_config_val(key: str, default: str = "") -> str: |
53 | 55 | } |
54 | 56 |
|
55 | 57 |
|
| 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 | + |
56 | 261 | async def notify_discord( |
57 | 262 | message: str, |
58 | 263 | *, |
@@ -107,15 +312,36 @@ async def notify_node_complete(run_id: str, node: str, title: str = "", summary: |
107 | 312 | await notify_discord(msg, run_id=run_id, node=node) |
108 | 313 |
|
109 | 314 |
|
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 | + """ |
112 | 329 | emoji = NODE_EMOJI.get(node, "📌") |
113 | 330 | lines = [ |
114 | 331 | f"⏸️ **{emoji} {node} 等待审核** `[REVIEW]`", |
115 | 332 | f"`run_id: {run_id}` · `node: {node}`", |
116 | 333 | ] |
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: |
118 | 343 | lines.append(f"> {output_summary[:300]}") |
| 344 | + |
119 | 345 | lines.append("") |
120 | 346 | lines.append(f"💬 直接回复审核意见 → `contentpipe_chat({run_id})`") |
121 | 347 | lines.append(f"✅ 说「通过/OK」→ `contentpipe_approve({run_id})`") |
|
0 commit comments