192 lines
7.9 KiB
Python
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)
|