Files
writer-work-flow/packages/skills/ww_skills/skill_registry.py
Yaojia Wang dca4d45d4e refactor(agents): prompt 外置方案A — 21 prompt 散文外迁 .md + SpecResolver
把 21 个内置 agent 的 system_prompt 从 specs.py 的 Python 常量外置为
prompts/<spec.name>.md,import 期由 load_prompt 确定性加载;建立 SPECS 名册
+ SCHEMA_CATALOG(Pydantic 类型留 Python)+ 统一只读解析入口 SpecResolver。
纯重构、零功能/schema 变更,缓存断点前块字节级不变(不变量 #9)。

@llm packages/agents(步骤1-3)
- spec_model.py:抽出 AgentSpec(frozen,字段不变)
- prompt_loader.py:load_prompt = utf-8-sig 去BOM → LF 归一 → NFC → rstrip尾LF,
  内存缓存 + fail-fast(PromptNotFoundError),import 期确定性
- schema_catalog.py:SCHEMA_CATALOG[name]→output type 唯一真相源(refiner=None)
- prompts/*.md ×21:取常量「运行时值」程序化外迁(反斜杠折行已塌缩,
  物理换行≡运行时换行);文件名按 spec.name 连字符(style.md/character-gen.md 等)
- specs.py:删 21 常量 + AgentSpec 类;system_prompt=load_prompt(name)、
  output_schema=SCHEMA_CATALOG[name];建 SPECS + REVIEW_RESERVED_NAMES;
  *_spec 兼容期保留且 SPECS[name] is *_spec(同一实例)。804→337 行
- __init__.py:显式 __all__ 重导出(避 F401)

@backend packages/skills(步骤4-5)
- SpecResolver:内置 SPECS(纯内存、零 DB)+ 用户 SkillRegistry 统一 get;
  内置 name 永不触发 DB;output_schema_for 精确匹配
- skill_registry:保留命名空间守卫前移至入库校验,拒同名内置 → VALIDATION
- toolbox_registry:GeneratorTool.spec 改走 SPECS[...],删 12 个 *_spec 直接 import

@devops repo-root
- .gitattributes:prompts/*.md text eol=lf(修正:须用完整嵌套路径才匹配)
- packages/agents/pyproject:hatchling artifacts 纳入 prompts/*.md 随 wheel/sdist 分发
- ci.yml:新增 build wheel → 裸装 → import ww_agents.SPECS 冒烟

TDD 全程 mock 网关;门禁绿:ruff/format clean · mypy 209 files · pytest 744 passed
(含金标准 sha256 回归 / md↔spec↔catalog 一一对应 / fail-fast / BOM+NFC /
内置守卫 / 同一实例 / 编排器无回归 / apps/api import-smoke / 打包冒烟)
2026-06-24 04:49:44 +02:00

163 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Skill registry loaderARCH §5.6 加载段 / PRODUCT_SPEC §5.5)。
从 `SkillRepo``skills` 表的读侧抽象)加载声明式 skill 行 → 转 frozen `AgentSpec`
(内置 builtin / 用户 custom / 社区 community 同构)。加载后按 `tier` 经 LLM 网关执行
(复用 §5.1 的 agent 机制,本模块**不**重实现网关)。
加载时对每条声明跑 `validate_declaration`越权reads/writes 指向未知表)的 skill
直接拒绝(不入册),守 §5.6 表权限契约(不可信用户输入的第一道闸)。
不变量 #2行里只带 `tier`writer/analyst/lightregistry 不解析具体 model
model 解析在网关。registry 加载后不可变specs 字典只读)。
"""
from __future__ import annotations
from typing import Protocol, cast, get_args
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import REVIEW_RESERVED_NAMES, SPECS, AgentSpec
from ww_db.models import Skill
from ww_llm_gateway.types import Tier
from ww_shared import AppError, ErrorCode
from ww_skills.skill_permissions import validate_declaration
_VALID_TIERS: frozenset[str] = frozenset(get_args(Tier))
# 内置保留命名空间四审受信名REVIEW_RESERVED_NAMES 全部内置 SPECS name。
# 用户 skill 不可冒用这些 name守卫前移在入库期呼应不变量 #3四审 prompt 不被偷换)。
_RESERVED_NAMES: frozenset[str] = REVIEW_RESERVED_NAMES | frozenset(SPECS)
def _reject_reserved_name(spec: AgentSpec) -> None:
"""用户 skill 冒用内置/保留名 → AppError(VALIDATION)(不可信声明的第一道闸)。"""
if spec.name in _RESERVED_NAMES:
raise AppError(
ErrorCode.VALIDATION,
f"Skill「{spec.name}」与内置保留名冲突,不可覆盖",
{"skill": spec.name},
)
class SkillRecord(BaseModel):
"""`skills` 表一行的声明式快照frozensnake_case
`input_schema`/`output_schema` 在 DB 里是 JSON Schema dict用户自定义无 Python 类型),
registry 暂以纯声明prompt + 表权限 + tier执行结构化 schema 的运行期绑定属后续,
故这里不携带 type[BaseModel](内置 8 agent 仍用各自硬编码的 spec见 ww_agents
"""
model_config = {"frozen": True}
name: str
scope: str # builtin / custom / community
tier: Tier
system_prompt: str
# 不可变序列frozen 只防整字段重绑,不防 list 原地变异record.reads.append 可旁路);
# tuple 无 append/clear对齐 AgentSpec 同字段语义。Pydantic v2 coerce list→tuple。
reads: tuple[str, ...] = ()
writes: tuple[str, ...] = ()
genre: str | None = None
def _to_spec(record: SkillRecord) -> AgentSpec:
return AgentSpec(
name=record.name,
tier=record.tier,
system_prompt=record.system_prompt,
input_schema=None,
output_schema=None,
reads=record.reads,
writes=record.writes,
genre=record.genre,
scope=record.scope,
)
class SkillRepo(Protocol):
"""`skills` 表读侧接口registry 加载源;测试注内存 fake"""
async def list_all(self) -> list[SkillRecord]: ...
class SkillRegistryLike(Protocol):
"""`SpecResolver` 消费的 registry 只读契约(结构子集;测试注 duck-type fake
SpecResolver 只用 `.get` / `.names` / `.list_scope` 三个方法——以 Protocol 精确捕获该
契约,避免依赖具体 `SkillRegistry` 类逼测试 fake 加 `# type: ignore`。
"""
def get(self, name: str) -> AgentSpec: ...
def names(self) -> list[str]: ...
def list_scope(self, scope: str) -> list[AgentSpec]: ...
class SkillRegistry:
"""加载后的只读 skill 注册表name → AgentSpec"""
def __init__(self, specs: dict[str, AgentSpec]) -> None:
self._specs = dict(specs)
@classmethod
async def load(cls, repo: SkillRepo) -> SkillRegistry:
"""从 repo 拉全部声明 → 校验表权限 → 建 frozen AgentSpec 字典。
越权声明reads/writes 指向未知表)→ AppError(VALIDATION),加载中断(不入册)。
"""
specs: dict[str, AgentSpec] = {}
for record in await repo.list_all():
spec = _to_spec(record)
_reject_reserved_name(spec) # 冒用内置/保留名 → 抛 VALIDATION
validate_declaration(spec) # 越权 → 抛 VALIDATION
specs[spec.name] = spec
return cls(specs)
def get(self, name: str) -> AgentSpec:
"""按名取 spec不存在 → AppError(NOT_FOUND)。"""
spec = self._specs.get(name)
if spec is None:
raise AppError(ErrorCode.NOT_FOUND, f"skill not found: {name}")
return spec
def names(self) -> list[str]:
return sorted(self._specs)
def list_scope(self, scope: str) -> list[AgentSpec]:
"""按 scopebuiltin/custom/community过滤按 name 排序。"""
return [s for s in sorted(self._specs.values(), key=lambda s: s.name) if s.scope == scope]
def _row_to_record(row: Skill) -> SkillRecord:
# DB `tier` 是裸 str非法档位 → VALIDATION不可信用户输入第一道闸
if row.tier not in _VALID_TIERS:
raise AppError(
ErrorCode.VALIDATION,
f"Skill「{row.name}」声明了非法档位:{row.tier}",
{"skill": row.name, "tier": row.tier},
)
return SkillRecord(
name=row.name,
scope=row.scope,
tier=cast(Tier, row.tier),
system_prompt=row.system_prompt,
reads=tuple(str(t) for t in (row.reads or [])),
writes=tuple(str(t) for t in (row.writes or [])),
genre=row.genre,
)
class SqlSkillRepo:
"""`skills` 表读侧 SQLAlchemy 实现registry 加载源;只读)。"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def list_all(self) -> list[SkillRecord]:
rows = (await self._s.execute(select(Skill))).scalars().all()
return [_row_to_record(r) for r in rows]