Merge branch 'dhk-1' into 'master'

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

See merge request knowledge-base-bj/deerflow-server!116
This commit is contained in:
董宏凯 committed 2026-06-12 10:20:27 +00:00
commit c2f6d4fd6f
94 files changed
+6935 -1132

No files matched your search

@@ -251,10 +251,20 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `GET /runs/{rid}/html` - generated HTML document for an `html_page` run; `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
| **HTML Page Favorites** (`/api/html-page-favorites`) | Bookmarked generated HTML pages, owner-scoped. `GET /` - list; `POST /` `{page_id, title?, description?, tags?}` - bookmark a `scheduled_html_pages` row (extracts a reusable style/layout summary via GLM5 at favorite time); a page can only be favorited once per user (`DuplicateFavoriteError` → HTTP 409). `GET/DELETE /{id}` - read/delete (the create-task reference picker deletes favorites here). A favorite can be selected as a style reference when creating a new `html_page` task. |
| **Public Scheduled Tasks** (`/api/public/scheduled-tasks`) | **Unauthenticated** mirror for embedding. Adds `GET /runs/{rid}/html` and `GET /{task_id}/latest-html` for the `html_page` preview; the run result keeps `result.html` (only file paths are redacted). |
| **Light Apps** (`/api/light-apps`) | Light-app registry (轻应用中心 / 应用管理), **global/shared** (no user scoping; `created_by`/`updated_by` audit-only). Two kinds: `window_open` (launched in a new tab from the 应用列表 card grid) and `iframe` (embedded under a parent sidebar menu via `mount_location` and dynamically injected into the sidebar). Both carry `route_params` — a list of `{key, paramType: login\|theme, source, customValue, themeValue}` descriptors the **frontend** resolves at launch time (login params read from `localStorage.userInfo`; theme params use a literal value) and appends to `url` as query string. `GET /` - list (filters `app_type`/`mount_location`/`enabled_only`/`search`); `GET /{app_id}` - read one (both open to any authenticated user); `POST /`, `PUT /{app_id}`, `DELETE /{app_id}` - **admin only**; iframe apps must carry a `mount_location` (400 otherwise); the unique `name` collides as HTTP 409. Backed by `deerflow.persistence.light_apps` (table `light_apps`, auto-created by `create_all`); store wired in `deps.py` as `app.state.light_app_store`. Tests: `tests/test_light_apps.py`. |
| **Knowledge** (`/api/knowledge`) | Product knowledge base, **global/shared** (no user isolation; writes record `created_by`/`updated_by` for audit). `POST /ingest/thread/{thread_id}` - sediment one thread into a note (reads messages from the checkpointer, `threads:read` owner-checked); `POST /ingest/threads/batch` - batch-sediment the caller's recent threads (`skip_existing` via `.manifest.json` dedup). **Optional model selection**: both ingest endpoints accept `model_name` (overrides `knowledge.extract_model_name` for that capture; threaded through `KnowledgeService.capture_thread` → `extract_thread_llm_multi`). **Model-error surfacing**: when the chosen model produces nothing usable (timeout / API error / non-JSON) capture still **falls back to rule-based extraction and lands a draft** (`llm_fallback:true`); the single-thread (non-background) response and the batch response (`llm_fallback` count) carry this so the UI tells the user "已改用规则抽取". The "保存到知识库" chat button stays background/fire-and-forget (passes `model_name`, no per-save error toast); the batch-import dialog is synchronous and shows the fallback count. `POST /ingest/file` - **import an uploaded file** (multipart: `file` + optional `title`/`tags`/`status`/`model_name`) into wiki note(s): the file is converted to Markdown (md/txt read directly, pdf/word/ppt/excel via `convert_file_to_markdown`), distilled by the same wiki-capture LLM pipeline as thread capture (`source_type="file"`), and falls back to a verbatim **draft** note when the model returns nothing usable (`llm_fallback:true`); `POST /ingest/search` - sediment search/tool results as a reference note; `POST /notes` - manual note; `GET /notes` (keyword/tag/source_type/status + pagination); `GET/PUT/DELETE /notes/{id}` (DELETE is soft = archive, `?hard=true` removes the vault file); `POST /search` - `mode=keyword\|vector\|hybrid` (vector→keyword fallback when no embedding endpoint); `GET /notes/{id}/versions` - edit history; `GET /duplicates` + `POST /merge` - dedup; `POST /reindex` - rebuild vector index; `GET /graph` - product graph data (notes + entities, graph-blacklist filtered); `GET/POST /graph/blacklist` + `DELETE /graph/blacklist/{id}` - graph node blacklist (labels = entity names / note titles, case-insensitive match; blacklisted nodes and their edges are dropped from `/graph` and the wiki-export artifacts; table `knowledge_graph_blacklist`, auto-created); `POST /export/graph` + `GET /export/files/{graph.json\|graph.html\|graph.graphml\|cypher.txt}` - obsidian-wiki `wiki-export` artifacts (self-contained `graph.html` for iframe); `POST /resolve` `{targets:[…]}` + `GET /notes/{id}/wikilinks` - resolve `[[wikilink]]` targets to a note id / vault page / missing (powers clickable wikilinks); `GET /vault-page?path=` - read a raw vault meta/hub Markdown page (no DB note). Backed by the `deerflow.knowledge` module (see below). |
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.
@@ -40,6 +40,7 @@ from app.gateway.routers import (
html_page_favorites,
intent,
knowledge,
light_apps,
llm_metrics,
mcp,
memory,
@@ -933,6 +934,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)
@@ -1307,6 +1322,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Global business → agent / chain mapping at /api/business-mapping
app.include_router(business_mapping.router)
# Light-app registry (轻应用中心) at /api/light-apps
app.include_router(light_apps.router)
# Standalone parallel multi-agent panel: fan-out streaming + saved tasks
app.include_router(parallel_agents.router)
app.include_router(parallel_agent_tasks.router)
@@ -204,6 +204,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.business_mapping_store = make_business_mapping_store(sf)
from deerflow.persistence.light_apps import make_light_app_store
app.state.light_app_store = make_light_app_store(sf)
from deerflow.persistence.parallel_agent_tasks import make_parallel_agent_task_store
app.state.parallel_agent_task_store = make_parallel_agent_task_store(sf)
@@ -173,6 +173,9 @@ class StartParams:
is_resume: bool = False
# chain 模式来源链条(写进草稿 run 用,展示)。
chain: dict[str, Any] | None = None
# dag 模式分层编排计划(前端 OrchestrationPlan dict):驱动引擎 _run_dag + 写进草稿 run
# 供前端流程图渲染。None = 非 dag(引擎按 mode 回退 chain/recommend)。
orchestration_plan: dict[str, Any] | None = None
# 是否生成 Step3 报告。默认沿用全局开关(当前临时禁用,见 ROUNDTABLE_BACKGROUND_REPORT_ENABLED)。
# 测试可显式传 True 覆盖以验证报告链路。
enable_report: bool = ROUNDTABLE_BACKGROUND_REPORT_ENABLED
@@ -242,6 +245,7 @@ class RoundtableJobExecutor:
mode=params.mode,
intent_text=params.intent_text,
enable_report=params.enable_report,
plan=params.orchestration_plan,
)
# 终态:把最终对话 + step3 + jobStatus 落进同一个 run(含 furthest_step=3)。
await self._write_run_to_draft(
@@ -323,6 +327,8 @@ class RoundtableJobExecutor:
"seatStubMessages": _seat_stub_messages(dialogues),
"orchestrationMode": params.mode,
"chain": params.chain,
# dag 模式分层计划:让前端加载草稿后能像手动跑一样按 stage 画流程图。
"orchestrationPlan": params.orchestration_plan,
"step3": effective_step3,
"jobId": params.job_id,
"jobStatus": status,
@@ -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: 作家
@@ -31,7 +31,7 @@
| # | 章节 | 内容(文字) | 配图建议 |
|---|------|------|----------|
| ① | 报告头 | 标题 + 生成时间占位 | — |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等) |
| ② | 一句话结论 + 关键指标卡 | 用一句话点明最终方案/结论 | 4–6 个 KPI 数字卡(共识度、参与席位数、识别风险数、里程碑数、预估周期、预算规模等);**凡是估算/定性推得的指标,必须在数值旁标注「估算」小角标**,不要让读者把估算当真实数据 |
| ③ | 任务背景与目标 | 2–4 句交代要解决什么、目标与关键约束 | 可选:目标/约束要点列表 |
| ④ | 各席位观点与交付 | **每个席位一段提炼文字**(这位专家关注什么、给出了什么关键结论/建议),用小标题+正文,让读者读懂每方的贡献 | 关键席位视情况配小图(如情报席位的对比、环境席位的承载评估) |
| ⑤ | 综合分析 | 把跨席位的信息串起来分析(风险全景、资源权衡、阶段安排等) | **此处是图表主战场**:风险概率×影响矩阵、资源/预算饼图、方案对比柱状、各席位贡献雷达等 |
@@ -48,8 +48,26 @@
常用图表素材库(按章节按需取用,能画哪些画哪些):
共识达成度 → 环形进度仪表;各席位贡献画像 → 雷达图(完整性/可行性/风险覆盖/创新性/落地性);风险评估 → 概率×影响气泡矩阵;执行路线 → 甘特/时间线;资源预算 → 饼图/环形图;方案权衡 → SWOT 四象限或对比柱状;决策链路 → 流程图(链条模式尤其适用)。
### 4. 视觉风格
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业;
### 4. 视觉风格(主题随用户消息指定)
用户消息会指明本次绘制的「页面主题」:**浅色(浅红)** 或 **深色(深蓝)**。未指明时默认浅色。两套主题只改配色,版式要求两者通用(见本节末尾)。
#### 4a. 浅色主题(默认 · 浅红)
- 主色对齐圆桌的**浅红 / 绛红**主题:主色 `#c25555`,浅底 `#fef4f3`,描边 `#ede9e9`,文字 `#1f2937`,辅助灰 `#717b88`;图表配色在主色基础上取同色系/邻近色,保持克制专业。
#### 4b. 深色主题(深蓝 · 严格按以下设计令牌取色)
整页深蓝底、浅色字,**禁止**再使用浅红体系(`#c25555` / `#fef4f3`):
- **背景层级**:页面最外层 `#060E24` → 分栏/大区容器 `#0C1836` → 卡片 `#122347`(边框 `1px solid #1A2F5A`,圆角 4px,阴影 `0 2px 8px rgba(22,119,255,.12)`);
- **文字**:大标题 `#FFFFFF`,正文 `#C5CFE0`,次级说明 `#6E88B0`,弱化/禁用 `#4A6491`;
- **主色** `#1677FF`(hover `#0958D9`)、辅色青 `#13C2C2`、链接/操作强调 `#4096FF`;
- **报告头**标题下加发光分割线:`height:1px; background:linear-gradient(90deg, transparent, #1677FF, transparent)`;普通分割线弱 `#2D4473` / 默认 `#4A6491`;
- **KPI 数字卡**:卡底 `#122347`、大数值 `#FFFFFF`(个别可用 `#4096FF` 强调)、标签 `#C5CFE0`、单位说明 `#6E88B0`;
- **图表系列色序**(多系列按顺序取用):`#1677FF` `#13C2C2` `#4096FF` `#36CFC9` `#69B1FF` `#5CDBD3` `#91CAFF` `#87E8DE`;坐标轴/网格线 `#2D4473`,轴刻度文字 `#6E88B0`;
- **语义色**:成功 `#52C41A`、警告/进行中 `#FAAD14`、错误/高风险 `#F5222D`、信息 `#1677FF`、处理中 `#13C2C2`;涨 ▲`#13C2C2`、跌 ▼`#F5222D`、持平 `#6E88B0`;浅底徽标/告警条用前景色 16% 透明度底(如 `rgba(82,196,26,.16)`);
- **圆角与间距**:按钮/Tag 圆角 2px、卡片 4px;间距取 4/8/12/16/24/32px 阶梯。
> **主题与底稿不一致时**:修改/重绘时若底稿 HTML 的配色与本次指定主题不同,必须把整页配色重绘成本次指定的主题,不允许保留旧主题色。
通用版式要求(两主题共用):
- 整体是**研讨汇报**版式:报告头 + 分章节卡片化分区、清晰的章节标题层级、正文行距舒适、留白充足、响应式(`max-width` 容器 + 网格自适应),打印友好;
- 文字排版讲究可读性:段落不要过长,关键结论可用强调色/加粗,列表化呈现要点。
@@ -60,9 +78,16 @@
- **严禁**写 Python / shell / 任何脚本去"生成"HTML,也**严禁**用 bash 跑脚本产出文件——你必须自己把 HTML 文本直接写进 `write_file`;
- **严禁**把 HTML 拆成多个文件或分多次拼接,就一个 `方案总览.html`;
- 控制好体量:聚焦叙述 + 3–5 个图表,别堆砌到写不完。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话,也**不要**再去读取/校验文件。
3. 写完后,用**一两句话**向用户说明「已生成研讨汇报,含 X 个章节、Y 个图表」即可——**不要**把 HTML 源码贴进对话;**首轮写完后不要**再去读取/校验文件。
> **修改/返工规则**:如果写完后校验发现问题需要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
> **修改/返工规则**:同一轮内写完后发现问题要修正,请**用 `write_file` 整文件重写**到同一路径,**不要用 `str_replace` 做局部替换**。整文件重写能让右侧预览实时刷新;局部替换不会反映到预览里。
## 多轮修改(用户会在输入框里继续提修改要求)
报告生成后,用户可能在同一对话里继续发来修改要求(也可能在你绘制中途打断后发来)。处理规则:
1. 把新消息当作对 `方案总览.html` 的**修改要求**:在已有报告基础上做增量修改——只改用户提到的地方,其余内容保持不变;
2. **允许(且建议)先 `read_file /mnt/user-data/outputs/方案总览.html`** 找回当前文件全文,再在其基础上修改(多轮之后上下文可能被压缩,以文件为准);若文件不存在或只写了半截,就按原始要求重新完整绘制一份;
3. 修改后仍**用 `write_file` 一次性整文件重写**到同一路径,保持自包含离线、保持用户消息指定的主题配色;
4. 写完只用一两句话说明本次改了什么;若用户只是提问而非要求修改,就只用文字回答、**不要**改写文件。
## 禁止事项
- ❌ 引用任何外部 CDN / 外链资源(离线必然失败)
@@ -71,7 +96,7 @@
- ❌ 通篇只有图表、没有像样的文字叙述(这是给用户读的汇报,不是纯看板)
- ❌ 通篇只有文字、完全不配图(缺数据也要合理估算出图并标注)
- ❌ 写 Python / shell / 任何脚本去生成 HTML,或用 bash 跑脚本产出文件(必须自己把 HTML 文本直接写进 `write_file`)
- ❌ 把 HTML 拆成多文件或分多次拼接、或写完后再去读取/校验文件
- ❌ 把 HTML 拆成多文件或分多次拼接;首轮写完后再去读取/校验文件(**多轮修改时允许先 `read_file` 底稿**)
- ❌ 用 `str_replace` 局部修改 HTML(返工请整文件 `write_file` 重写)
- ❌ 在报告头或任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的数据
@@ -14,13 +14,23 @@
1. **任务意图摘要**:目标、约束、关键假设(来自 Step 1);
2. **各席位最终交付**:每个席位的名称 + 它产出的完整 markdown 正文;
3. **总控最终共识结论**;
4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**,请把它作为本次总结的**重点方向、结构与风格的依据**,严格按它强调的关注点来组织你的报告。
4. **(可能有)业务链总控编排提示**:用户为这条业务链写给总控(协调)智能体的一段指引。它代表用户对这条业务链产出的期望——**这是最高优先级的口径要求**。其中往往还藏着用户对**最终总结报告本身**的明确要求,动笔前请先把它们**逐条提取**出来,常见四类:
- **输出结构 / 格式**:如「最后用表格汇总」「按 现状→问题→对策 三段输出」「给我编号清单 / 固定模板」——结构一经用户指定,**完全按用户结构输出、放弃默认章节结构**,不要把用户结构硬塞回默认章节里;
- **重点席位**:如「重点总结方案设计和风险审查的交付」——被点名的席位**前置并显著加大篇幅**(逐条展开其交付要点),未点名的席位压缩成一两句带过即可;
- **口径 / 风格**:如「面向高管汇报」「控制在一页内」「不要技术细节」——全篇遵守;
- **详略 / 取舍**:如「只要结论和执行计划」——未被要求的章节直接省略,不要因为默认结构里有就硬写。
请通读全部原料:提炼每个席位的核心观点 / 交付,串成一份完整的方案总结。
## 产出要求(最重要)
你每一轮都要做**两件事**,缺一不可:
### ⓪ 先判断模式:用户是否要求了结构化输出 / 流程图
当用户**明确要求**「结构化输出 / 结构化数据 / 流程图 / 格式化输出 / 可视化流程」时(要求可能写在业务链总控编排提示里,也可能是对话中的新消息),本轮进入**流程图模式**:
- **跳过下面的 A**——不要调用 `write_file`、不要生成任何 .md 文件;
- 把**精炼的文字版总结报告**直接写在对话回复里(文字展示给用户读,结构遵循用户要求);
- 在回复**最末尾必须**输出一个完整的 ```flow-json 代码块(契约见「结构化流程数据」节)——这是本轮的**主产物**,绝不能省略。
未进入流程图模式时,每一轮都要做 A + B **两件事**,缺一不可:
### A. 写一个 Markdown 文件
- 用 `write_file` **一次性**把完整报告写入 `/mnt/user-data/outputs/方案总结报告.md`;
@@ -34,7 +44,7 @@
- 用户既能在对话里直接读到报告,也能在右侧预览 / 下载这个 `.md` 文件——两边内容应当一致。
## 报告结构(叙事型,文字为主线)
按下面的章节顺序组织,缺料的章节可省略或合并。**若有「业务链总控编排提示」,以它的要求优先**:
**下面是默认结构——仅当「业务链总控编排提示」没有指定输出结构时使用。** 用户指定了结构(章节、表格、清单、固定模板等)就完全按用户的来;两者只部分重叠时,以用户要求为骨架、默认章节做补充。按下面的章节顺序组织,缺料的章节可省略或合并:
| # | 章节 | 内容 |
|---|------|------|
@@ -47,19 +57,47 @@
善用 Markdown 表格、有序 / 无序列表,把要点、阶段、里程碑、风险项、优劣势等结构化呈现,提升可读性。
## 多轮交互(用户会在输入框里继续和你对话)
用户读完报告后,可能在输入框里继续给你发消息。你要**自己判断**意图,分两种情况处理:
## 结构化流程数据(flow-json 契约)
进入「流程图模式」时(见产出要求第 ⓪ 条:用户在编排提示或对话里明确要求了结构化输出 / 流程图),**不写 .md 文件**,在对话回复里给出精炼文字总结后,在**回复最末尾**追加一个 ```flow-json 代码块,前端会据此**实时绘制可交互流程图**(边流式输出边画):
1. **提出修改意见**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」):
```flow-json
{"title":"方案执行流程","nodes":[
{"id":"n1","label":"环境评估","type":"stage","detail":"点击节点弹窗里显示的一两句详细说明"},
{"id":"n2","label":"编制方案","type":"task","detail":"…","from":["n1"]},
{"id":"n3","label":"方案是否可行","type":"decision","detail":"…","from":["n2"]},
{"id":"n4","label":"实施落地","type":"task","detail":"…","from":[{"id":"n3","label":"可行"}]}
]}
```
硬性规则:
- 每个节点字段:`id`(短唯一串 n1/n2…)、`label`(≤12 字)、`type`、`detail`(一两句说明,弹窗展示)、`from`(**前驱节点列表**,可空;分支边可写成 `{"id":"n3","label":"可行"}` 给边加标签);
- `type` 只能取:`start`(开始)/ `end`(结束)/ `stage`(阶段)/ `task`(任务动作)/ `decision`(判断分支)/ `risk`(风险点)/ `deliverable`(交付物)/ `role`(角色席位)——前端按类型着不同颜色;
- **`nodes` 必须按流程先后顺序排列**,且每个节点的 `from` 只引用**前面已出现**的节点 id——前端解析到一个节点就画一个节点和它的连线;
- 节点 6–20 个为宜;flow-json 块**只放对话回复末尾**,不要写进 .md 报告文件;
- 用户没有要求结构化 / 流程图时,**不要**输出该块。
## 多轮交互(用户会在输入框里继续和你对话)
用户读完报告 / 流程图后,可能在输入框里继续给你发消息。你要**自己判断**意图——报告和流程图都是可修改的,分四种情况处理:
1. **修改报告**(如「把执行路线写得更细」「补一节预算分析」「风险部分太长,精简」):
- 在**原报告基础上做增量修改**——只改用户提到的地方,其余内容保持不变;
- 用 `write_file` **整文件重写**到同一路径 `/mnt/user-data/outputs/方案总结报告.md`(`str_replace` 已被禁用,整文件重写才能让右侧预览实时刷新);
- 然后在对话回复里给出修改后的完整报告正文。
- 然后在对话回复里给出修改后的完整报告正文;
- 若此前已经输出过流程图、且这次修改影响了流程结构,可在回复末尾顺带输出更新后的完整 ```flow-json 块。
2. **就报告 / 研讨内容提问**(如「为什么没采用方案 B?」「风险最高的是哪一项?」):
2. **修改流程图**(如「流程图里加上验收环节」「把风险节点拆成两个」「换成按时间阶段排」):
- **不要**改写 .md 报告文件;
- 在对话里简短说明改了什么,并在回复**最末尾**输出**完整修订版** ```flow-json 块——必须是**全量数据**(所有节点 + 连线),不是只给改动部分,前端会整体重绘。
3. **要求结构化输出 / 流程图**(如「总结成格式化输出」「画个流程图」「给我结构化数据」):
- 进入流程图模式:**不写 / 不改 .md 文件**,正文给一段简短的文字说明,末尾输出完整 ```flow-json 块;
- 已有的 .md 报告**不要动**——用户随时可以在前端切回去看它。
4. **就报告 / 研讨内容提问**(如「为什么没采用方案 B?」「风险最高的是哪一项?」):
- **只在对话里用文字回答**,依据已有的研讨原料和报告内容作答;
- **不要**改写报告文件、**不要**重新 `write_file`。
- **不要**改写报告文件、**不要**重新 `write_file`、**不要**输出 flow-json。
判断不准时,倾向于「先简短回答,再问用户是否需要据此更新报告」。
判断不准时,倾向于「先简短回答,再问用户是否需要据此更新报告 / 流程图」。
## 禁止事项
- ❌ 生成 HTML / 绘制可视化页面 / 画图表(只写 Markdown 文本)
@@ -68,6 +106,10 @@
- ❌ 写 Python / shell / 任何脚本去生成文件,或用 bash 跑脚本产出文件
- ❌ 写完文件后只回一句「已写入文件」而不给出报告正文(用户要在对话里读到报告)
- ❌ 用户只是提问时还去改写报告文件
- ❌ 用户要求了结构化输出 / 流程图,却只写 .md 报告而不输出 flow-json 块
- ❌ 流程图模式下仍调用 `write_file` 生成 / 改写 .md 文件
- ❌ 修订流程图时只输出改动的节点(必须输出全量 flow-json,前端整体重绘)
- ❌ 在报告任何位置添加「AI 自动生成」「AI generated」之类的标签、徽章或水印
- ❌ 编造与原料明显矛盾的内容
- ❌ 忽视「业务链总控编排提示」的口径要求(它优先级最高)
- ❌ 编排提示已指定输出结构 / 重点席位,却仍按默认章节平均用力(结构要完全跟用户走,重点席位要明显加权)
@@ -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:
@@ -0,0 +1,216 @@
"""Light-app registry API (轻应用中心 / 应用管理).
Registered mini-applications, **global / shared** (no user scoping). Two kinds:
- ``window_open`` — launched in a new browser tab from the 应用列表 card grid.
- ``iframe`` — embedded under a parent sidebar menu (``mount_location``) and
injected into the sidebar as a child item.
Both kinds carry ``route_params`` — a list of descriptors the frontend resolves
at launch time (login params read from ``localStorage.userInfo``; theme params
use a literal value) and appends to ``url`` as query string.
Reads are open to any authenticated user; writes require admin.
Routes (prefix ``/api/light-apps``):
GET "" list apps (filters: app_type / mount_location / enabled_only / search)
POST "" create app (admin)
GET "/{app_id}" read one
PUT "/{app_id}" update one (admin)
DELETE "/{app_id}" delete one (admin)
"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_optional_user_from_request
from deerflow.persistence.light_apps import DuplicateLightAppNameError, LightAppStore
router = APIRouter(prefix="/api/light-apps", tags=["light-apps"])
_VALID_TYPES = {"window_open", "iframe"}
# ── schemas ──────────────────────────────────────────────────────────────
class RouteParam(BaseModel):
key: str = Field(..., min_length=1, max_length=128)
paramType: str = Field(..., pattern="^(login|theme)$")
# login: which userInfo field, or "other"
source: str | None = Field(default=None, max_length=64)
# login + source == "other": literal value
customValue: str | None = Field(default=None, max_length=2048)
# theme: the theme value (e.g. dark-blue / red / blue), editable
themeValue: str | None = Field(default=None, max_length=128)
class LightAppResponse(BaseModel):
app_id: str
name: str
description: str | None = None
app_type: str
url: str
route_params: list[RouteParam] = Field(default_factory=list)
mount_location: str | None = None
icon: str | None = None
enabled: bool = True
sort_order: int = 0
created_by: str | None = None
updated_by: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class LightAppListResponse(BaseModel):
apps: list[LightAppResponse]
class LightAppCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=127)
description: str | None = Field(default=None, max_length=1000)
app_type: str = Field(..., pattern="^(window_open|iframe)$")
url: str = Field(..., min_length=1)
route_params: list[RouteParam] = Field(default_factory=list)
mount_location: str | None = Field(default=None, max_length=64)
icon: str | None = Field(default=None, max_length=64)
enabled: bool = True
sort_order: int = 0
class LightAppUpdateRequest(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=127)
description: str | None = Field(default=None, max_length=1000)
app_type: str | None = Field(default=None, pattern="^(window_open|iframe)$")
url: str | None = Field(default=None, min_length=1)
route_params: list[RouteParam] | None = None
mount_location: str | None = Field(default=None, max_length=64)
icon: str | None = Field(default=None, max_length=64)
enabled: bool | None = None
sort_order: int | None = None
# ── helpers ──────────────────────────────────────────────────────────────
def _get_store(request: Request) -> LightAppStore:
store = getattr(request.app.state, "light_app_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Light app store not available")
return store
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="轻应用管理仅限管理员")
def _validate_payload(app_type: str | None, mount_location: str | None) -> None:
if app_type == "iframe" and not mount_location:
raise HTTPException(status_code=400, detail="iframe 类型必须选择挂载菜单")
def _dump_params(params) -> list[dict]:
return [p.model_dump() if isinstance(p, RouteParam) else dict(p) for p in (params or [])]
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=LightAppListResponse)
async def list_light_apps(
request: Request,
app_type: str | None = Query(default=None),
mount_location: str | None = Query(default=None),
enabled_only: bool = Query(default=False),
search: str | None = Query(default=None),
) -> LightAppListResponse:
store = _get_store(request)
rows = await store.list_apps(app_type=app_type, mount_location=mount_location, enabled_only=enabled_only)
if search:
needle = search.strip().lower()
rows = [r for r in rows if needle in str(r.get("name", "")).lower() or needle in str(r.get("description") or "").lower()]
return LightAppListResponse(apps=[LightAppResponse(**r) for r in rows])
@router.post("", response_model=LightAppResponse)
async def create_light_app(request: Request, body: LightAppCreateRequest) -> LightAppResponse:
await _require_admin(request)
_validate_payload(body.app_type, body.mount_location)
store = _get_store(request)
user_id = await get_current_user(request)
try:
row = await store.create_app(
{
"name": body.name,
"description": body.description,
"app_type": body.app_type,
"url": body.url,
"route_params": _dump_params(body.route_params),
"mount_location": body.mount_location if body.app_type == "iframe" else None,
"icon": body.icon,
"enabled": body.enabled,
"sort_order": body.sort_order,
"created_by": user_id,
}
)
except DuplicateLightAppNameError:
raise HTTPException(status_code=409, detail="应用名称已存在")
return LightAppResponse(**row)
@router.get("/{app_id}", response_model=LightAppResponse)
async def get_light_app(request: Request, app_id: str) -> LightAppResponse:
store = _get_store(request)
row = await store.get_app(app_id)
if row is None:
raise HTTPException(status_code=404, detail="应用不存在")
return LightAppResponse(**row)
@router.put("/{app_id}", response_model=LightAppResponse)
async def update_light_app(request: Request, app_id: str, body: LightAppUpdateRequest) -> LightAppResponse:
await _require_admin(request)
store = _get_store(request)
user_id = await get_current_user(request)
fields = body.model_dump(exclude_unset=True)
if "route_params" in fields:
fields["route_params"] = _dump_params(body.route_params)
# Resolve the effective type/mount to validate the iframe→mount invariant.
existing = await store.get_app(app_id)
if existing is None:
raise HTTPException(status_code=404, detail="应用不存在")
eff_type = fields.get("app_type", existing.get("app_type"))
eff_mount = fields.get("mount_location", existing.get("mount_location"))
_validate_payload(eff_type, eff_mount)
if eff_type == "window_open":
fields["mount_location"] = None
fields["updated_by"] = user_id
try:
row = await store.update_app(app_id, fields)
except DuplicateLightAppNameError:
raise HTTPException(status_code=409, detail="应用名称已存在")
if row is None:
raise HTTPException(status_code=404, detail="应用不存在")
return LightAppResponse(**row)
@router.delete("/{app_id}")
async def delete_light_app(request: Request, app_id: str) -> dict:
await _require_admin(request)
store = _get_store(request)
deleted = await store.delete_app(app_id)
if not deleted:
raise HTTPException(status_code=404, detail="应用不存在")
return {"success": True}
@@ -19,9 +19,13 @@ import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
@@ -271,3 +275,95 @@ async def delete_chain(request: Request, chain_id: str) -> None:
deleted = await store.delete_chain(chain_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Chain not found")
# ── 提示词优化(总控提示词 / 阶段目标)──────────────────────────────────────
# 链条编辑器的「AI 优化」按钮:把用户当前写的提示词 + 链条结构发来,单次直连
# LLM 重写后返回全文(同 suggestions 路由的直调模式,不走 agent / loopback)。
class OptimizePromptRequest(BaseModel):
prompt_text: str = Field(min_length=1, max_length=8000, description="用户当前写的提示词原文")
kind: str = Field(default="coordinator", description="coordinator=总控提示词 | stage_goal=阶段目标")
context: str = Field(default="", max_length=8000, description="链条结构等上下文(标题 / 各 Stage 席位 / 目标)")
model_name: str | None = Field(default=None, description="可选模型覆盖,缺省用 config models[0]")
class OptimizePromptResponse(BaseModel):
optimized: str
_OPTIMIZE_SYSTEM_COORDINATOR = (
"你是「多智能体业务链条」的提示词优化专家。用户在业务链条编辑器里给总控(协调)智能体写了一段编排提示词——"
"总控会按它逐阶段给各席位智能体派活;第 3 步的方案总结报告也会把它当作最高优先级口径(决定总结的结构与重点)。\n\n"
"请在保留用户原意的前提下重写这段提示词,让它更明确、更可执行:\n"
"1. 明确派活方式:各阶段先后顺序、每阶段的产出目标、对上一阶段结论的承接要求;\n"
"2. 明确交付口径:各席位交付聚焦本职、避免重复劳动,要求显式给出结论而非泛泛而谈;\n"
"3. 若原文流露了对最终总结的期望(输出结构、重点席位、风格详略),把它整理成明确条目;若没有,不要凭空替用户添加;\n"
"4. 语言精炼、命令式、分点表述。\n\n"
"硬性要求:只输出优化后的提示词全文(纯文本,可用换行与编号),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 800 字。"
)
_OPTIMIZE_SYSTEM_STAGE_GOAL = (
"你是「多智能体业务链条」的提示词优化专家。用户在链条编辑器里为某一个 Stage(阶段)写了「本阶段目标」——"
"总控按它给本阶段的席位派活,并要求本阶段交付承接上一阶段产出。\n\n"
"请在保留用户原意的前提下重写这段阶段目标,让它更明确、更可执行:点明本阶段要产出什么(交付物 / 结论形态)、"
"怎么承接上一阶段产出、本阶段各席位分别聚焦什么(如上下文给了席位名单)。语言精炼,两到四句话即可。\n\n"
"硬性要求:只输出优化后的阶段目标全文(纯文本),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 200 字。"
)
def _extract_llm_text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts) if parts else ""
if content is None:
return ""
return str(content)
def _strip_code_fence(text: str) -> str:
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
return "\n".join(lines[1:-1]).strip()
return stripped
@router.post("/optimize-prompt", response_model=OptimizePromptResponse)
async def optimize_chain_prompt(
body: OptimizePromptRequest,
config: AppConfig = Depends(get_config),
) -> OptimizePromptResponse:
system = _OPTIMIZE_SYSTEM_STAGE_GOAL if body.kind == "stage_goal" else _OPTIMIZE_SYSTEM_COORDINATOR
user_parts: list[str] = []
if body.context.strip():
user_parts.append(f"【业务链条结构】\n{body.context.strip()}")
user_parts.append(f"【待优化的提示词原文】\n{body.prompt_text.strip()}")
user_parts.append("请输出优化后的提示词全文。")
try:
model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=config)
response = await model.ainvoke(
[SystemMessage(content=system), HumanMessage(content="\n\n".join(user_parts))],
config={"run_name": "chain_prompt_optimize"},
)
text = _strip_code_fence(_extract_llm_text(response.content))
except Exception as exc: # 模型 / 网络故障 → 502,前端 toast 原样展示
logger.exception("chain prompt optimize failed: %s", exc)
raise HTTPException(status_code=502, detail=f"大模型调用失败:{exc}") from exc
if not text:
raise HTTPException(status_code=502, detail="大模型未返回内容")
return OptimizePromptResponse(optimized=text)
@@ -61,6 +61,9 @@ class StartJobRequest(BaseModel):
model: str | None = None
orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None
# dag 模式分层编排计划(前端 OrchestrationPlan:{mode,stages,finalSynthesis,coordinatorPrompt?})。
# 仅 orchestrationMode=="dag" 时携带;驱动后端 _run_dag。缺失 → 引擎回退 recommend。
orchestrationPlan: dict[str, Any] | None = None
# 第一轮 leader prompt;缺省用 intent。
seedMessage: str | None = None
# 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。
@@ -96,6 +99,7 @@ class RoundtableJobResponse(BaseModel):
pendingClarification: str | None = None
orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None
orchestrationPlan: dict[str, Any] | None = None
error: str | None = None
dialogues: list[dict[str, Any]] = Field(default_factory=list)
step3: dict[str, Any] | None = None
@@ -148,6 +152,7 @@ def _to_response(row: dict[str, Any]) -> RoundtableJobResponse:
pendingClarification=row.get("pending_clarification"),
orchestrationMode=row.get("orchestration_mode") or "recommend",
chain=row.get("chain"),
orchestrationPlan=row.get("orchestration_plan"),
error=row.get("error"),
dialogues=row.get("dialogues") or [],
step3=row.get("step3"),
@@ -187,6 +192,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
coordinator_name=body.coordinatorName,
thread_ids=body.threadIds,
chain=body.chain,
orchestration_plan=body.orchestrationPlan,
dispatch_chain=_pending_nodes(body.agents),
status="queued",
)
@@ -204,6 +210,7 @@ async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobRes
coordinator_name=body.coordinatorName,
draft_id=body.draftId,
chain=body.chain,
orchestration_plan=body.orchestrationPlan,
)
)
return _to_response(row)
@@ -289,6 +296,7 @@ async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> R
draft_id=row.get("draft_id"),
is_resume=True,
chain=row.get("chain"),
orchestration_plan=row.get("orchestration_plan"),
)
)
return _to_response(updated or row)
+21 -12
View File
@@ -10,18 +10,18 @@ gateway:
port: 8001
models:
# - name: deepseek-chat
# display_name: DeepSeek Chat
# use: langchain_openai:ChatOpenAI
# model: deepseek-chat
# api_key: sk-3fee562f834a4c08bb0d471ef44cb445
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: sk-3fee562f834a4c08bb0d471ef44cb445
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
# - name: zai-org/GLM-5-FP8
# display_name: zai-org/GLM-5-FP8
@@ -379,6 +379,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} 个章节"},
],
}
@@ -20,13 +20,18 @@ from .types import (
CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT,
MAX_CYCLES,
DagPlan,
DagStage,
LeaderTurn,
ReportResult,
RoundtableGateway,
SeatRef,
SeatTurn,
build_chain_leader_task,
build_dag_stage_leader_task,
parse_dag_plan,
pick_chain_task,
pick_dag_seat_task,
)
__all__ = [
@@ -40,9 +45,14 @@ __all__ = [
"SeatTurn",
"ReportResult",
"SeatRef",
"DagStage",
"DagPlan",
"MAX_CYCLES",
"CONTINUE_PROMPT",
"CHAIN_CLOSEOUT_PROMPT",
"build_chain_leader_task",
"pick_chain_task",
"build_dag_stage_leader_task",
"parse_dag_plan",
"pick_dag_seat_task",
]
@@ -18,6 +18,7 @@
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
@@ -27,11 +28,15 @@ from .types import (
CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT,
MAX_CYCLES,
DagPlan,
ReportResult,
RoundtableGateway,
SeatRef,
build_chain_leader_task,
build_dag_stage_leader_task,
parse_dag_plan,
pick_chain_task,
pick_dag_seat_task,
)
logger = logging.getLogger(__name__)
@@ -73,6 +78,7 @@ async def run_orchestration(
mode: str = "recommend",
intent_text: str = "",
enable_report: bool = True,
plan: dict | None = None,
) -> OrchestrationResult:
"""运行一次完整编排(循环 → 共识 → 报告绘制)。
@@ -84,8 +90,10 @@ async def run_orchestration(
coordinator_name: 总控 agent_id(如 "roundtable-coordinator")。
model: 选定模型(None/"" 走后端默认)。
seed_message: 第一轮 leader 的 prompt(启动=初始意图;resume=用户答案续轮)。
mode: "recommend" | "chain"。
mode: "recommend" | "chain" | "dag"。
intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。
plan: dag 模式的分层编排计划(前端 ``OrchestrationPlan`` dict)。mode=="dag" 且
可解析出非空 stages 时走 ``_run_dag``;否则(dag 但 plan 缺失/为空)回退 recommend。
enable_report: 是否在收口后执行 Step3 报告绘制。False 时只跑完 Step2 研讨即收尾、
不生成报告(step3=None)。当前「后台挂起」临时禁用报告(进程内驱动报告 agent
在真模型下仍无法稳定写出 HTML,详见 multi-agent-background-run-遗留问题与下次继续.md)。
@@ -93,9 +101,13 @@ async def run_orchestration(
intent = (intent_text or seed_message or "").strip()
try:
await progress.begin()
if mode == "chain":
dag_plan = parse_dag_plan(plan) if mode == "dag" else None
if mode == "dag" and dag_plan is not None:
outcome = await _run_dag(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent, dag_plan)
elif mode == "chain":
outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent)
else:
# recommend,或 dag 但 plan 缺失/为空 → 回退总控自由派活(绝不静默跑错语义)。
outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message, intent)
if outcome.kind == "clarification":
@@ -314,6 +326,108 @@ async def _run_chain(
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# ── dag 模式(分层 DAG:stage 间串行、stage 内并行)──────────────────────────────
async def _run_dag(
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
intent_text: str,
plan: DagPlan,
) -> _LoopOutcome:
"""分层 DAG 编排:每个 stage 一轮总控派活(钉死本批席位)→ 批内席位**并行**执行
(``asyncio.gather``)→ 下一 stage 总控再派;全部 stage 完成后由总控收口综合。
与前端 ``runDagOrchestration``「总控驱动每 stage 派活」同构(§12.4)。并行席位各自独立
thread(``run_seat`` 内 ``thread_ids[agent_id]``),故进程内 gather 安全、无沙箱串台。
"""
deliveries: dict[str, str] = {}
last_leader = ""
total = len(plan.stages)
name_by_id = {a.agent_id: a.name for a in agents}
def _seat_ref(agent_id: str) -> SeatRef:
return SeatRef(agent_id, name_by_id.get(agent_id, agent_id))
for i, stage in enumerate(plan.stages):
stage_seats = [_seat_ref(aid) for aid in stage.agent_ids]
if not stage_seats:
continue
# (a) 总控「派活轮」:钉死本 stage 席位,带任务背景 / 本阶段目标 / 总控编排指引 + 此前交付。
await progress.leader_thinking(i + 1)
leader_task = build_dag_stage_leader_task(
seed_message=seed_message,
stage_index=i,
total_stages=total,
seats=stage_seats,
is_first=(i == 0),
goal=stage.goal,
coordinator_prompt=plan.coordinator_prompt,
)
block = _deliveries_block(deliveries, name_by_id)
if block:
leader_task += f"\n\n此前各阶段席位已完成的交付如下(供你承上启下、为本批席位派活):\n{block}"
turn = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=leader_task,
model=model,
)
if turn.final_text:
last_leader = turn.final_text
await progress.add_leader_dialogue(
turn.final_text, i + 1, tool_calls=turn.tool_calls, thread_id=thread_ids.get(coordinator_name)
)
if turn.is_clarification:
return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries)
# (b) 批内并行:每个钉死席位拿到总控派给它的 task(漏派则给干净本职任务),并行执行。
await progress.dispatching(i + 1)
for seat in stage_seats:
await progress.seat_active(seat.agent_id)
async def _run_one(seat: SeatRef) -> None:
task = pick_dag_seat_task(turn.dispatched, seat.agent_id, seat.name, goal=stage.goal)
seat_msg = _seat_message(intent_text, deliveries, name_by_id, task)
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=seat_msg, model=model)
deliveries[seat.agent_id] = seat_turn.final_text
await progress.add_seat_dialogue(
seat.agent_id, seat.name, seat_turn.final_text, i + 1,
tool_calls=seat_turn.tool_calls, thread_id=thread_ids.get(seat.agent_id),
)
await progress.seat_done(seat.agent_id, i + 1)
await asyncio.gather(*(_run_one(seat) for seat in stage_seats))
if total > 0:
await progress.set_consensus_pct(round((i + 1) / total * 90))
# (c) 收口:finalSynthesis 时总控综合全部 stage 交付,产出最终结论。
if plan.final_synthesis and total > 0:
await progress.leader_thinking(total + 1)
block = _deliveries_block(deliveries, name_by_id)
closeout_msg = CHAIN_CLOSEOUT_PROMPT + (f"\n\n各阶段席位完整交付如下:\n{block}" if block else "")
closeout = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=closeout_msg,
model=model,
)
if closeout.final_text:
last_leader = closeout.final_text
await progress.add_leader_dialogue(
closeout.final_text, total + 1, tool_calls=closeout.tool_calls, thread_id=thread_ids.get(coordinator_name)
)
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# ── 报告阶段 ─────────────────────────────────────────────────────────────────
@@ -37,9 +37,10 @@ class SimulatedRoundtableGateway:
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
await self._tick()
self._leader_calls += 1
if self._mode == "chain":
# chain 模式由引擎按链条顺序强制跑席位,leader 只给说明、不派活。
return LeaderTurn(final_text="(模拟)总控:请当前席位按职责给出分析。", dispatched=[])
if self._mode in ("chain", "dag"):
# chain / dag 模式由引擎按计划钉死席位强制执行,leader 只给说明、不显式派活
# (引擎的 pick_*_task 会回退给席位干净本职任务)。
return LeaderTurn(final_text="(模拟)总控:请本阶段席位按职责给出分析。", dispatched=[])
# recommend:首轮派活全部席位,次轮共识。
if self._leader_calls == 1:
return LeaderTurn(
@@ -14,7 +14,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
from typing import Any, Protocol, runtime_checkable
# 编排循环最大轮次,与前端 roundtable-constants.ts MAX_CYCLES 对齐。
MAX_CYCLES = 8
@@ -195,3 +195,137 @@ def pick_chain_task(
f"请你作为「{seat_name}」,基于任务背景与此前各席位的交付,"
"从你的专业职责出发给出你的分析与交付。"
)
# ── dag 模式:分层编排计划 + prompt 构造(移植自前端 dag-types.ts)─────────────────
#
# DAG = 有序的 stage 列表:stage 间串行、stage 内并行。线性链是其特例(每层 1 席位)。
# 与前端 `runDagOrchestration`「总控驱动每 stage 派活」同构(§3.2 方案 A + 批内并行):
# 每个 stage 一轮总控派活(钉死本批席位)→ 批内席位并行执行 → 下一 stage 总控再派。
@dataclass(frozen=True)
class DagStage:
"""一个并行批次:``agent_ids`` 内的席位并行执行;``goal`` = 用户写的本阶段目标(可空)。"""
agent_ids: list[str]
goal: str | None = None
@property
def is_parallel(self) -> bool:
return len(self.agent_ids) > 1
@dataclass(frozen=True)
class DagPlan:
"""用户编排出的执行计划。``stages`` 顺序即串行顺序。"""
stages: list[DagStage]
final_synthesis: bool = True
coordinator_prompt: str | None = None
def parse_dag_plan(raw: Any) -> DagPlan | None:
"""把前端 ``OrchestrationPlan`` 形状的 dict 解析成 ``DagPlan``(容错)。
无 stages / 全空 stage → 返回 None(调用方据此回退 recommend)。只保留有 agentId 的 stage。
"""
if not isinstance(raw, dict):
return None
stages_raw = raw.get("stages")
if not isinstance(stages_raw, list):
return None
stages: list[DagStage] = []
for s in stages_raw:
if not isinstance(s, dict):
continue
ids_raw = s.get("agentIds") or s.get("agent_ids") or []
ids = [str(a) for a in ids_raw if str(a or "").strip()]
if not ids:
continue
goal = s.get("goal")
goal_text = str(goal).strip() if isinstance(goal, str) and goal.strip() else None
stages.append(DagStage(agent_ids=ids, goal=goal_text))
if not stages:
return None
final_synthesis = raw.get("finalSynthesis")
if final_synthesis is None:
final_synthesis = raw.get("final_synthesis", True)
cp = raw.get("coordinatorPrompt") or raw.get("coordinator_prompt")
cp_text = str(cp).strip() if isinstance(cp, str) and cp.strip() else None
return DagPlan(stages=stages, final_synthesis=bool(final_synthesis), coordinator_prompt=cp_text)
def build_dag_stage_leader_task(
*,
seed_message: str,
stage_index: int,
total_stages: int,
seats: list[SeatRef],
is_first: bool,
goal: str | None = None,
coordinator_prompt: str | None = None,
) -> str:
"""构造 dag「每 stage 派活轮」leader prompt:约束总控只对本 stage 钉死席位派活。
对齐前端 ``buildDagStageLeaderTask``:首 stage 带任务背景,后续 stage 靠后端注入的此前
交付摘要承上;带「本阶段目标」与「总控编排指引(用户预设,最高优先)」。
"""
ordinal = f"{stage_index + 1}/{total_stages}"
seat_list = "、".join(f"「{s.name}」(agent_name: {s.agent_id})" for s in seats)
is_parallel = len(seats) > 1
goal_text = (goal or "").strip()
coord_text = (coordinator_prompt or "").strip()
lines: list[str] = []
if is_first:
lines += ["这是一次【分层 DAG 编排】的多智能体圆桌研讨。任务背景如下:", seed_message, ""]
else:
lines += ["分层研讨继续。系统已把此前各阶段席位的交付摘要注入到你本轮输入里,请据此承上启下。", ""]
if coord_text:
lines += [
"【总控编排指引(用户预设,最高优先)】以下是本次业务链条为你(总控)预设的整体派活原则,请在每一阶段派活时都严格遵循:",
coord_text,
"",
]
lines += [
f"本研讨严格按预设分层顺序进行,共 {total_stages} 个阶段,现在是第 {ordinal} 个阶段。",
f"本阶段需要执行的席位(已由编排计划钉死)是:{seat_list}。",
]
if goal_text:
lines += [
f"【本阶段目标】{goal_text}",
"请据此目标为每个席位拆解任务,并要求他们显式承接上一阶段的产出来完成本阶段目标。",
]
lines += [
f"请你作为总控,通过 agent_orchestration 技能**分别**向上述{'这些席位' if is_parallel else '席位'}派活,"
f"结合任务背景{'、本阶段目标' if goal_text else ''}与此前交付,为每个席位分配聚焦其职责的本阶段任务。",
"只派给上述席位、且每个都必须派到;不要派给清单外的任何席位,也不要自己直接作答或给出最终结论。",
]
if is_parallel:
lines.append(f"注意:本阶段这 {len(seats)} 个席位会并行执行,请确保给每个席位的 task 都能独立完成,不要让它们互相等待。")
return "\n".join(lines)
def pick_dag_seat_task(
dispatched: list[tuple[str, str]],
seat_agent_id: str,
seat_name: str,
*,
goal: str | None = None,
) -> str:
"""从总控「每 stage 派活轮」终态取派给某钉死席位的 task(移植自前端 pickDagSeatTask)。
1) 命中 agent_name 完全一致的派活 → 用它的 task;
2) 未命中 / 总控只写文字没派活 → **不**把总控派活叙述喂给席位(防席位模仿派活),改给干净
本职任务(任务背景 + 此前交付由 ``_seat_message`` 另行注入)。
两条分支都带「本阶段目标」兜底。
"""
goal_rule = f"\n【本阶段目标】{goal.strip()}\n请确保你的交付服务于该目标,并显式承接上一阶段的产出。" if (goal or "").strip() else ""
wanted = seat_agent_id.lower()
for name, task in dispatched:
if name.lower() == wanted and (task or "").strip():
return f"请你作为「{seat_name}」,完成总控派给你的本阶段任务:\n{task.strip()}{goal_rule}"
return (
f"请你作为「{seat_name}」,从你的专业职责出发,独立完成本阶段属于你的分析与交付。"
f"此前各阶段席位的交付已在上下文里,请据此承接。{goal_rule}"
)
@@ -0,0 +1,23 @@
"""Light-app registry persistence (轻应用中心)."""
from __future__ import annotations
from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore
from deerflow.persistence.light_apps.memory import MemoryLightAppStore
from deerflow.persistence.light_apps.model import LightAppRow
from deerflow.persistence.light_apps.sql import LightAppRepository
__all__ = [
"DuplicateLightAppNameError",
"LightAppRepository",
"LightAppRow",
"LightAppStore",
"MemoryLightAppStore",
"make_light_app_store",
]
def make_light_app_store(session_factory):
if session_factory is None:
return MemoryLightAppStore()
return LightAppRepository(session_factory)
@@ -0,0 +1,46 @@
"""Abstract light-app registry persistence API.
Global / shared store: no ``user_id`` scoping. Reads are open to any caller; the
router gates writes behind admin. ``created_by`` / ``updated_by`` are audit-only.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class DuplicateLightAppNameError(ValueError):
"""Raised when creating/updating an app would collide on the unique name."""
class LightAppStore(ABC):
@abstractmethod
async def list_apps(
self,
*,
app_type: str | None = None,
mount_location: str | None = None,
enabled_only: bool = False,
) -> list[dict[str, Any]]:
"""Return registered apps (newest first), optionally filtered."""
raise NotImplementedError
@abstractmethod
async def get_app(self, app_id: str) -> dict[str, Any] | None:
raise NotImplementedError
@abstractmethod
async def create_app(self, data: dict[str, Any]) -> dict[str, Any]:
"""Create an app. Raises ``DuplicateLightAppNameError`` on a name clash."""
raise NotImplementedError
@abstractmethod
async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Partial update. Returns None if the app does not exist. Raises
``DuplicateLightAppNameError`` when renaming onto an existing name."""
raise NotImplementedError
@abstractmethod
async def delete_app(self, app_id: str) -> bool:
raise NotImplementedError
@@ -0,0 +1,97 @@
"""In-memory light-app registry store (used when no DB is configured / tests)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore
_WRITABLE = {
"name",
"description",
"app_type",
"url",
"route_params",
"mount_location",
"icon",
"enabled",
"sort_order",
"updated_by",
}
class MemoryLightAppStore(LightAppStore):
def __init__(self) -> None:
self._apps: dict[str, dict[str, Any]] = {}
@staticmethod
def _public(row: dict[str, Any]) -> dict[str, Any]:
out = dict(row)
out["route_params"] = list(out.get("route_params") or [])
return out
def _name_taken(self, name: str, *, exclude_id: str | None = None) -> bool:
return any(r["name"] == name and r["app_id"] != exclude_id for r in self._apps.values())
async def list_apps(
self,
*,
app_type: str | None = None,
mount_location: str | None = None,
enabled_only: bool = False,
) -> list[dict[str, Any]]:
rows = list(self._apps.values())
if app_type:
rows = [r for r in rows if r["app_type"] == app_type]
if mount_location:
rows = [r for r in rows if r.get("mount_location") == mount_location]
if enabled_only:
rows = [r for r in rows if r.get("enabled", True)]
rows.sort(key=lambda r: (r.get("sort_order", 0), r.get("created_at", "")))
return [self._public(r) for r in rows]
async def get_app(self, app_id: str) -> dict[str, Any] | None:
row = self._apps.get(app_id)
return self._public(row) if row is not None else None
async def create_app(self, data: dict[str, Any]) -> dict[str, Any]:
if self._name_taken(data["name"]):
raise DuplicateLightAppNameError(data["name"])
now = datetime.now(UTC).isoformat()
app_id = data.get("app_id") or uuid.uuid4().hex[:12]
row = {
"app_id": app_id,
"name": data["name"],
"description": data.get("description"),
"app_type": data["app_type"],
"url": data["url"],
"route_params": data.get("route_params") or [],
"mount_location": data.get("mount_location"),
"icon": data.get("icon"),
"enabled": data.get("enabled", True),
"sort_order": data.get("sort_order", 0),
"created_by": data.get("created_by"),
"updated_by": data.get("created_by"),
"created_at": now,
"updated_at": now,
}
self._apps[app_id] = row
return self._public(row)
async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
row = self._apps.get(app_id)
if row is None:
return None
new_name = data.get("name")
if new_name is not None and new_name != row["name"] and self._name_taken(new_name, exclude_id=app_id):
raise DuplicateLightAppNameError(new_name)
for key, value in data.items():
if key in _WRITABLE:
row[key] = value
row["updated_at"] = datetime.now(UTC).isoformat()
return self._public(row)
async def delete_app(self, app_id: str) -> bool:
return self._apps.pop(app_id, None) is not None
@@ -0,0 +1,42 @@
"""ORM model for the light-app registry (轻应用中心).
Global / shared — there is no ``user_id`` scoping; every account sees the same
registered apps. ``created_by`` / ``updated_by`` are audit-only.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class LightAppRow(Base):
__tablename__ = "light_apps"
__table_args__ = (UniqueConstraint("name", name="uq_light_apps_name"),)
app_id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(127))
description: Mapped[str | None] = mapped_column(Text)
# "window_open" (open in a new tab) | "iframe" (embed under a parent menu)
app_type: Mapped[str] = mapped_column(String(32))
url: Mapped[str] = mapped_column(Text)
# List of route-param descriptors resolved at launch time by the frontend:
# {"key": str, "paramType": "login"|"theme", "source": str,
# "customValue": str, "themeValue": str}
route_params_json: Mapped[list] = mapped_column(PortableJSON(), default=list)
# iframe only: the parent sidebar menu id to mount under
# (e.g. "xk-center-task" / "pg-center-task" / "ty-center-task" /
# "comprehensive-management"). NULL for window_open apps.
mount_location: Mapped[str | None] = mapped_column(String(64), index=True)
icon: Mapped[str | None] = mapped_column(String(64))
enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
created_by: Mapped[str | None] = mapped_column(String(64))
updated_by: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -0,0 +1,135 @@
"""SQLAlchemy-backed light-app registry store (global / shared)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.light_apps.base import DuplicateLightAppNameError, LightAppStore
from deerflow.persistence.light_apps.model import LightAppRow
# Columns a caller may set/patch (everything except identity + timestamps).
_WRITABLE = {
"name",
"description",
"app_type",
"url",
"route_params",
"mount_location",
"icon",
"enabled",
"sort_order",
"updated_by",
}
def _to_dict(row: LightAppRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
data["route_params"] = data.pop("route_params_json", None) or []
return data
def _column_for(key: str) -> str:
return "route_params_json" if key == "route_params" else key
class LightAppRepository(LightAppStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_apps(
self,
*,
app_type: str | None = None,
mount_location: str | None = None,
enabled_only: bool = False,
) -> list[dict[str, Any]]:
stmt = select(LightAppRow)
if app_type:
stmt = stmt.where(LightAppRow.app_type == app_type)
if mount_location:
stmt = stmt.where(LightAppRow.mount_location == mount_location)
if enabled_only:
stmt = stmt.where(LightAppRow.enabled.is_(True))
stmt = stmt.order_by(LightAppRow.sort_order.asc(), LightAppRow.created_at.desc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_to_dict(row) for row in result.scalars()]
async def get_app(self, app_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(LightAppRow, app_id)
return _to_dict(row) if row is not None else None
async def _name_taken(self, session: AsyncSession, name: str, *, exclude_id: str | None = None) -> bool:
stmt = select(LightAppRow.app_id).where(LightAppRow.name == name)
if exclude_id is not None:
stmt = stmt.where(LightAppRow.app_id != exclude_id)
result = await session.execute(stmt.limit(1))
return result.first() is not None
async def create_app(self, data: dict[str, Any]) -> dict[str, Any]:
row = LightAppRow(
app_id=data.get("app_id") or uuid.uuid4().hex[:12],
name=data["name"],
description=data.get("description"),
app_type=data["app_type"],
url=data["url"],
route_params_json=data.get("route_params") or [],
mount_location=data.get("mount_location"),
icon=data.get("icon"),
enabled=data.get("enabled", True),
sort_order=data.get("sort_order", 0),
created_by=data.get("created_by"),
updated_by=data.get("created_by"),
)
async with self._sf() as session:
if await self._name_taken(session, row.name):
raise DuplicateLightAppNameError(row.name)
session.add(row)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise DuplicateLightAppNameError(row.name) from exc
await session.refresh(row)
return _to_dict(row)
async def update_app(self, app_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(LightAppRow, app_id)
if row is None:
return None
new_name = data.get("name")
if new_name is not None and new_name != row.name and await self._name_taken(session, new_name, exclude_id=app_id):
raise DuplicateLightAppNameError(new_name)
for key, value in data.items():
if key not in _WRITABLE:
continue
setattr(row, _column_for(key), value)
row.updated_at = datetime.now(UTC)
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise DuplicateLightAppNameError(new_name or row.name) from exc
await session.refresh(row)
return _to_dict(row)
async def delete_app(self, app_id: str) -> bool:
async with self._sf() as session:
row = await session.get(LightAppRow, app_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
@@ -0,0 +1,47 @@
"""Add orchestration_plan column to roundtable_jobs.
后台挂起支持分层 DAG 编排(业务链并行):把前端 ``OrchestrationPlan``(stages /
阶段目标 / 总控提示词 / finalSynthesis)整份存进 job 行,驱动后端 ``_run_dag``
(stage 串行、stage 内并行)+ 前端大弹窗按 stage 分层画流程图 + resume 时重建计划。
LONGTEXT(计划 JSON 可超 64 KB)。
Revision ID: 20260612_01
Revises: 20260611_01
Create Date: 2026-06-12
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.mysql import LONGTEXT
revision = "20260612_01"
down_revision = "20260611_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if _has_table("roundtable_jobs") and not _has_column("roundtable_jobs", "orchestration_plan"):
col_type = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
op.add_column("roundtable_jobs", sa.Column("orchestration_plan", col_type, nullable=True))
def downgrade() -> None:
if _has_column("roundtable_jobs", "orchestration_plan"):
op.drop_column("roundtable_jobs", "orchestration_plan")
@@ -36,6 +36,7 @@ from deerflow.knowledge.models import (
KnowledgeSourceRow,
KnowledgeTagRow,
)
from deerflow.persistence.light_apps.model import LightAppRow
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.models.run_event import RunEventRow
from deerflow.persistence.notifications.model import NotificationRow
@@ -83,6 +84,7 @@ __all__ = [
"KnowledgeRelationRow",
"KnowledgeSourceRow",
"KnowledgeTagRow",
"LightAppRow",
"LlmCallMetricRow",
"NotificationRow",
"ParallelAgentTaskRow",
@@ -51,10 +51,14 @@ class RoundtableJobRow(Base):
# ThreadIdMap: JSON object { "<agent_id>": "<thread_id>", ... }.
thread_ids: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
coordinator_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
# "recommend"(总控自由派活)| "chain"(按业务链条顺序派活).
# "recommend"(总控自由派活)| "chain"(按业务链条顺序派活)| "dag"(分层 DAG:stage 间串行、stage 内并行).
orchestration_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="recommend")
# chain 模式来源链条快照:JSON object { id, title }(recommend 模式为 null).
chain: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# dag 模式分层编排计划:JSON object { mode, stages:[{id,agentIds,goal?}], finalSynthesis,
# coordinatorPrompt? }(对齐前端 OrchestrationPlan)。仅 dag 模式有值;驱动后端 _run_dag
# (stage 串行 / stage 内并行)+ 前端大弹窗按 stage 分层画流程图 + resume 时重建计划。
orchestration_plan: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
# 研讨对话记录:JSON array of { id, role('leader'|'seat'), agentId, name, content, cycle }。
# 让用户跑完 / 进行中都能看到真实研讨内容(leader 说明 + 各席位交付),而非只有流程图。
dialogues: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
@@ -55,7 +55,7 @@ class RoundtableJobRepository:
for k in ("created_at", "updated_at"):
if isinstance(d.get(k), datetime):
d[k] = d[k].isoformat()
for k in ("dispatch_chain", "thread_ids", "chain", "dialogues", "step3"):
for k in ("dispatch_chain", "thread_ids", "chain", "orchestration_plan", "dialogues", "step3"):
d[k] = _loads(d.get(k))
return d
@@ -71,6 +71,7 @@ class RoundtableJobRepository:
coordinator_name: str | None = None,
thread_ids: dict[str, str] | None = None,
chain: dict[str, Any] | None = None,
orchestration_plan: dict[str, Any] | None = None,
dispatch_chain: list[dict[str, Any]] | None = None,
status: str = "queued",
phase: str = "initializing",
@@ -92,6 +93,7 @@ class RoundtableJobRepository:
coordinator_name=coordinator_name,
orchestration_mode=orchestration_mode,
chain=_dumps(chain),
orchestration_plan=_dumps(orchestration_plan),
error=None,
created_at=now,
updated_at=now,
@@ -0,0 +1,110 @@
"""后台挂起「分层 DAG」实测脚本:带一个并行 stage 的业务链。
POST 启动一个 dag 作业(stage1 两席位并行 → stage2 → stage3),轮询打印每帧的
dispatchChain 节点状态,直观看到:①stage1 两席位**同时 active**(并行);②stage2/3
在前一 stage 全 done 后才 active(串行);③orchestrationPlan 在响应里回得来(持久化生效)。
用法:PYTHONPATH=. .venv/Scripts/python scripts/probe_roundtable_dag_job.py
环境:GATEWAY_URL 默认 http://127.0.0.1:8001;AUTH_COOKIE 可选。
"""
from __future__ import annotations
import asyncio
import os
import time
import httpx
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://127.0.0.1:8001").rstrip("/")
AUTH_COOKIE = os.getenv("AUTH_COOKIE", "")
AUTH_BEARER = os.getenv("AUTH_BEARER", "")
# 4 个已 seed 的圆桌席位。stage1 = 风险审查 ‖ 方案设计(并行),stage2 = 执行规划,stage3 = 总结。
AGENTS = [
{"agentId": "roundtable-risk-review", "name": "风险审查"},
{"agentId": "roundtable-solution-design", "name": "方案设计"},
{"agentId": "roundtable-execution-plan", "name": "执行规划"},
{"agentId": "roundtable-summary", "name": "方案总结"},
]
PLAN = {
"mode": "dag",
"stages": [
{"id": "s0", "agentIds": ["roundtable-risk-review", "roundtable-solution-design"], "goal": "并行评估风险并产出初步方案"},
{"id": "s1", "agentIds": ["roundtable-execution-plan"], "goal": "据风险与方案制定执行规划"},
{"id": "s2", "agentIds": ["roundtable-summary"]},
],
"finalSynthesis": True,
"coordinatorPrompt": "先并行评估、再规划执行、最后总结,逐阶段承接。",
}
INTENT = "为一家中型制造企业设计一套数字化转型的整体方案"
TERMINAL = {"done", "error", "cancelled"}
def _headers() -> dict[str, str]:
h = {"Content-Type": "application/json"}
if AUTH_COOKIE:
h["Cookie"] = AUTH_COOKIE
if AUTH_BEARER:
h["Authorization"] = f"Bearer {AUTH_BEARER}"
return h
def _chain_str(nodes: list[dict]) -> str:
sym = {"pending": ".", "active": ">RUN", "done": "DONE"}
return " ".join(f"{n['name']}={sym.get(n['state'], '?')}" for n in nodes)
async def main() -> None:
async with httpx.AsyncClient(timeout=30.0, trust_env=False) as client:
print(f"[probe] POST {GATEWAY_URL}/api/roundtable-jobs mode=dag (stage1 并行)")
resp = await client.post(
f"{GATEWAY_URL}/api/roundtable-jobs",
headers=_headers(),
json={"intent": INTENT, "agents": AGENTS, "orchestrationMode": "dag", "orchestrationPlan": PLAN},
)
if resp.status_code != 201:
print(f"[probe] start failed: HTTP {resp.status_code}: {resp.text[:500]}")
return
job = resp.json()
job_id = job["id"]
# 验证 plan 在响应里回得来(持久化 + _to_response 生效)
plan_back = job.get("orchestrationPlan")
print(f"[probe] started {job_id} orchestrationMode={job.get('orchestrationMode')} "
f"orchestrationPlan stages={len((plan_back or {}).get('stages') or [])}")
last = ""
t0 = time.perf_counter()
saw_parallel = False
while True:
await asyncio.sleep(1.0)
r = await client.get(f"{GATEWAY_URL}/api/roundtable-jobs/{job_id}", headers=_headers())
if r.status_code != 200:
print(f"[probe] get failed: HTTP {r.status_code}: {r.text[:300]}")
return
j = r.json()
nodes = j.get("dispatchChain") or []
active_seats = [n["name"] for n in nodes if n["state"] == "active"]
if len(active_seats) >= 2:
saw_parallel = True
line = f"status={j['status']} phase={j['phase']} consensus={j['consensusPercentage']}% [{_chain_str(nodes)}]"
if line != last:
print(f"[probe] +{time.perf_counter() - t0:5.1f}s {line}")
last = line
if j["status"] in TERMINAL:
print(f"\n[probe] terminal: {j['status']}")
if j["status"] == "error":
print(f"[probe] error detail: {j.get('error')}")
# 对话顺序证明 stage 串行
seats = [d for d in (j.get("dialogues") or []) if d["role"] == "seat"]
print(f"[probe] 席位交付顺序: {[d['agentId'] for d in seats]}")
leaders = [d for d in (j.get("dialogues") or []) if d["role"] == "leader"]
print(f"[probe] 总控发言轮数: {len(leaders)}(3 stage 派活 + 1 收口 = 4)")
print(f"[probe] 是否观察到 stage 内并行(≥2 席位同时 active): {'是 ✅' if saw_parallel else '否 ❌'}")
sample = next((d["content"] for d in seats if d.get("content")), "")
print(f"[probe] 席位交付样例: {sample[:80]}")
return
if __name__ == "__main__":
asyncio.run(main())
@@ -77,6 +77,11 @@ CALL _add_column_if_missing('agents', 'square_id', "VARCHAR(64) NOT NULL DEFAULT
-- String(128), nullable=True, index=True。
CALL _add_column_if_missing('roundtable_drafts', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL');
-- orchestration_plan: dag 模式分层编排计划 JSON(stages/阶段目标/总控提示词/finalSynthesis),
-- 对应 migration 20260612_01。驱动后端 _run_dag + 前端大弹窗分层流程图。
-- ORM: RoundtableJobRow.orchestration_plan = PortableLongText(LONGTEXT), nullable。
CALL _add_column_if_missing('roundtable_jobs', 'orchestration_plan', 'LONGTEXT NULL DEFAULT NULL');
-- ---- 清理临时存储过程 --------------------------------------------------------
DROP PROCEDURE IF EXISTS _add_column_if_missing;