Files
writer-work-flow/packages/skills/tests/test_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

149 lines
4.7 KiB
Python
Raw 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.

"""T5.5 Skill registry loader 单测ARCH §5.6 加载段)。
`SkillRegistry` 从 `SkillRepo`DB 行抽象)加载声明式 `AgentSpec`builtin/custom/community
提供 `get(name)` / `list_scope(scope)` / `names()`。加载时跑 `validate_declaration`——
越权声明reads/writes 指向未知表)的 skill 直接拒绝(不入册),守 §5.6 表权限契约。
纯内存 fake `SkillRepo`,无 DB注册行只是声明数据registry 转成 frozen AgentSpec。
不变量 #2行里只带 tierregistry 不解析具体 modelmodel 解析在网关)。
"""
from __future__ import annotations
import pytest
from ww_agents import AgentSpec
from ww_shared import AppError, ErrorCode
from ww_skills import SkillRecord, SkillRegistry
def _record(
name: str,
*,
scope: str = "custom",
reads: tuple[str, ...] = (),
writes: tuple[str, ...] = (),
) -> SkillRecord:
return SkillRecord(
name=name,
scope=scope,
tier="writer",
system_prompt=f"prompt for {name}",
reads=reads,
writes=writes,
genre=None,
)
class _FakeSkillRepo:
def __init__(self, records: list[SkillRecord]) -> None:
self._records = records
async def list_all(self) -> list[SkillRecord]:
return list(self._records)
@pytest.mark.asyncio
async def test_load_builds_specs_keyed_by_name() -> None:
repo = _FakeSkillRepo(
[
_record(
"worldgen",
scope="builtin",
reads=("world_entities",),
writes=("world_entities",),
),
_record("cpgen", scope="custom", reads=("characters",)),
]
)
registry = await SkillRegistry.load(repo)
assert set(registry.names()) == {"worldgen", "cpgen"}
spec = registry.get("worldgen")
assert isinstance(spec, AgentSpec)
assert spec.tier == "writer"
assert spec.reads == ("world_entities",)
assert spec.scope == "builtin"
@pytest.mark.asyncio
async def test_get_missing_skill_raises_not_found() -> None:
registry = await SkillRegistry.load(_FakeSkillRepo([_record("a")]))
with pytest.raises(AppError) as exc:
registry.get("missing")
assert exc.value.code is ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_list_scope_filters_by_scope() -> None:
repo = _FakeSkillRepo(
[
_record("b1", scope="builtin"),
_record("c1", scope="custom"),
_record("c2", scope="custom"),
]
)
registry = await SkillRegistry.load(repo)
assert {s.name for s in registry.list_scope("custom")} == {"c1", "c2"}
assert {s.name for s in registry.list_scope("builtin")} == {"b1"}
@pytest.mark.asyncio
async def test_load_rejects_over_permission_skill() -> None:
# 越权声明reads 指向未知表)→ 加载即拒绝(守 §5.6),不静默入册。
repo = _FakeSkillRepo([_record("evil", reads=("secret_table",))])
with pytest.raises(AppError) as exc:
await SkillRegistry.load(repo)
assert exc.value.code is ErrorCode.VALIDATION
@pytest.mark.asyncio
async def test_load_rejects_skill_colliding_with_reserved_review_name() -> None:
# 用户 skill 偷用四审保留名continuity→ 入库即拒(守卫前移,不变量 #3
repo = _FakeSkillRepo([_record("continuity", reads=("chapter_digests",))])
with pytest.raises(AppError) as exc:
await SkillRegistry.load(repo)
assert exc.value.code is ErrorCode.VALIDATION
@pytest.mark.asyncio
async def test_load_rejects_skill_colliding_with_builtin_name() -> None:
# 用户 skill 冒用非四审的内置 namebrainstorm→ 入库即拒。
repo = _FakeSkillRepo([_record("brainstorm", reads=("projects",))])
with pytest.raises(AppError) as exc:
await SkillRegistry.load(repo)
assert exc.value.code is ErrorCode.VALIDATION
@pytest.mark.asyncio
async def test_resolver_build_does_not_raise_on_reserved_collision() -> None:
# 守卫在入库期loadresolver.build 纯合并不校验——不得因同名抛错。
from ww_skills import SpecResolver
empty = await SkillRegistry.load(_FakeSkillRepo([]))
resolver = SpecResolver.build(empty) # 不抛
assert resolver.get("continuity") is not None
def test_skill_record_reads_writes_are_immutable_tuples() -> None:
# frozen 只防整字段重绑,不防 list 原地变异——reads/writes 必须是 tuple无 append
record = _record("immut", reads=("characters",), writes=("world_entities",))
assert isinstance(record.reads, tuple)
assert isinstance(record.writes, tuple)
assert record.reads == ("characters",)
assert record.writes == ("world_entities",)
assert not hasattr(record.reads, "append")