AI 写作四阶段内置智能体 Phase 1-4 落地

新增 ai-writing-researcher/outliner/writer/editor 种子 agent、_agent_runner 与 use_builtin_agents 灰度开关;四节点可按 flag 切换 SOUL 模式;时间线补大纲规划师配色并更新改造文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
董宏凯andCursor committed 2026-06-11 22:58:38 +08:00
1 parent 1a41ac97d2
commit a4a9a4d26e
21 files changed
+1104 -134

No files matched your search

@@ -194,3 +194,32 @@ result = await run_writing_agent(
**文档**
- 本文件
- Phase 5 收口时更新 `offline-backend-20260512/backend/CLAUDE.md`
---
## 9. 实施记录(2026-06-11,Phase 1-4 已落地)
### 已交付
- **Phase 1**:`_ai_writing_seed_assets/{ai-writing-researcher,ai-writing-outliner,ai-writing-writer,ai-writing-editor}/{config.yaml,SOUL.md}` + `_ai_writing_seed.py::ensure_ai_writing_functional_agents()`,挂启动 lifespan(`_sync_legacy_agents` 之前)+ `ai_writing.list_sessions / list_article_types` 入口自愈。
- **Phase 2**:`nodes/_agent_runner.py`(`stream_writing_agent` / `run_writing_agent` / `parse_marker_json` / `resolve_agent_model_name` / `use_builtin_agent`);四节点按 `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` 二选一(默认全 false,config 按 mtime 热加载,改完即对新会话生效)。agent 目录缺失时节点**静默回退旧 prompt 路径**,写作流程永不因 agent 被删而中断。
- **Phase 3(部分)**:per-agent 模型覆盖已生效(优先级 `agent config.model > 节点显式回退模型(keyword_model/rank_model)> 表单主模型 model_name`);SOUL/模型在智能体管理页可编辑、按 mtime 热加载。researcher「检索能力改挂载技能」未做(仍走节点内 SkillPicker 编排)。
- **Phase 4**:时间线 agent 名由后端 `progress_events.agent_name` 动态下发(flag 开启时读 agent config 的 `name`);`TimelineStep.tsx` 补「大纲规划师」配色(未命中名字回退灰色,admin 改名不报错)。
- **测试**:`tests/test_ai_writing_seed.py`(资源完整性 / 契约 marker / never-overwrite)+ `tests/test_ai_writing_agent_runner.py`(marker 多级解析 / 模型优先级 / flag / 流式端到端 / SOUL 缺失降级),共 31 例。
### 与 §6 契约的修订(以实现为准)
为保住「逐章流式打字机」「review_chunk/review_item_done 分维度卡片」「素材确认/求助暂停」等前端形态,编排控制权全部留在节点,agent 只承担**单次结构化产出**:
| Agent | 实际契约 |
|---|---|
| `ai-writing-researcher` | 两个子任务:`[KEYWORDS_READY]` + `{"keywords": []}`;`[MATERIALS_RANKED]` + `{"ranked_ids": [], "summary": ""}`。素材本体来自节点的检索编排(agent 不联网),`MaterialPackage` 仍由节点组装 |
| `ai-writing-outliner` | `[OUTLINE_READY]` + 完整 `Outline` JSON(新规划 / 修改模式共用,payload 开头标明任务模式) |
| `ai-writing-writer` | **无 JSON marker**:每章直接输出 Markdown 正文(保流式);严格模式素材不足只输出一行 `[[SECTION_BLOCKED]] 原因`(沿用原哨兵) |
| `ai-writing-editor` | 每个维度一次调用:`[REVIEW_READY]` + `{"score": int, "issues": []}`;综合分/verdict/pass_threshold 仍由节点按 config 计算(不信任 LLM 自判通过) |
另一处架构修订:**不在子 thread 上经 lead agent 运行时跑**(§3.2 原设想复用 `intent.py` 机制,但其 loopback 在 app 层,harness 图节点不可 import;且单轮结构化调用无需 thread/checkpointer/中间件成本)。`run_writing_agent` 直接「SOUL 作 system prompt + 单条输入」调模型,规避了 §7.5 的子线程隔离/清理风险。researcher 挂技能(Phase 3 剩余项)如需完整工具体系再评估接入运行时。
### 已知存量测试失败(与本次无关)
`test_ai_writing_graph.py`(mock 已删除的 `intent_parser.llm_json`)、`test_ai_writing_word_count.py`(mock 已删除的 `writer_draft.get_model`)、`test_ai_writing_draft_headings.py`(断言三级结构升级前的 `####→###` 行为)、`test_ai_writing_outline_revise.py`(断言旧 prompt 文案),改造前即失败,待另行清理。
@@ -22,6 +22,8 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
const AGENT_COLOR: Record<string, string> = {
素材收集专家: 'text-blue-500',
作家: 'text-purple-500',
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
大纲规划师: 'text-violet-500',
编辑: 'text-orange-500',
系统: 'text-muted-foreground',
};
+1 -1
View File
@@ -36,7 +36,7 @@ export default defineConfig({
"base":"./",
server: {
// 与 scripts/env.sh FRONTEND_PORT=5174、圆桌 wujie 嵌入文档一致。
port: 5174,
port: 5173,
// 无界宿主跨域 fetch 子应用静态资源时,Vite 需回显请求的 Origin。
cors: true,
},
@@ -254,6 +254,15 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
### AI Writing Built-in Functional Agents (四阶段内置智能体)
The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four stage "brains" — 素材收集专家 / 大纲规划师 / 作家 / 编辑 — that can each run in one of two modes, switched per-stage by `config.yaml → ai_writing.use_builtin_agents.{researcher,outliner,writer,editor}` (default **false**, mtime hot-reloaded, new sessions pick up changes immediately):
- **Legacy (flag off)**: hard-coded prompts in `prompts/*.py`, direct LLM call (unchanged behavior).
- **Builtin-agent (flag on)**: the node loads the corresponding functional agent from `.deer-flow/agents/ai-writing-<stage>/` — `SOUL.md` becomes the system prompt and `config.yaml`'s `model` overrides the stage model (priority: `agent.model > node explicit fallback (keyword_model/rank_model) > state.model_name`). Admins edit SOUL/model from the agent management page. Runner: `nodes/_agent_runner.py` (`run_writing_agent` / `stream_writing_agent` / `parse_marker_json`). Output contracts (marker + ```json``` blocks, intent.py-style): researcher `[KEYWORDS_READY]` / `[MATERIALS_RANKED]`, outliner `[OUTLINE_READY]`, editor (per-dimension) `[REVIEW_READY]`; writer emits raw section Markdown (keeps per-section streaming) with the legacy `[[SECTION_BLOCKED]]` sentinel in strict mode. **Orchestration stays in the nodes** (search fan-out, per-section loop, 3-dimension review loop, all pause points) — the agent only does single structured generations, and a missing/empty SOUL silently falls back to the legacy path (`WritingAgentUnavailable`).
Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets/<id>/`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
The admin 用户排行榜 is served from **precomputed per-day snapshots** in `admin_leaderboard_daily_stats` (keyed by `(stat_date, settings_hash)`), not by aggregating raw rows on every request. A background scheduler inside the Gateway (`_leaderboard_snapshot_scheduler_loop` in `app/gateway/app.py`, singleton file-locked) ticks every `ADMIN_LEADERBOARD_BACKGROUND_INTERVAL_SECONDS` (default 300s): it prewarms recent days, then builds any `queued`/`error`/stale-`running` day. `GET /leaderboard` merges the per-day snapshots for the requested range. **Today** is rebuilt whenever its snapshot is older than `ADMIN_LEADERBOARD_TODAY_TTL_SECONDS` (default 43200s = half a day, so the leaderboard refreshes ~twice daily); a past day, once `ready`, is treated as fresh forever.
@@ -933,6 +933,20 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"Roundtable functional agent seeding failed at startup (non-fatal)"
)
# AI 写作功能型 agent(researcher / outliner / writer / editor)缺失自动补建。
# 同圆桌:必须在 _sync_legacy_agents 之前,补建目录才能在同一次启动被
# upsert 进 agents 表,admin 启动后即可在智能体管理页编辑四个写作 agent。
try:
from app.gateway.routers._ai_writing_seed import (
ensure_ai_writing_functional_agents,
)
ensure_ai_writing_functional_agents()
except Exception:
logger.exception(
"AI-writing functional agent seeding failed at startup (non-fatal)"
)
# 业务链条管理:幂等补建固定业务行(3Q/6BF/7BF/8BF),agent/chain 初始为空。
try:
business_mapping_store = getattr(app.state, "business_mapping_store", None)
@@ -0,0 +1,117 @@
"""AI 写作功能型 agent 的「调用前自检 + 缺失自动补建」工具。
适用场景
--------
``ai-writing-researcher`` / ``ai-writing-outliner`` / ``ai-writing-writer`` /
``ai-writing-editor`` 是 AI 写作四阶段(素材收集 → 大纲 → 写作 → 审核)的
**功能型**内置 agent。当 ``config.yaml`` 的 ``ai_writing.use_builtin_agents.*``
开关打开时,对应的图节点会从 ``.deer-flow/agents/<id>/`` 加载该 agent 的
SOUL.md(人设+结构化输出契约)与 config.yaml(可配 per-阶段模型)。
仓库根 ``.gitignore`` 把整个 ``.deer-flow/`` 目录排除,新机器 ``git clone``
之后这些目录**不会存在**。本模块在两个地方被调用:
1. **启动 lifespan**(``app/gateway/app.py``):服务起来就把缺失目录补齐,
紧接着的 ``_sync_legacy_agents`` 会把它们 upsert 进 DB,新机器一启动
``/api/agents`` 列表里就有这四个 agent,admin 可直接在智能体管理页编辑
SOUL / 模型。
2. **AI 写作路由入口**(``ai_writing.list_sessions`` / ``list_article_types``,
即用户打开写作页必经的两个 GET):防御性兜底——若管理员在启动后手删了
目录,下一次打开写作页也能自愈。
与 ``_roundtable_seed.py`` 完全同构:资源文件
(``_ai_writing_seed_assets/<id>/``)是发布默认值的唯一可信源,seeder 只在
目录缺失时字节级复制,**绝不覆盖**已存在目录(尊重运维/admin 现场手改)。
线上调优 SOUL 后请把改动写回对应资源 .md,否则新机器补建出来的还是旧版。
注意:graph 节点侧(harness 层)对 agent 目录缺失有独立兜底——加载失败时
自动回退到旧的硬编码 prompt 路径,所以 seeder 失败不会打断写作流程。
"""
from __future__ import annotations
import logging
from collections.abc import Iterable
from pathlib import Path
from deerflow.config.paths import get_paths
logger = logging.getLogger(__name__)
RESEARCHER_AGENT_ID = "ai-writing-researcher"
OUTLINER_AGENT_ID = "ai-writing-outliner"
WRITER_AGENT_ID = "ai-writing-writer"
EDITOR_AGENT_ID = "ai-writing-editor"
# 验证版 SOUL.md + config.yaml 的源码资源目录(随 git 发布)。每个子目录名即 agent id。
_ASSETS_DIR = Path(__file__).parent / "_ai_writing_seed_assets"
# 四个功能型内置 agent,任何部署都应存在;顺序无关紧要(逐个独立补建)。
_FUNCTIONAL_AGENT_IDS: tuple[str, ...] = (
RESEARCHER_AGENT_ID,
OUTLINER_AGENT_ID,
WRITER_AGENT_ID,
EDITOR_AGENT_ID,
)
# seeder 会复制资源目录里的这些文件(存在即复制,缺失则跳过)。
_SEED_FILES: tuple[str, ...] = ("config.yaml", "SOUL.md")
def _ensure_one(agent_id: str) -> bool:
"""Copy the verified SOUL.md + config.yaml for one functional agent into
``.deer-flow/agents/<id>/`` if that directory is missing.
Returns True if the files were just created, False if the directory already
existed (we never overwrite — respect any local edits) or the seed assets
are missing.
"""
agent_dir = get_paths().agent_dir(agent_id)
if agent_dir.exists():
return False
src_dir = _ASSETS_DIR / agent_id
if not src_dir.is_dir():
# Assets should always ship with the code; if they don't, surface a loud
# log instead of silently creating an empty agent dir.
logger.error(
"Missing seed assets for ai-writing agent '%s' (expected at %s)",
agent_id,
src_dir,
)
return False
agent_dir.mkdir(parents=True, exist_ok=True)
for fname in _SEED_FILES:
src = src_dir / fname
if src.exists():
# 字节级复制,保留验证版文件原样(换行/编码),与资源文件 hash 对齐。
(agent_dir / fname).write_bytes(src.read_bytes())
return True
def ensure_ai_writing_functional_agents() -> list[str]:
"""Seed the four ai-writing functional agents if their on-disk directories
are missing. Idempotent — fast no-op when all exist.
Returns the list of agent ids that were just created (empty when nothing
needed seeding). Errors are logged but never raised — a partial failure
must not block the request; the graph node falls back to the legacy
hard-coded prompt path when the agent really cannot be loaded.
"""
created: list[str] = []
for agent_id in _FUNCTIONAL_AGENT_IDS:
try:
if _ensure_one(agent_id):
created.append(agent_id)
except Exception:
logger.exception("Failed to seed ai-writing functional agent '%s'", agent_id)
if created:
logger.info("Seeded missing ai-writing functional agents: %s", ", ".join(created))
return created
def required_agent_ids() -> Iterable[str]:
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics."""
return tuple(_FUNCTIONAL_AGENT_IDS)
@@ -0,0 +1,42 @@
# 编辑
你是 AI 写作流水线的「编辑」,一名资深主编,标准极高,要求严格。你在一家权威媒体担任主编十五年,见过各种质量的文章。
系统每次派给你**一个审查维度**(输入开头会写明:事实准确性 / 逻辑结构 / 语言文字),你只从该维度审查文章,给出该维度的评分(0-100 整数)和具体问题列表。你不能改写文章,只做当前指定维度的审查。综合评分与是否通过由系统汇总判定,不归你管。
---
## 0. 硬规则(最高优先级)
1. **只审当前维度**:输入指定哪个维度就只审哪个维度,不要跨维度挑问题。
2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。
3. **JSON 必须严格合法**:半角双引号 `"`,不要尾随逗号、不要注释。`score` 是 0-100 的整数;每个问题的 `severity` 必须是 `"major"`(必须修改)或 `"minor"`(建议修改);该维度没有问题时 `issues` 填空数组 `[]`。
4. **意见要具体可执行**:`location` 指明位置(如"第二段"),`issue` 说清问题,`suggestion` 给出可操作的修改建议。
---
## 1. 结束协议:`[REVIEW_READY]`
审查完成后,**严格**按以下结构输出,前后不得有其它内容(`[REVIEW_READY]` 单独成行):
```
[REVIEW_READY]
```json
{
"score": 82,
"issues": [
{"severity": "major", "location": "第二段", "issue": "引用的增长数据没有素材支撑", "suggestion": "补充素材 m002 中的具体数据,并注明来源"},
{"severity": "minor", "location": "结尾段", "issue": "结论略显仓促", "suggestion": "增加一句对未来行业趋势的判断"}
]
}
```
```
---
## 2. 反例(请认真避开)
❌ 先输出"这篇文章整体质量不错,下面是我的审查意见:"再给 JSON —— 直接输出 marker + JSON。
❌ marker 后追加总结陈词 —— ```json``` 闭合后什么都不要写。
❌ `severity` 写成 `"high"` / `"严重"` —— 只允许 `"major"` / `"minor"`。
❌ 审「语言文字」维度时挑"数据缺乏素材支撑"的事实性问题 —— 只审当前维度。
@@ -0,0 +1,3 @@
description: AI 写作流程的编辑,从事实/逻辑/语言三个维度逐项审查文章质量,给出评分与具体修改意见
id: ai-writing-editor
name: 编辑
@@ -0,0 +1,80 @@
# 大纲规划师
你是 AI 写作流水线的「大纲规划师」。你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。
你只做一件事:**根据素材和写作意图,产出/调整文章的三级大纲**(章节 → 小节 → 子要点)。你不写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。
系统派给你的任务有两种模式(输入开头会写明):
- **新规划**:根据写作意图 + 素材,从零规划 3-5 个章节的大纲
- **修改模式**:输入里带「当前大纲」JSON + 用户修改要求 → 在当前大纲基础上调整
---
## 0. 硬规则(最高优先级)
1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止 `<think></think>` 标签及其中内容;禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲")——无论素材是否充分,都必须直接产出大纲。
2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。
3. **JSON 必须严格合法**:半角双引号 `"`,不要尾随逗号、不要注释;`material_ids` 中的 ID 必须来自给定素材,没有合适素材时填 `[]`。
4. **修改模式只改用户明确提到的部分**:用户没要求改动的章节、小节、子要点必须原样保留(标题、内容、material_ids 全部不变);章节/小节数量完全由用户要求决定,要加就加、要减就减。
---
## 1. 三级层级语义(决定文章结构)
- Level 1 = `section_title`(章节):渲染为 `##` 二级标题,统领一组内容。高度概括的短语,6-15 字。每篇 3-5 个章节(修改模式数量听用户的)。
- Level 2 = `point_title`(小节):渲染为 `###` 三级标题,下面会有一段 2-3 句的概述正文。名词性短语,15 字以内,不带句末标点。每章节 2-4 个小节。
- Level 3 = `sub_points`(子要点):渲染为 `####` 四级标题,**每个子要点都会展开成一段 3-5 句的独立正文**——不是 bullet 列表,所以要规划成「能独立成段展开」的小主题。名词性短语,≤15 字,不带句末标点。每小节 2-4 条;信息确实少到无法拆分时可留 `[]`。
兼容历史大纲:若「当前大纲」里的 `key_points` 是字符串数组(旧版 2 级结构),保留原值视为小节标题,输出时补成 `{"point_title": "<原 string>", "sub_points": []}`。
---
## 2. 结束协议:`[OUTLINE_READY]`
完成规划后,**严格**按以下结构输出,前后不得有其它内容(`[OUTLINE_READY]` 单独成行):
```
[OUTLINE_READY]
```json
{
"title": "新能源汽车出口为何高速增长",
"sections": [
{
"section_title": "引言:出口数据创新高",
"key_points": [
{"point_title": "出口量同比增幅", "sub_points": ["2024 年累计出口规模", "同比增速对比 2023"]},
{"point_title": "全球市场排名变化", "sub_points": ["对欧出口跃升", "对东盟首次反超日韩"]}
],
"material_ids": ["m001"]
},
{
"section_title": "增长背后的三大动因",
"key_points": [
{"point_title": "产业链成本优势", "sub_points": ["电池原料端议价能力", "整车规模效应"]},
{"point_title": "海外政策利好", "sub_points": ["欧盟碳关税倒逼", "中东购车补贴"]}
],
"material_ids": ["m002", "m003"]
},
{
"section_title": "结论与展望",
"key_points": [
{"point_title": "未来增长挑战", "sub_points": ["欧盟反补贴调查", "海外渠道建设成本"]},
{"point_title": "行业趋势判断", "sub_points": []}
],
"material_ids": ["m004"]
}
]
}
```
```
---
## 3. 反例(请认真避开)
❌ 先输出"好的,我来规划大纲:"再给 JSON —— 直接输出 marker + JSON。
❌ marker 后追加"以上大纲覆盖了……如需调整请告诉我" —— ```json``` 闭合后什么都不要写。
❌ 修改模式下重写了用户没提到的章节标题。
❌ `material_ids` 里出现素材清单中不存在的编号。
❌ 把子要点写成完整句子(如"我们应该分析出口数据的变化趋势。")——应为名词性短语。
@@ -0,0 +1,3 @@
description: AI 写作流程的大纲规划师,根据写作意图与素材规划文章三级大纲(章节→小节→子要点),并按用户反馈调整已有大纲
id: ai-writing-outliner
name: 大纲规划师
@@ -0,0 +1,66 @@
# 素材收集专家
你是 AI 写作流水线的「素材收集专家」。你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。
实际的网络/技能检索由系统执行,**你不联网**。你只承担两类结构化子任务,系统每次只派给你其中一类(输入开头会写明「任务:XXX」):
- **任务 A · 提取检索词**:从写作意图中提炼指定数量的搜索关键词
- **任务 B · 素材排序**:从已检索到的素材列表中,选出与写作意图最相关的素材 ID 并按相关度排序
你不写文章、不评价内容好坏、不输出任何与当前任务无关的内容。
---
## 0. 硬规则(最高优先级)
1. **严禁思考与表态**:这是简单的结构化抽取任务。禁止输出任何思考、分析、推理或规划过程,禁止 `<think></think>` 标签及其中内容,禁止"让我想想""首先"之类过渡语。收到输入后立即输出结果。
2. **marker 后立刻闭嘴**:```json``` 代码块的反引号闭合之后,禁止再输出任何一个字符。
3. **JSON 必须严格合法**:引号一律半角双引号 `"`(不要中文引号),不要尾随逗号,不要注释,后端用程序解析,任何变形都会导致降级。
4. **不要照抄占位符**:示例里的 `"..."`、`关键词1` 等都是占位示意,必须输出依据真实输入提炼的内容。
---
## 1. 任务 A · 提取检索词
输入会给出:写作意图、文章类型、目标读者、要求的关键词数量 N。
要求:
- 关键词必须具体、可搜索,避免过于宽泛的词。
- 数量必须正好 N 个,不多不少。
- 关键词依据写作意图提炼,贴合文章类型与目标读者。
输出格式(严格遵守,`[KEYWORDS_READY]` 单独成行):
```
[KEYWORDS_READY]
```json
{"keywords": ["新能源汽车出口", "汽车出口数据", "新能源汽车海外市场"]}
```
```
## 2. 任务 B · 素材排序
输入会给出:写作意图、素材列表(每条含 ID 和摘要)。
要求:
- 选出与写作意图最相关的素材 ID(最多 8 条),按相关度从高到低排序。
- `ranked_ids` 中的 ID **必须来自素材列表**,绝不编造 ID。
- `summary` 一句话概括检索结果(共搜索到 X 条,精选 Y 条)。
输出格式(严格遵守,`[MATERIALS_RANKED]` 单独成行):
```
[MATERIALS_RANKED]
```json
{"ranked_ids": ["m003", "m001", "m005"], "summary": "共搜索到 8 条素材,精选 3 条最相关的"}
```
```
---
## 3. 反例(请认真避开)
❌ 输出思考过程:"让我分析一下这个写作意图……" —— 直接输出 marker + JSON。
❌ marker 后追加说明:"以上关键词覆盖了……" —— ```json``` 闭合后什么都不要写。
❌ 编造素材 ID:`ranked_ids` 里出现列表中不存在的 `m099`。
❌ 照抄占位符:输出 `{"keywords": ["关键词1", "关键词2"]}`。
@@ -0,0 +1,3 @@
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、并对检索回来的素材按相关度排序筛选
id: ai-writing-researcher
name: 素材收集专家
@@ -0,0 +1,49 @@
# 作家
你是 AI 写作流水线的「作家」。你有十年内容创作经验,文字流畅,善于用事实说话。
系统按大纲**逐章**派活给你:每次输入一个章节(含文章整体结构、当前章节的小节/子要点、相关素材、写作模式),你写出**这一个章节**的正文。你不能修改大纲,不能写其他章节,只写当前这一段。
- 优先采用素材中的事实与数据;素材不足时按输入里的「写作模式」处理(宽松=可结合通用知识,严格=只准用素材)。
- 不要脱离主题杜撰具体的数字、人名、机构名与日期。
---
## 0. 硬规则(最高优先级)
1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止"让我想想""首先""我需要""根据写作要求"之类过渡语,禁止 `<think></think>` 标签及其中内容。
2. **绝不拒绝、绝不元话语**:禁止输出任何对用户说的话或对任务本身的评价,例如"我无法完成此任务""您提供的素材无法支撑写作""建议您提供……素材"——这类话绝不允许出现在文章里。无论素材是否充分,都按「写作模式」的规则直接产出。
3. **直接输出 Markdown 正文**:不要 JSON、不要解释、不要"以下是正文"之类开场白。收到输入后立即开始写正文(含 `###` 小标题)。
4. **严格模式的唯一例外**:输入标明「写作模式:严格依据素材」且素材确实不足以支撑本章节时,**只输出一行**:`[[SECTION_BLOCKED]] 一句话说明缺什么素材`(例:`[[SECTION_BLOCKED]] 缺少关于XX的具体数据与案例`),此外不输出任何内容。只要素材能支撑哪怕部分要点,就正常写作,不要输出该标记。
---
## 1. 结构要求(三级结构,必须严格遵守)
「章节要点」用层级 bullet 列表给出:
- 主要 bullet(顶格)= 小节标题,对应 Markdown `###` 三级标题
- 缩进 bullet(带前导空格)= 该小节下的子要点,对应 Markdown `####` 四级标题
**每个小节、每个子要点都要有自己的正文段,分两层展开**:
1. `### 小节标题` 下先写一段「概述/引入正文」(2-3 句),说明本小节要讨论什么、几个子要点之间的关系。
2. 然后对每个子要点:`#### 子要点标题` 单起一行,再展开 3-5 句具体论述(结合素材中的事实/数据)。
补充规则:
- 若小节**没有子要点**(缩进 bullet 为空):在 `### 小节标题` 下直接连贯成段论述,**不出现 ####**。
- 即使章节只有一个小节,也要为它写出对应的 `###` 标题。
- 标题层级严格对应:文章 `#` / 章节 `##` 由系统注入,**你不要输出这两级**;你只输出 `###` 小节与 `####` 子要点。
- 不要输出 `#####` 或更深。
- 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。
- 充分展开每一层,写满输入要求的篇幅。
---
## 2. 反例(请认真避开)
❌ 开头写"好的,下面是「XX」章节的内容:" —— 直接从 `###` 或正文开始。
❌ 输出 `## 章节标题` —— 章节标题由系统注入,你最高只到 `###`。
❌ 严格模式素材不足时硬凑无关内容、或写"素材与意图不匹配" —— 只输出一行 `[[SECTION_BLOCKED]] 原因`。
❌ 宽松模式下输出"我无法基于现有素材完成" —— 宽松模式永远正常写作,可用通用知识补充。
@@ -0,0 +1,3 @@
description: AI 写作流程的作家,根据大纲与素材逐章撰写文章正文(三级结构 Markdown),严格模式下素材不足时声明阻塞而不杜撰
id: ai-writing-writer
name: 作家
@@ -110,6 +110,11 @@ class SessionResponse(BaseModel):
async def list_sessions(request: Request):
"""列出当前用户的 AI 写作历史会话。"""
from app.gateway.deps import get_ai_writing_session_store, get_current_user
from app.gateway.routers._ai_writing_seed import ensure_ai_writing_functional_agents
# 调用前自检:打开写作页必经此接口。若管理员在启动后手删了内置写作 agent
# 目录,这里自愈补建,保证 use_builtin_agents 开启时图节点能加载到 SOUL。
ensure_ai_writing_functional_agents()
repo = get_ai_writing_session_store(request)
if repo is None:
@@ -190,6 +195,10 @@ async def save_transcript(session_id: str, body: SaveTranscriptRequest, request:
async def list_article_types(request: Request):
"""列出所有文章类型,按 sort_order 升序排列。"""
from app.gateway.deps import get_article_type_store
from app.gateway.routers._ai_writing_seed import ensure_ai_writing_functional_agents
# 防御性兜底,与 list_sessions 同理(写作表单加载文章类型时也会经过这里)。
ensure_ai_writing_functional_agents()
store = get_article_type_store(request)
if store is None:
@@ -378,6 +378,15 @@ ai_writing:
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
max_materials: 20 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
# 四阶段「内置功能型智能体」开关(按阶段灰度)。true = 该阶段节点改为加载
# .deer-flow/agents/ai-writing-<stage>/ 的 SOUL.md + config(admin 可在智能体
# 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。
# 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。
use_builtin_agents:
researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务)
outliner: false # 大纲规划师(大纲生成 / 按反馈调整)
writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变)
editor: false # 编辑(事实/逻辑/语言三维度审查)
log_system:
enabled: true
@@ -0,0 +1,269 @@
"""AI 写作「内置功能型智能体」运行助手。
四个写作阶段(素材收集/大纲/写作/审核)的图节点在 ``config.yaml →
ai_writing.use_builtin_agents.<stage>`` 打开时,不再使用 ``prompts/*.py`` 的
硬编码提示词直调 LLM,而是改为:
1. 从 ``.deer-flow/agents/<agent-id>/`` 加载该内置 agent 的 **SOUL.md**
(人设 + 工作流 + 结构化输出契约)作为 system prompt;
2. 模型按 ``agent config.yaml 的 model > 节点显式回退模型 > state.model_name``
的优先级解析(admin 可在智能体管理页给某阶段单独配模型);
3. 流式调用该模型,chunk 通过调用方传入的回调透传到 LangGraph ``custom``
通道(前端时间线打字机不回归);
4. 按 SOUL 规定的 marker(如 ``[OUTLINE_READY]``)+ ```json``` 块解析结构化
输出,解析复用 ``_utils.parse_json`` 多级容错。
设计取舍(对应 ai-writing-内置智能体改造.md §3.2 / §7):
- **编排控制权留在节点**:writer 的逐章循环、researcher 的检索编排、editor 的
三维度循环都不交给 agent——agent 只承担「单次结构化产出」,这样干预点/逐章
流式/严格模式求助逻辑全部原样保留。
- **不经过 lead agent 运行时**:本模块位于 harness 层,不能 import ``app.*``
(intent.py 的 loopback 机制在 app 层),也无需为单轮结构化调用付出
thread/checkpointer/中间件链的成本。SOUL + per-agent 模型已覆盖本场景的
可配置性诉求;researcher 的「挂载技能」(Phase 3)再考虑接入完整运行时。
- **agent 目录缺失时抛 ``WritingAgentUnavailable``**:由节点捕获后回退旧的
硬编码 prompt 路径,种子器(app 层 ``_ai_writing_seed.py``)失败不阻断写作。
"""
from __future__ import annotations
import logging
import re
from collections.abc import Callable
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from ._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta
logger = logging.getLogger(__name__)
# ── 内置写作 agent 的稳定 id(与 app 层种子器 _ai_writing_seed.py 对齐)──────────
RESEARCHER_AGENT_ID = "ai-writing-researcher"
OUTLINER_AGENT_ID = "ai-writing-outliner"
WRITER_AGENT_ID = "ai-writing-writer"
EDITOR_AGENT_ID = "ai-writing-editor"
# ── SOUL 规定的结构化输出 marker(契约见各 agent 的 SOUL.md)─────────────────────
KEYWORDS_READY_MARKER = "[KEYWORDS_READY]"
MATERIALS_RANKED_MARKER = "[MATERIALS_RANKED]"
OUTLINE_READY_MARKER = "[OUTLINE_READY]"
REVIEW_READY_MARKER = "[REVIEW_READY]"
# flag 名(config.yaml ai_writing.use_builtin_agents.<stage>)→ agent id
STAGE_AGENT_IDS = {
"researcher": RESEARCHER_AGENT_ID,
"outliner": OUTLINER_AGENT_ID,
"writer": WRITER_AGENT_ID,
"editor": EDITOR_AGENT_ID,
}
class WritingAgentUnavailable(RuntimeError):
"""内置写作 agent 的 SOUL.md 缺失/为空,无法按 agent 模式运行。
节点捕获后应回退到旧的硬编码 prompt 路径(等价于 flag 关闭),不让写作
流程因 agent 目录被删而中断。
"""
def use_builtin_agent(stage: str) -> bool:
"""读 ``config.yaml → ai_writing.use_builtin_agents.<stage>`` 开关。
缺失/非法一律视为 False(默认走旧节点逻辑)。config 带 mtime 热加载,
改完 config.yaml 新会话即生效。
"""
from ._utils import get_ai_writing_config
flags = get_ai_writing_config().get("use_builtin_agents")
if not isinstance(flags, dict):
return False
return bool(flags.get(stage, False))
def load_writing_agent(agent_id: str):
"""加载内置写作 agent 的 (config, soul);SOUL 缺失抛 WritingAgentUnavailable。"""
from deerflow.config.agents_config import load_agent_config, load_agent_soul
soul = load_agent_soul(agent_id)
if not soul:
raise WritingAgentUnavailable(
f"内置写作智能体 '{agent_id}' 的 SOUL.md 缺失或为空"
"(目录可能被删除,重启 Gateway 或重新打开写作页可自动补建)"
)
try:
agent_cfg = load_agent_config(agent_id)
except ValueError:
# config.yaml 解析失败不致命:SOUL 仍可用,模型回退节点/表单选择。
logger.warning("load_writing_agent: config.yaml of '%s' is invalid, ignoring", agent_id)
agent_cfg = None
return agent_cfg, soul
def agent_display_name(agent_id: str, default: str) -> str:
"""取 agent 的展示名(progress_events.agent_name 用),加载失败回退默认值。"""
try:
from deerflow.config.agents_config import load_agent_config
cfg = load_agent_config(agent_id)
name = (cfg.name or "").strip() if cfg else ""
return name or default
except Exception:
return default
def resolve_agent_model_name(
agent_cfg,
state: dict,
config: RunnableConfig,
*,
explicit_fallback: str | None = None,
strict: bool = True,
) -> str | None:
"""解析本次 agent 调用使用的模型名。
优先级:agent config.yaml 的 ``model`` > ``explicit_fallback``(节点显式
传入,如 researcher 的 keyword_model/rank_model)> ``state.model_name``
(用户写作表单选的主模型)> configurable.model_name。
strict=True(大纲/写作/审核):全部为空抛 ValueError(与 get_writing_model
口径一致——不静默回退默认模型);strict=False(researcher 辅助任务):
返回 None,由 create_chat_model 回退 config.yaml 的 models[0]。
"""
candidates = (
(getattr(agent_cfg, "model", None) or "") if agent_cfg else "",
explicit_fallback or "",
(state or {}).get("model_name") or "",
(config.get("configurable", {}) or {}).get("model_name") or "",
)
for name in candidates:
if isinstance(name, str) and name.strip():
return name.strip()
if strict:
raise ValueError("未指定写作模型(model_name 为空):请在写作表单选择模型后重试")
return None
def parse_marker_json(text: str, marker: str, fallback: dict) -> dict:
"""从 agent 输出中按「marker + ```json``` 块」契约解析 JSON,多级降级。
1. ``marker\\n```json {...} ``` ``(SOUL 规定的标准形态,正则仿
intent.py::_INTENT_READY_PATTERN);
2. marker 存在但没包代码块 → 取 marker 之后的全部文本交给 parse_json;
3. marker 缺失(弱模型漏输出)→ 整段文本交给 parse_json;
4. 全部失败 → fallback。
"""
if not text or not text.strip():
return fallback
pattern = re.compile(re.escape(marker) + r"\s*```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL)
m = pattern.search(text)
if m:
result = parse_json(m.group(1), fallback)
if result is not fallback:
return result
idx = text.find(marker)
if idx != -1:
tail = text[idx + len(marker):]
result = parse_json(tail, fallback)
if result is not fallback:
return result
return parse_json(text, fallback)
async def stream_writing_agent(
*,
agent_id: str,
user_payload: str,
state: dict,
config: RunnableConfig,
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
) -> str:
"""在「SOUL 作 system prompt + 单条 Human 输入」契约下流式运行一个写作 agent。
Args:
agent_id: 内置写作 agent id(STAGE_AGENT_IDS 的值之一)。
user_payload: 本阶段输入(意图/素材/大纲/草稿等拼成的一段文本)。
on_text: 每个流式片段回调 ``(text, is_thinking)``,调用方在回调里把
chunk 包装成本阶段的 custom 事件(outline_chunk / draft_chunk /
review_chunk ...)推给前端;None = 不透传(如 researcher 的辅助任务)。
explicit_model_fallback / strict_model: 见 resolve_agent_model_name。
Returns:
累积的**正文**文本(<think> 思考与 reasoning_content 已剥离),供
parse_marker_json / 章节拼装使用。
Raises:
WritingAgentUnavailable: SOUL 缺失(调用方回退旧 prompt 路径)。
其余模型异常原样上抛,由调用方按既有 try/except 兜底成 status:error。
"""
agent_cfg, soul = load_writing_agent(agent_id)
model_name = resolve_agent_model_name(
agent_cfg, state, config,
explicit_fallback=explicit_model_fallback,
strict=strict_model,
)
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name)
full_text = ""
parser = ThinkTagParser()
async for chunk in model.astream([
SystemMessage(content=soul),
HumanMessage(content=user_payload),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning and on_text is not None:
on_text(reasoning, True)
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
if on_text is not None:
on_text(text, is_thinking)
return full_text
async def run_writing_agent(
*,
agent_id: str,
user_payload: str,
marker: str,
fallback: dict,
state: dict,
config: RunnableConfig,
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
) -> dict:
"""跑一个写作 agent 并按 marker 契约解析其结构化输出(失败回 fallback)。
模型调用异常时与 ``_utils.llm_json`` 同口径:返回
``{**fallback, "_llm_error": str(e)}``,不上抛(SOUL 缺失除外——
WritingAgentUnavailable 原样上抛,由节点切回旧 prompt 路径)。
"""
try:
full_text = await stream_writing_agent(
agent_id=agent_id,
user_payload=user_payload,
state=state,
config=config,
on_text=on_text,
explicit_model_fallback=explicit_model_fallback,
strict_model=strict_model,
)
except WritingAgentUnavailable:
raise
except Exception as e:
logger.error("run_writing_agent(%s) failed: %s", agent_id, e, exc_info=True)
return {**fallback, "_llm_error": str(e)}
return parse_marker_json(full_text, marker, fallback)
@@ -11,6 +11,16 @@ from ..state import AIWritingState, ReviewResult, ReviewIssue
from ..prompts.editor_prompts import (
EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER,
)
from ._agent_runner import (
EDITOR_AGENT_ID,
REVIEW_READY_MARKER,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
resolve_agent_model_name,
run_writing_agent,
use_builtin_agent,
)
from ._utils import get_ai_writing_config, get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -119,19 +129,45 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
("language", LANGUAGE_REVIEW_USER.format(article=article)),
]
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("editor model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "编辑",
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.editor):
# 三维度循环仍由节点编排(review_chunk / review_item_done 流式形态不变),
# 每个维度跑一次 ai-writing-editor agent(SOUL 作 system prompt,输出
# [REVIEW_READY] + {"score","issues"})。agent 目录缺失则静默回退旧路径。
use_agent = use_builtin_agent("editor")
agent_label = "编辑"
model = None
if use_agent:
try:
agent_cfg, _soul = load_writing_agent(EDITOR_AGENT_ID)
# 模型守卫提前到循环外:模型名缺失/配错时与旧路径同样转成 status:error
resolve_agent_model_name(agent_cfg, state, config)
agent_label = agent_display_name(EDITOR_AGENT_ID, "编辑")
except WritingAgentUnavailable as e:
logger.warning("editor builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
except Exception as e:
logger.error("editor agent model resolution failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": agent_label,
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
if not use_agent:
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("editor model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "编辑",
"title": "模型错误", "detail": str(e), "message": "审核失败",
}],
}
results: dict[str, dict] = {}
review_errors: list[str] = [] # 分项审查的模型异常,供完成后非致命提示
@@ -140,8 +176,24 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
for category, user_prompt in REVIEW_TASKS:
fallback = {"score": 80, "issues": []}
try:
full_text = await _stream_one_review(model, user_prompt, category, writer)
res = parse_json(full_text, fallback)
if use_agent:
def _on_text(text: str, is_thinking: bool, _cat: str = category) -> None:
writer({"type": "review_chunk", "category": _cat, "text": text, "is_thinking": is_thinking})
res = await run_writing_agent(
agent_id=EDITOR_AGENT_ID,
user_payload=user_prompt,
marker=REVIEW_READY_MARKER,
fallback=fallback,
state=state,
config=config,
on_text=_on_text,
)
if res.get("_llm_error"):
review_errors.append(f"「{_CATEGORY_LABEL.get(category, category)}审查」{res['_llm_error']}")
else:
full_text = await _stream_one_review(model, user_prompt, category, writer)
res = parse_json(full_text, fallback)
except Exception as e:
logger.warning("editor review [%s] failed: %s", category, e)
res = fallback
@@ -191,7 +243,7 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
if review_errors:
notices.append({
"type": "notice",
"agent_name": "编辑",
"agent_name": agent_label,
"title": "模型错误",
"detail": "部分审查项调用失败,已按默认分兜底。原因:" + ";".join(review_errors[:3]),
})
@@ -199,11 +251,11 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict:
return {
"review_result": review,
"progress_events": [
{"type": "step_start", "agent_name": "编辑", "message": "开始审核"},
{"type": "step_start", "agent_name": agent_label, "message": "开始审核"},
*notices,
{
"type": "review_ready",
"agent_name": "编辑",
"agent_name": agent_label,
"message": f"审核完成,综合得分 {overall_score},结论:{'通过' if verdict == 'pass' else '打回修改'}",
},
],
@@ -18,10 +18,46 @@ from ..prompts.researcher_prompts import (
EXTRACT_KEYWORDS_SYSTEM, EXTRACT_KEYWORDS_USER,
RANK_MATERIALS_SYSTEM, RANK_MATERIALS_USER,
)
from ._agent_runner import (
KEYWORDS_READY_MARKER,
MATERIALS_RANKED_MARKER,
RESEARCHER_AGENT_ID,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
run_writing_agent,
use_builtin_agent,
)
from ._utils import get_ai_writing_config, llm_json, safe_get_stream_writer
logger = logging.getLogger(__name__)
# ── 内置 agent 模式的子任务 payload(SOUL 已含人设与输出契约,这里只给事实输入)──
_AGENT_KEYWORDS_USER = """任务:提取检索词
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。
关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图提炼真实检索词。
严格按 SOUL 约定输出 [KEYWORDS_READY] + JSON。
/no_think"""
_AGENT_RANK_USER = """任务:素材排序
写作意图:{user_intent}
素材列表(每条含ID和摘要):
{materials_list}
请选出最相关的素材ID(最多8条),按相关度从高到低排序。
严格按 SOUL 约定输出 [MATERIALS_RANKED] + JSON。
/no_think"""
_NOTEBOOK_SEARCH_PATH = "/api/v1/source/mentioned-documents/search"
_NOTEBOOK_MAX_KEYWORDS = 3 # 每次写作最多对笔记发起的检索轮数
_NOTEBOOK_TOP_K = 5 # 每次检索取回的 chunk 数
@@ -89,12 +125,12 @@ def _per_keyword_limit(max_materials: int, total_keywords: int) -> int:
return (max_materials + total_keywords - 1) // total_keywords
def _notice(title: str, detail: str, raw: str = "") -> dict:
def _notice(title: str, detail: str, raw: str = "", agent_name: str = "素材收集专家") -> dict:
"""构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。
raw 非空时附带接口的原始错误响应体,前端默认折叠展示。
"""
event = {"type": "notice", "agent_name": "素材收集专家", "title": title, "detail": detail}
event = {"type": "notice", "agent_name": agent_name, "title": title, "detail": detail}
if raw:
event["raw"] = raw
return event
@@ -254,6 +290,20 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 素材总量上限(config.yaml: ai_writing.max_materials),按检索词数均分到每个词
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.researcher):
# 检索编排(并发搜索 / 技能编排 / 笔记检索)仍由节点控制,只把两个 LLM 子任务
# (提取检索词 / 素材排序)换成跑 ai-writing-researcher agent(SOUL 可被 admin
# 编辑;模型优先级 agent.model > keyword_model/rank_model > 表单主模型)。
use_agent = use_builtin_agent("researcher")
researcher_label = "素材收集专家"
if use_agent:
try:
load_writing_agent(RESEARCHER_AGENT_ID)
researcher_label = agent_display_name(RESEARCHER_AGENT_ID, "素材收集专家")
except WritingAgentUnavailable as e:
logger.warning("researcher builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
def _emit(message: str) -> None:
writer({"type": "research_progress", "message": message})
@@ -272,18 +322,35 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
_emit(f"追加检索 {len(keywords)} 个关键词…")
else:
_emit("正在生成检索词…")
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
)
if use_agent:
kw_result = await run_writing_agent(
agent_id=RESEARCHER_AGENT_ID,
user_payload=_AGENT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
marker=KEYWORDS_READY_MARKER,
fallback={"keywords": [intent]},
state=state,
config=config,
explicit_model_fallback=keyword_model,
strict_model=False,
)
else:
kw_result = await llm_json(
EXTRACT_KEYWORDS_SYSTEM,
EXTRACT_KEYWORDS_USER.format(
user_intent=intent,
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords = _clean_keywords(kw_result.get("keywords"), intent)
if extra_keywords:
@@ -321,7 +388,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 与前端 [ai-writing:custom] 日志对齐,钉死是否实时推送。
logger.info("[skill-timing] t=%.3f emit skill_search_step type=%s skill=%s",
time.monotonic(), step.get("type"), step.get("skill_name") or "")
writer({"type": "skill_search_step", "agent_name": "素材收集专家", **step})
writer({"type": "skill_search_step", "agent_name": researcher_label, **step})
if is_skill_search:
# ── 技能编排检索 ──
@@ -509,13 +576,26 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
f"- ID: {m['id']} 标题: {m['title']} 摘要: {m['content'][:100]}"
for m in raw_materials
)
rank_result = await llm_json(
RANK_MATERIALS_SYSTEM,
RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list),
fallback={"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"},
config=config,
model_name=rank_model,
)
rank_fallback = {"ranked_ids": [m["id"] for m in raw_materials[:max_materials]], "summary": f"共搜索到{len(raw_materials)}条素材"}
if use_agent:
rank_result = await run_writing_agent(
agent_id=RESEARCHER_AGENT_ID,
user_payload=_AGENT_RANK_USER.format(user_intent=intent, materials_list=materials_list),
marker=MATERIALS_RANKED_MARKER,
fallback=rank_fallback,
state=state,
config=config,
explicit_model_fallback=rank_model,
strict_model=False,
)
else:
rank_result = await llm_json(
RANK_MATERIALS_SYSTEM,
RANK_MATERIALS_USER.format(user_intent=intent, materials_list=materials_list),
fallback=rank_fallback,
config=config,
model_name=rank_model,
)
rank_error = rank_result.get("_llm_error", "")
ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials])
id_to_mat = {m["id"]: m for m in raw_materials}
@@ -554,9 +634,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 容错告警:检索接口 / 模型调用出错时以非致命 notice 提示用户,不打断写作流程
notices: list[dict] = []
if kw_result.get("_llm_error"):
notices.append(_notice("模型错误", f"检索词生成失败,已用默认关键词兜底。原因:{kw_result['_llm_error']}"))
notices.append(_notice("模型错误", f"检索词生成失败,已用默认关键词兜底。原因:{kw_result['_llm_error']}", agent_name=researcher_label))
if rank_error:
notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}"))
notices.append(_notice("模型错误", f"素材排序失败,已按相关度兜底排序。原因:{rank_error}", agent_name=researcher_label))
if search_errors:
uniq = list(dict.fromkeys(search_errors))
# 解析失败时收集到的接口原始响应体,前端折叠展示便于定位
@@ -567,6 +647,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"ES 波动导致检索异常",
";".join(uniq[:3]),
raw="\n\n----------\n\n".join(raw_dumps[:3]),
agent_name=researcher_label,
))
# 进度事件文案:用户输入 / 追加模式 / 技能模式下让用户看到差异化提示
@@ -583,8 +664,8 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
"status": "writing_draft" if is_supplement else "awaiting_material_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "素材收集专家", "message": step_start_msg},
{"type": "step_start", "agent_name": researcher_label, "message": step_start_msg},
*notices,
{"type": "materials_ready", "agent_name": "素材收集专家", "message": summary},
{"type": "materials_ready", "agent_name": researcher_label, "message": summary},
],
}
@@ -12,6 +12,15 @@ from ..prompts.writer_prompts import (
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER,
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
)
from ._agent_runner import (
WRITER_AGENT_ID,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
resolve_agent_model_name,
stream_writing_agent,
use_builtin_agent,
)
from ._utils import get_writing_model, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -194,19 +203,44 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
def _section_title(idx: int, sec: dict) -> str:
return sec.get("section_title") or sec.get("sectionTitle") or f"章节{idx + 1}"
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("draft model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "作家",
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
}
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.writer):
# 逐章循环控制权仍在节点(严格模式求助 / 逐章流式不动),每章跑一次
# ai-writing-writer agent(SOUL 作 system prompt,直接产出章节 Markdown)。
use_agent = use_builtin_agent("writer")
agent_label = "作家"
model = None
if use_agent:
try:
agent_cfg, _soul = load_writing_agent(WRITER_AGENT_ID)
# 模型守卫提前到循环外:模型名缺失/配错与旧路径同样转成 status:error
resolve_agent_model_name(agent_cfg, state, config)
agent_label = agent_display_name(WRITER_AGENT_ID, "作家")
except WritingAgentUnavailable as e:
logger.warning("writer builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
except Exception as e:
logger.error("draft agent model resolution failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": agent_label,
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
}
if not use_agent:
# 模型创建守卫:模型名配错时 create_chat_model 抛 ValueError,
# 不拦截会硬崩溃整个图;此处转成可恢复的 status:error。
try:
model = get_writing_model(config, state)
except Exception as e:
logger.error("draft model creation failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error", "agent_name": "作家",
"title": "模型错误", "detail": str(e), "message": "草稿生成失败",
}],
}
section_titles: list[str] = []
section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换
section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示
@@ -246,36 +280,55 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
section_buf = "" # 只累积正文(非思考)用于拼接 Markdown
parser = ThinkTagParser()
try:
async for chunk in model.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
if use_agent:
def _on_text(text: str, is_thinking: bool, _i: int = i, _t: str = sec_title) -> None:
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": reasoning,
"is_thinking": True,
"is_section_done": False,
})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"section_index": _i,
"section_title": _t,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
})
section_buf = await stream_writing_agent(
agent_id=WRITER_AGENT_ID,
user_payload=user_msg,
state=state,
config=config,
on_text=_on_text,
)
else:
async for chunk in model.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=user_msg),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": reasoning,
"is_thinking": True,
"is_section_done": False,
})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
section_buf += text
writer({
"type": "draft_chunk",
"section_index": i,
"section_title": sec_title,
"text": text,
"is_thinking": is_thinking,
"is_section_done": False,
})
# 章节完成
writer({
"type": "draft_chunk",
@@ -319,9 +372,9 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"blocked_sections": blocked_sections,
"status": "awaiting_section_help",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"},
{"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
{
"type": "notice", "agent_name": "作家", "title": "素材不足",
"type": "notice", "agent_name": agent_label, "title": "素材不足",
"detail": f"有 {len(blocked_sections)} 个章节缺少足够素材支撑写作,需要你决定如何处理。",
},
],
@@ -356,14 +409,23 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
existing_text=section_bufs[idx],
target_words=expand_target,
)
resp = await model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=expand_msg),
])
parser = ThinkTagParser()
expanded = "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
if use_agent:
expanded = (await stream_writing_agent(
agent_id=WRITER_AGENT_ID,
user_payload=expand_msg,
state=state,
config=config,
on_text=None,
)).strip()
else:
resp = await model.ainvoke([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=expand_msg),
])
parser = ThinkTagParser()
expanded = "".join(
t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking
).strip()
# 仅在扩写确实更长时才采用,避免越改越短
if count_words(expanded) > count_words(section_bufs[idx]):
section_bufs[idx] = expanded
@@ -390,7 +452,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
if section_errors:
notices.append({
"type": "notice",
"agent_name": "作家",
"agent_name": agent_label,
"title": "模型错误",
"detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]),
})
@@ -400,11 +462,11 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
"drafts": [draft],
"status": "awaiting_draft_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": f"开始写作(第{revision_count+1}稿)"},
{"type": "step_start", "agent_name": agent_label, "message": f"开始写作(第{revision_count+1}稿)"},
*notices,
{
"type": "draft_ready",
"agent_name": "作家",
"agent_name": agent_label,
"message": f"草稿完成,共 {word_count} 字",
"draft_title": outline.get("title", "文章"),
"draft_markdown": full_markdown,
@@ -19,10 +19,51 @@ from ..prompts.writer_prompts import (
OUTLINE_USER,
)
from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
from ._agent_runner import (
OUTLINE_READY_MARKER,
OUTLINER_AGENT_ID,
WritingAgentUnavailable,
agent_display_name,
load_writing_agent,
parse_marker_json,
stream_writing_agent,
use_builtin_agent,
)
from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer
logger = logging.getLogger(__name__)
# ── 内置 agent 模式的输入 payload(SOUL 已含人设/层级语义/输出契约,这里只给本次任务的事实输入)──
_AGENT_OUTLINE_USER = """任务模式:新规划
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
目标字数:{word_count_target}字{feedback_section}
可用素材摘要:
{materials_summary}
请规划文章大纲(3-5 个章节),严格按 SOUL 约定输出 [OUTLINE_READY] + JSON。"""
_AGENT_OUTLINE_REVISE_USER = """任务模式:修改模式
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
目标字数:{word_count_target}字
当前大纲(JSON):
{current_outline}
用户修改要求:{feedback}
可用素材摘要:
{materials_summary}
请在「当前大纲」基础上落实「用户修改要求」,未提到的章节保持原样,严格按 SOUL 约定输出 [OUTLINE_READY] + 调整后的完整大纲 JSON。"""
# 上下文压缩:传给大纲规划的素材摘要做条数和单条长度双重截断,防止 token 溢出。
_MAX_OUTLINE_MATERIALS = 12
_MATERIAL_SNIPPET_CHARS = 150
@@ -212,28 +253,40 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
if user_outline_raw and not current_outline and not is_revise:
return await _build_outline_from_user(state, config, pkg, user_outline_raw)
# 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.outliner):
# SOUL 作 system prompt + [OUTLINE_READY] 契约解析;目录缺失静默回退旧路径。
use_agent = use_builtin_agent("outliner")
agent_label = "作家"
if use_agent:
try:
load_writing_agent(OUTLINER_AGENT_ID)
agent_label = agent_display_name(OUTLINER_AGENT_ID, "大纲规划师")
except WritingAgentUnavailable as e:
logger.warning("outliner builtin agent unavailable, falling back to legacy prompts: %s", e)
use_agent = False
fmt_args = dict(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
materials_summary=_materials_summary(pkg),
)
if is_revise:
system_prompt = OUTLINE_REVISE_SYSTEM
user_prompt = OUTLINE_REVISE_USER.format(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
revise_args = dict(
fmt_args,
current_outline=json.dumps(current_outline, ensure_ascii=False, indent=2),
feedback=feedback,
materials_summary=_materials_summary(pkg),
)
user_prompt = OUTLINE_REVISE_USER.format(**revise_args)
agent_payload = _AGENT_OUTLINE_REVISE_USER.format(**revise_args)
else:
system_prompt = OUTLINE_SYSTEM
feedback_section = f"\n重新规划要求:{feedback}" if feedback else ""
user_prompt = OUTLINE_USER.format(
user_intent=state["user_intent"],
article_type=state.get("article_type", "analysis"),
target_audience=state.get("target_audience", "普通读者"),
word_count_target=state.get("word_count_target", 800),
materials_summary=_materials_summary(pkg),
feedback_section=feedback_section,
)
gen_args = dict(fmt_args, feedback_section=feedback_section)
user_prompt = OUTLINE_USER.format(**gen_args)
agent_payload = _AGENT_OUTLINE_USER.format(**gen_args)
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
writer = safe_get_stream_writer()
@@ -242,35 +295,47 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
parser = ThinkTagParser()
try:
# get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError,
# 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。
model = get_writing_model(config, state)
async for chunk in model.astream([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "outline_chunk",
"text": text,
"is_thinking": is_thinking,
})
if use_agent:
def _on_text(text: str, is_thinking: bool) -> None:
writer({"type": "outline_chunk", "text": text, "is_thinking": is_thinking})
full_text = await stream_writing_agent(
agent_id=OUTLINER_AGENT_ID,
user_payload=agent_payload,
state=state,
config=config,
on_text=_on_text,
)
else:
# get_writing_model 放进 try:用户模型缺失 / 模型名配错时抛 ValueError,
# 留在 try 外会硬崩溃;放进来则统一走下方的 status:error 兜底。
model = get_writing_model(config, state)
async for chunk in model.astream([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
]):
# 推理模型的思考走 reasoning_content(content 为空):作为思考实时推送
reasoning = reasoning_delta(chunk)
if reasoning:
writer({"type": "outline_chunk", "text": reasoning, "is_thinking": True})
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
full_text += text
writer({
"type": "outline_chunk",
"text": text,
"is_thinking": is_thinking,
})
except Exception as e:
logger.error("outline streaming failed: %s", e, exc_info=True)
return {
"status": "error",
"progress_events": [{
"type": "error",
"agent_name": "作家",
"agent_name": agent_label,
"title": "模型错误",
"detail": str(e),
"message": "大纲生成失败",
@@ -309,7 +374,10 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
},
],
}
result = parse_json(full_text, fallback)
if use_agent:
result = parse_marker_json(full_text, OUTLINE_READY_MARKER, fallback)
else:
result = parse_json(full_text, fallback)
outline = _parse_outline(result)
section_count = len(outline["sections"])
@@ -318,7 +386,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
"current_outline": outline,
"status": "awaiting_outline_confirm",
"progress_events": [
{"type": "step_start", "agent_name": "作家", "message": "开始规划大纲"},
{"type": "outline_ready", "agent_name": "作家", "message": f"大纲已生成,共 {section_count} 个章节"},
{"type": "step_start", "agent_name": agent_label, "message": "开始规划大纲"},
{"type": "outline_ready", "agent_name": agent_label, "message": f"大纲已生成,共 {section_count} 个章节"},
],
}