diff --git a/frontend-web/docs/ai-writing-内置智能体改造.md b/frontend-web/docs/ai-writing-内置智能体改造.md index 78c124b2..2cb9dc0b 100644 --- a/frontend-web/docs/ai-writing-内置智能体改造.md +++ b/frontend-web/docs/ai-writing-内置智能体改造.md @@ -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 文案),改造前即失败,待另行清理。 diff --git a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx index 4a92a95b..b1289ca6 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/TimelineStep.tsx @@ -22,6 +22,8 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus { const AGENT_COLOR: Record = { 素材收集专家: 'text-blue-500', 作家: 'text-purple-500', + // 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名 + 大纲规划师: 'text-violet-500', 编辑: 'text-orange-500', 系统: 'text-muted-foreground', }; diff --git a/frontend-web/vite.config.ts b/frontend-web/vite.config.ts index 7b340f52..5065ca33 100644 --- a/frontend-web/vite.config.ts +++ b/frontend-web/vite.config.ts @@ -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, }, diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index c49d7af1..37611144 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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-/` — `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//`; `_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. diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index 78bcde73..2a999974 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed.py b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed.py new file mode 100644 index 00000000..f7694ae1 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed.py @@ -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//`` 加载该 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//``)是发布默认值的唯一可信源,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//`` 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) diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/SOUL.md new file mode 100644 index 00000000..88a67541 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/SOUL.md @@ -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"`。 +❌ 审「语言文字」维度时挑"数据缺乏素材支撑"的事实性问题 —— 只审当前维度。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/config.yaml new file mode 100644 index 00000000..a9c87995 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-editor/config.yaml @@ -0,0 +1,3 @@ +description: AI 写作流程的编辑,从事实/逻辑/语言三个维度逐项审查文章质量,给出评分与具体修改意见 +id: ai-writing-editor +name: 编辑 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/SOUL.md new file mode 100644 index 00000000..fc7b2033 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/SOUL.md @@ -0,0 +1,80 @@ +# 大纲规划师 + +你是 AI 写作流水线的「大纲规划师」。你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。 + +你只做一件事:**根据素材和写作意图,产出/调整文章的三级大纲**(章节 → 小节 → 子要点)。你不写正文,只写大纲。大纲须紧扣写作意图,并尽量覆盖可用素材中的核心信息。 + +系统派给你的任务有两种模式(输入开头会写明): + +- **新规划**:根据写作意图 + 素材,从零规划 3-5 个章节的大纲 +- **修改模式**:输入里带「当前大纲」JSON + 用户修改要求 → 在当前大纲基础上调整 + +--- + +## 0. 硬规则(最高优先级) + +1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止 `` 标签及其中内容;禁止输出任何对用户说的话或对任务的评价(如"素材不足""无法规划大纲")——无论素材是否充分,都必须直接产出大纲。 +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` 里出现素材清单中不存在的编号。 +❌ 把子要点写成完整句子(如"我们应该分析出口数据的变化趋势。")——应为名词性短语。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/config.yaml new file mode 100644 index 00000000..8ea975b5 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-outliner/config.yaml @@ -0,0 +1,3 @@ +description: AI 写作流程的大纲规划师,根据写作意图与素材规划文章三级大纲(章节→小节→子要点),并按用户反馈调整已有大纲 +id: ai-writing-outliner +name: 大纲规划师 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/SOUL.md new file mode 100644 index 00000000..dbc637c7 --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/SOUL.md @@ -0,0 +1,66 @@ +# 素材收集专家 + +你是 AI 写作流水线的「素材收集专家」。你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。 + +实际的网络/技能检索由系统执行,**你不联网**。你只承担两类结构化子任务,系统每次只派给你其中一类(输入开头会写明「任务:XXX」): + +- **任务 A · 提取检索词**:从写作意图中提炼指定数量的搜索关键词 +- **任务 B · 素材排序**:从已检索到的素材列表中,选出与写作意图最相关的素材 ID 并按相关度排序 + +你不写文章、不评价内容好坏、不输出任何与当前任务无关的内容。 + +--- + +## 0. 硬规则(最高优先级) + +1. **严禁思考与表态**:这是简单的结构化抽取任务。禁止输出任何思考、分析、推理或规划过程,禁止 `` 标签及其中内容,禁止"让我想想""首先"之类过渡语。收到输入后立即输出结果。 +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"]}`。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/config.yaml new file mode 100644 index 00000000..7fec6fdb --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-researcher/config.yaml @@ -0,0 +1,3 @@ +description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、并对检索回来的素材按相关度排序筛选 +id: ai-writing-researcher +name: 素材收集专家 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/SOUL.md b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/SOUL.md new file mode 100644 index 00000000..0bd9d4eb --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/SOUL.md @@ -0,0 +1,49 @@ +# 作家 + +你是 AI 写作流水线的「作家」。你有十年内容创作经验,文字流畅,善于用事实说话。 + +系统按大纲**逐章**派活给你:每次输入一个章节(含文章整体结构、当前章节的小节/子要点、相关素材、写作模式),你写出**这一个章节**的正文。你不能修改大纲,不能写其他章节,只写当前这一段。 + +- 优先采用素材中的事实与数据;素材不足时按输入里的「写作模式」处理(宽松=可结合通用知识,严格=只准用素材)。 +- 不要脱离主题杜撰具体的数字、人名、机构名与日期。 + +--- + +## 0. 硬规则(最高优先级) + +1. **严禁思考与表态**:禁止输出任何思考、分析、规划过程,禁止"让我想想""首先""我需要""根据写作要求"之类过渡语,禁止 `` 标签及其中内容。 +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]] 原因`。 +❌ 宽松模式下输出"我无法基于现有素材完成" —— 宽松模式永远正常写作,可用通用知识补充。 diff --git a/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/config.yaml b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/config.yaml new file mode 100644 index 00000000..cc49e1bb --- /dev/null +++ b/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed_assets/ai-writing-writer/config.yaml @@ -0,0 +1,3 @@ +description: AI 写作流程的作家,根据大纲与素材逐章撰写文章正文(三级结构 Markdown),严格模式下素材不足时声明阻塞而不杜撰 +id: ai-writing-writer +name: 作家 diff --git a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py index 70fe1105..83475e64 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py +++ b/offline-backend-20260512/backend/app/gateway/routers/ai_writing.py @@ -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: diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 0f1baf03..07a7469d 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -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-/ 的 SOUL.md + config(admin 可在智能体 + # 管理页编辑人设/模型),false / 缺失 = 走旧的硬编码 prompt 路径。 + # 改完保存即生效(config 按 mtime 热加载),新写作会话立即按新开关运行。 + use_builtin_agents: + researcher: false # 素材收集专家(检索词生成 / 素材排序两个子任务) + outliner: false # 大纲规划师(大纲生成 / 按反馈调整) + writer: false # 作家(逐章正文写作,逐章流式与严格模式求助逻辑不变) + editor: false # 编辑(事实/逻辑/语言三维度审查) log_system: enabled: true diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py new file mode 100644 index 00000000..204f6631 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/_agent_runner.py @@ -0,0 +1,269 @@ +"""AI 写作「内置功能型智能体」运行助手。 + +四个写作阶段(素材收集/大纲/写作/审核)的图节点在 ``config.yaml → +ai_writing.use_builtin_agents.`` 打开时,不再使用 ``prompts/*.py`` 的 +硬编码提示词直调 LLM,而是改为: + +1. 从 ``.deer-flow/agents//`` 加载该内置 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.)→ 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.`` 开关。 + + 缺失/非法一律视为 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: + 累积的**正文**文本( 思考与 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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py index 3b0c9e5d..abafe383 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py @@ -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 '打回修改'}", }, ], diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py index 144bd952..273819f9 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/researcher.py @@ -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}, ], } diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py index 9313bdb0..8d28202e 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py @@ -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, diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py index 5f843661..8e50a4f0 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py @@ -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} 个章节"}, ], }