把 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 / 打包冒烟)
121 lines
4.0 KiB
Python
121 lines
4.0 KiB
Python
"""T2.1 续审契约测试:ContinuityReview schema + continuity_spec 声明(C6)。
|
||
|
||
契约测试——构造符合 schema 的 mock 响应,校验 type 枚举约束、conflicts 列表、
|
||
四审只读(writes==[])。不联网、无 DB。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from pydantic import ValidationError
|
||
from ww_agents import AgentSpec, Conflict, ContinuityReview, continuity_spec
|
||
|
||
|
||
def test_continuity_review_parses_mock_response() -> None:
|
||
# Arrange:模拟网关 instructor 校验后的结构化产出
|
||
mock = {
|
||
"conflicts": [
|
||
{
|
||
"type": "性格漂移",
|
||
"where": "第 3 段,主角忽然示弱",
|
||
"refs": ["第 1 章人物卡:性格刚烈"],
|
||
"suggestion": "改为强硬回应,或补内心转折铺垫",
|
||
},
|
||
{
|
||
"type": "时间线倒错",
|
||
"where": "开篇提到三日后",
|
||
"refs": ["第 5 章 digest:事件发生在次日"],
|
||
"suggestion": "统一为次日",
|
||
},
|
||
]
|
||
}
|
||
|
||
# Act
|
||
review = ContinuityReview.model_validate(mock)
|
||
|
||
# Assert
|
||
assert len(review.conflicts) == 2
|
||
assert review.conflicts[0].type == "性格漂移"
|
||
assert review.conflicts[1].refs == ["第 5 章 digest:事件发生在次日"]
|
||
|
||
|
||
def test_continuity_review_allows_empty_conflicts() -> None:
|
||
review = ContinuityReview.model_validate({"conflicts": []})
|
||
assert review.conflicts == []
|
||
|
||
|
||
def test_continuity_review_defaults_to_empty_conflicts() -> None:
|
||
review = ContinuityReview()
|
||
assert review.conflicts == []
|
||
|
||
|
||
def test_conflict_type_rejects_unknown_value() -> None:
|
||
with pytest.raises(ValidationError):
|
||
Conflict.model_validate({"type": "排版错误", "where": "x", "refs": [], "suggestion": "y"})
|
||
|
||
|
||
def test_conflict_refs_default_empty() -> None:
|
||
conflict = Conflict.model_validate(
|
||
{"type": "设定违例", "where": "战斗场景", "suggestion": "删去违例能力"}
|
||
)
|
||
assert conflict.refs == []
|
||
|
||
|
||
def test_conflict_patch_fields_default_none() -> None:
|
||
# 一键采纳补丁字段(original/replacement)可缺:不可定位的冲突两者留空。
|
||
conflict = Conflict.model_validate(
|
||
{"type": "设定违例", "where": "战斗场景", "suggestion": "删去违例能力"}
|
||
)
|
||
assert conflict.original is None
|
||
assert conflict.replacement is None
|
||
|
||
|
||
def test_conflict_carries_patch_pair_when_localizable() -> None:
|
||
# 可局部修复:original 逐字原文最小句段 + replacement 改后文本,供前端一键 find-replace。
|
||
conflict = Conflict.model_validate(
|
||
{
|
||
"type": "设定违例",
|
||
"where": "第1段",
|
||
"refs": [],
|
||
"suggestion": "迈巴赫改为奔驰",
|
||
"original": "迈巴赫扬长而去的尾灯",
|
||
"replacement": "奔驰扬长而去的尾灯",
|
||
}
|
||
)
|
||
assert conflict.original == "迈巴赫扬长而去的尾灯"
|
||
assert conflict.replacement == "奔驰扬长而去的尾灯"
|
||
|
||
|
||
def test_continuity_spec_is_analyst_tier() -> None:
|
||
assert continuity_spec.tier == "analyst"
|
||
assert continuity_spec.name == "continuity"
|
||
|
||
|
||
def test_continuity_spec_is_read_only() -> None:
|
||
# 不变量 #3:四审只读
|
||
assert continuity_spec.writes == ()
|
||
|
||
|
||
def test_continuity_spec_declares_expected_reads() -> None:
|
||
assert continuity_spec.reads == ("chapter_digests", "characters", "world_entities")
|
||
|
||
|
||
def test_continuity_spec_output_schema_is_continuity_review() -> None:
|
||
assert continuity_spec.output_schema is ContinuityReview
|
||
|
||
|
||
def test_continuity_spec_has_nonempty_system_prompt() -> None:
|
||
assert continuity_spec.system_prompt.strip()
|
||
|
||
|
||
def test_agent_spec_is_immutable() -> None:
|
||
# frozen:加载后不得改动
|
||
with pytest.raises(ValidationError):
|
||
continuity_spec.tier = "writer"
|
||
|
||
|
||
def test_agent_spec_writer_allows_none_output_schema() -> None:
|
||
writer = AgentSpec(name="writer", tier="writer", system_prompt="写")
|
||
assert writer.output_schema is None
|
||
assert writer.scope == "builtin"
|