deerflow-code/offline-backend-20260512/backend/app/gateway/routers/_ai_writing_seed.py
2026-09-07 18:24:55 +08:00

192 lines
7.9 KiB
Python

"""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")
# 内置「知识库检索」技能:素材收集专家通用检索的默认技能。skills/ 目录被
# .gitignore 排除,所以与 agent 目录一样需要 seeder 在缺失时从资源补建。
KNOWLEDGE_SEARCH_SKILL_NAME = "knowledge-base-search"
_SKILL_ASSETS_DIR = _ASSETS_DIR / "skills"
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 _skills_public_dir() -> Path:
"""skills/public 物理目录(config.yaml skills.path 解析);独立成函数便于测试替身。"""
from deerflow.skills.storage import get_or_new_skill_storage
return Path(get_or_new_skill_storage().get_skills_root_path()) / "public"
def _ensure_default_search_skill() -> bool:
"""Seed the built-in knowledge-base-search skill into ``skills/public/``
when missing. Never overwrites an existing skill directory.
Returns True if the skill was just created.
"""
src = _SKILL_ASSETS_DIR / KNOWLEDGE_SEARCH_SKILL_NAME / "SKILL.md"
if not src.exists():
logger.error("Missing seed asset for skill '%s' (expected at %s)", KNOWLEDGE_SEARCH_SKILL_NAME, src)
return False
skill_dir = _skills_public_dir() / KNOWLEDGE_SEARCH_SKILL_NAME
if skill_dir.exists():
return False
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_bytes(src.read_bytes())
return True
def _ensure_researcher_default_skills() -> bool:
"""给**已存在**的 researcher agent config.yaml 补默认技能配置(就地升级)。
seeder 对已存在目录绝不整体覆盖,但旧版种子没有 ``skills`` 字段——通用
检索改为技能驱动后,老部署的智能体管理页会显示"未配置技能"(运行时虽有
knowledge-base-search 兜底,但用户看不见、也没法在它基础上增删)。只在
``skills`` 键**完全缺失**时补默认值;用户显式改过(包括清空成 ``[]``)
的配置不动。Returns True if the file was patched.
"""
import yaml
config_file = get_paths().agent_dir(RESEARCHER_AGENT_ID) / "config.yaml"
if not config_file.exists():
return False
try:
data = yaml.safe_load(config_file.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
logger.warning("researcher config.yaml unparsable, skip default-skills patch")
return False
if not isinstance(data, dict) or "skills" in data:
return False
data["skills"] = [KNOWLEDGE_SEARCH_SKILL_NAME]
config_file.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
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.
Also seeds the built-in knowledge-base-search skill (the researcher's
default retrieval skill) and patches a pre-existing researcher config.yaml
that predates the ``skills`` field.
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))
try:
if _ensure_default_search_skill():
logger.info("Seeded built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME)
except Exception:
logger.exception("Failed to seed built-in skill '%s'", KNOWLEDGE_SEARCH_SKILL_NAME)
try:
if _ensure_researcher_default_skills():
logger.info("Patched researcher config.yaml with default skills")
except Exception:
logger.exception("Failed to patch researcher default skills")
return created
def required_agent_ids() -> Iterable[str]:
"""Stable ids of agents this seeder owns — exposed for tests/diagnostics."""
return tuple(_FUNCTIONAL_AGENT_IDS)