Merge branch 'dhk-1' into 'master'
AI 写作四阶段内置智能体 Phase 1-4 落地 See merge request knowledge-base-bj/deerflow-server!116
This commit is contained in:
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)
|
||||
+42
@@ -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"`。
|
||||
❌ 审「语言文字」维度时挑"数据缺乏素材支撑"的事实性问题 —— 只审当前维度。
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
description: AI 写作流程的编辑,从事实/逻辑/语言三个维度逐项审查文章质量,给出评分与具体修改意见
|
||||
id: ai-writing-editor
|
||||
name: 编辑
|
||||
+80
@@ -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` 里出现素材清单中不存在的编号。
|
||||
❌ 把子要点写成完整句子(如"我们应该分析出口数据的变化趋势。")——应为名词性短语。
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
description: AI 写作流程的大纲规划师,根据写作意图与素材规划文章三级大纲(章节→小节→子要点),并按用户反馈调整已有大纲
|
||||
id: ai-writing-outliner
|
||||
name: 大纲规划师
|
||||
+66
@@ -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"]}`。
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
description: AI 写作流程的素材收集专家,负责为写作任务生成检索关键词、并对检索回来的素材按相关度排序筛选
|
||||
id: ai-writing-researcher
|
||||
name: 素材收集专家
|
||||
+49
@@ -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]] 原因`。
|
||||
❌ 宽松模式下输出"我无法基于现有素材完成" —— 宽松模式永远正常写作,可用通用知识补充。
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
description: AI 写作流程的作家,根据大纲与素材逐章撰写文章正文(三级结构 Markdown),严格模式下素材不足时声明阻塞而不杜撰
|
||||
id: ai-writing-writer
|
||||
name: 作家
|
||||
+31
-6
@@ -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」之类的标签、徽章或水印
|
||||
- ❌ 编造与原料明显矛盾的数据
|
||||
+52
-10
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+269
@@ -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)
|
||||
+70
-18
@@ -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 '打回修改'}",
|
||||
},
|
||||
],
|
||||
|
||||
+107
-26
@@ -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},
|
||||
],
|
||||
}
|
||||
+111
-49
@@ -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,
|
||||
|
||||
+108
-40
@@ -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} 个章节"},
|
||||
],
|
||||
}
|
||||
+10
@@ -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",
|
||||
]
|
||||
+116
-2
@@ -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)
|
||||
|
||||
|
||||
# ── 报告阶段 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
+4
-3
@@ -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(
|
||||
|
||||
+135
-1
@@ -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}"
|
||||
)
|
||||
+23
@@ -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)
|
||||
+46
@@ -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
|
||||
+97
@@ -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
|
||||
+42
@@ -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))
|
||||
+135
@@ -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
|
||||
+47
@@ -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")
|
||||
+2
@@ -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",
|
||||
|
||||
+5
-1
@@ -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)
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user