把 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 / 打包冒烟)
118 lines
3.4 KiB
Python
118 lines
3.4 KiB
Python
"""T3.4 大纲 Agent 契约测试:OutlineResult schema + outliner_spec 声明(C6 扩)。
|
||
|
||
契约测试——构造符合 schema 的 mock 响应,校验伏笔窗口字段、章节结构、
|
||
声明式 writes(验收/T3.5 才真写库,不变量 #3)。不联网、无 DB。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
from ww_agents import (
|
||
AgentSpec,
|
||
ForeshadowWindow,
|
||
OutlineChapter,
|
||
OutlineResult,
|
||
outliner_spec,
|
||
)
|
||
|
||
|
||
def test_outline_result_parses_mock_response() -> None:
|
||
# Arrange:模拟网关 instructor 校验后的结构化大纲产出
|
||
mock = {
|
||
"chapters": [
|
||
{
|
||
"no": 1,
|
||
"beats": ["主角登场,立下目标", "埋下身世伏笔"],
|
||
"foreshadow_windows": [
|
||
{
|
||
"code": "FS-BIRTH",
|
||
"plant_chapter": 1,
|
||
"expected_close_from": 20,
|
||
"expected_close_to": 30,
|
||
}
|
||
],
|
||
},
|
||
{
|
||
"no": 2,
|
||
"beats": ["遭遇对手"],
|
||
"foreshadow_windows": [],
|
||
},
|
||
]
|
||
}
|
||
|
||
# Act
|
||
outline = OutlineResult.model_validate(mock)
|
||
|
||
# Assert
|
||
assert len(outline.chapters) == 2
|
||
assert outline.chapters[0].no == 1
|
||
assert outline.chapters[0].beats == ["主角登场,立下目标", "埋下身世伏笔"]
|
||
window = outline.chapters[0].foreshadow_windows[0]
|
||
assert window.code == "FS-BIRTH"
|
||
assert window.plant_chapter == 1
|
||
assert window.expected_close_from == 20
|
||
assert window.expected_close_to == 30
|
||
|
||
|
||
def test_outline_chapter_defaults_empty_windows() -> None:
|
||
chapter = OutlineChapter.model_validate({"no": 3, "beats": ["收束支线"]})
|
||
assert chapter.foreshadow_windows == []
|
||
|
||
|
||
def test_outline_result_defaults_to_empty_chapters() -> None:
|
||
assert OutlineResult().chapters == []
|
||
|
||
|
||
def test_foreshadow_window_optional_bounds_default_none() -> None:
|
||
window = ForeshadowWindow.model_validate({"code": "FS-X"})
|
||
assert window.plant_chapter is None
|
||
assert window.expected_close_from is None
|
||
assert window.expected_close_to is None
|
||
|
||
|
||
def test_foreshadow_window_requires_code() -> None:
|
||
with pytest.raises(ValidationError):
|
||
ForeshadowWindow.model_validate({"plant_chapter": 1})
|
||
|
||
|
||
def test_outline_chapter_requires_no() -> None:
|
||
with pytest.raises(ValidationError):
|
||
OutlineChapter.model_validate({"beats": ["x"]})
|
||
|
||
|
||
def test_outliner_spec_is_analyst_tier() -> None:
|
||
assert outliner_spec.tier == "analyst"
|
||
assert outliner_spec.name == "outliner"
|
||
|
||
|
||
def test_outliner_spec_declares_expected_reads() -> None:
|
||
assert outliner_spec.reads == (
|
||
"projects",
|
||
"foreshadow",
|
||
"characters",
|
||
"world_entities",
|
||
)
|
||
|
||
|
||
def test_outliner_spec_declares_outline_write() -> None:
|
||
# 声明式 writes(经验收/T3.5 才真写库,不变量 #3)
|
||
assert outliner_spec.writes == ("outline",)
|
||
|
||
|
||
def test_outliner_spec_output_schema_is_outline_result() -> None:
|
||
assert outliner_spec.output_schema is OutlineResult
|
||
|
||
|
||
def test_outliner_spec_has_nonempty_system_prompt() -> None:
|
||
assert outliner_spec.system_prompt.strip()
|
||
|
||
|
||
def test_outliner_spec_is_immutable() -> None:
|
||
with pytest.raises(ValidationError):
|
||
outliner_spec.tier = "writer"
|
||
|
||
|
||
def test_outliner_spec_is_agent_spec() -> None:
|
||
assert isinstance(outliner_spec, AgentSpec)
|