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

150 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.

"""SpecResolver 单测Prompt 外置方案A · 步4
`SpecResolver` 统一内置(`SPECS`,纯内存、零 DB与用户 skill`SkillRegistry`DB
的只读解析入口。核心约束(不变量 #3 / 评审 HIGH
- `get(name)` 先查内置 `SPECS`(纯内存),命中内置 name **绝不触发任何 DB/registry 调用**
- 未命中内置才查 `SkillRegistry`;都无 → `AppError(NOT_FOUND)`
- `output_schema_for`:内置 → `SCHEMA_CATALOG[name]` 真类;纯用户 skill → None
name 精确字符串相等(无大小写/连字符归一),拼错近似 name → None 不报错;
- `resolver.build` 纯合并,**不**做保留名冲突校验(守卫前移至 SkillRegistry 入库)。
"""
from __future__ import annotations
import pytest
from ww_agents import (
SCHEMA_CATALOG,
SPECS,
AgentSpec,
)
from ww_shared import AppError, ErrorCode
from ww_skills import SpecResolver
class _FakeRegistry:
"""断言用 fake记录 get/names 是否被调用(内置 get 必须零触达)。"""
def __init__(self, specs: dict[str, AgentSpec] | None = None) -> None:
self._specs = dict(specs or {})
self.get_calls: list[str] = []
self.names_calls: int = 0
def get(self, name: str) -> AgentSpec:
self.get_calls.append(name)
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]:
self.names_calls += 1
return sorted(self._specs)
def list_scope(self, scope: str) -> list[AgentSpec]:
return [s for s in sorted(self._specs.values(), key=lambda s: s.name) if s.scope == scope]
def _user_spec(name: str, scope: str = "custom") -> AgentSpec:
return AgentSpec(
name=name,
tier="writer",
system_prompt=f"prompt for {name}",
input_schema=None,
output_schema=None,
reads=(),
writes=(),
scope=scope,
)
# ---- #7 resolver 内置/用户对齐 + 零 DB ----
def test_get_builtin_returns_specs_instance_without_touching_registry() -> None:
# Arrangefake registry 本身有同名条目也无所谓——内置命中必须零触达。
fake = _FakeRegistry({"continuity": _user_spec("continuity")})
resolver = SpecResolver.build(fake)
# Act
spec = resolver.get("continuity")
# Assert拿到的是内置同一实例且 registry.get 从未被调用(零 DB
assert spec is SPECS["continuity"]
assert fake.get_calls == []
def test_get_user_skill_falls_through_to_registry() -> None:
fake = _FakeRegistry({"my-skill": _user_spec("my-skill")})
resolver = SpecResolver.build(fake)
spec = resolver.get("my-skill")
assert spec.name == "my-skill"
assert fake.get_calls == ["my-skill"]
def test_get_unknown_everywhere_raises_not_found() -> None:
fake = _FakeRegistry()
resolver = SpecResolver.build(fake)
with pytest.raises(AppError) as exc:
resolver.get("nope-not-here")
assert exc.value.code is ErrorCode.NOT_FOUND
assert fake.get_calls == ["nope-not-here"]
def test_names_merges_builtin_and_user() -> None:
fake = _FakeRegistry({"my-skill": _user_spec("my-skill")})
resolver = SpecResolver.build(fake)
names = resolver.names()
assert "continuity" in names
assert "my-skill" in names
def test_list_scope_filters_builtin_and_user() -> None:
fake = _FakeRegistry({"my-skill": _user_spec("my-skill", scope="custom")})
resolver = SpecResolver.build(fake)
builtin = resolver.list_scope("builtin")
custom = resolver.list_scope("custom")
assert all(s.scope == "builtin" for s in builtin)
assert {s.name for s in custom} == {"my-skill"}
# ---- #9 output_schema_for ----
def test_output_schema_for_builtin_returns_real_type() -> None:
fake = _FakeRegistry()
resolver = SpecResolver.build(fake)
assert resolver.output_schema_for("continuity") is SCHEMA_CATALOG["continuity"]
def test_output_schema_for_refiner_is_none() -> None:
fake = _FakeRegistry()
resolver = SpecResolver.build(fake)
assert resolver.output_schema_for("refiner") is None
def test_output_schema_for_pure_user_skill_is_none() -> None:
fake = _FakeRegistry({"my-skill": _user_spec("my-skill")})
resolver = SpecResolver.build(fake)
assert resolver.output_schema_for("my-skill") is None
def test_output_schema_for_misspelled_name_is_none_no_fuzzy_match() -> None:
# 拼错近似 name下划线不得误命中连字符内置 "character-gen"(精确字符串相等)。
fake = _FakeRegistry()
resolver = SpecResolver.build(fake)
assert resolver.output_schema_for("character_gen") is None
assert resolver.output_schema_for("character-gen") is not None