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

168 lines
4.4 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.

"""T3.3 三审契约测试foreshadow / pace spec + 输出 schemaC6 扩)。
契约测试——构造符合 schema 的 mock 响应校验字段、tier、四审只读writes==[])。
不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
AgentSpec,
ForeshadowReview,
ForeshadowSuggestion,
PaceIssue,
PaceReview,
foreshadow_spec,
pace_spec,
)
# ---- ForeshadowReview schema ----
def test_foreshadow_review_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化产出
mock = {
"planted": [
{
"code": "FS-MARK",
"title": "胸口的胎记",
"where": "第 2 段主角沐浴时被瞥见",
"note": "暗示皇族血统",
}
],
"resolved": [
{
"code": "FS-BIRTH",
"title": "身世之谜",
"where": "第 9 段老者道破来历",
"note": "回收第 1 章埋设",
}
],
}
# Act
review = ForeshadowReview.model_validate(mock)
# Assert
assert len(review.planted) == 1
assert review.planted[0].code == "FS-MARK"
assert review.resolved[0].code == "FS-BIRTH"
assert review.resolved[0].where == "第 9 段老者道破来历"
def test_foreshadow_review_defaults_to_empty_lists() -> None:
review = ForeshadowReview()
assert review.planted == []
assert review.resolved == []
def test_foreshadow_suggestion_only_title_required() -> None:
sug = ForeshadowSuggestion.model_validate({"title": "新埋的悬念"})
assert sug.title == "新埋的悬念"
assert sug.code is None
assert sug.where is None
assert sug.note is None
def test_foreshadow_suggestion_requires_title() -> None:
with pytest.raises(ValidationError):
ForeshadowSuggestion.model_validate({"code": "FS-X"})
# ---- PaceReview schema ----
def test_pace_review_parses_mock_response() -> None:
mock = {
"water": [{"where": "第 46 段反复描写天气", "reason": "信息密度低、与主线无关"}],
"hook": True,
"beat_map": [1, 3, 2, 0, 4, 5],
}
review = PaceReview.model_validate(mock)
assert len(review.water) == 1
assert review.water[0].reason == "信息密度低、与主线无关"
assert review.hook is True
assert review.beat_map == [1, 3, 2, 0, 4, 5]
def test_pace_review_defaults() -> None:
review = PaceReview()
assert review.water == []
assert review.hook is False
assert review.beat_map == []
def test_pace_issue_requires_where_and_reason() -> None:
with pytest.raises(ValidationError):
PaceIssue.model_validate({"where": "x"})
# ---- foreshadow_spec 声明 ----
def test_foreshadow_spec_is_analyst_tier() -> None:
assert foreshadow_spec.tier == "analyst"
assert foreshadow_spec.name == "foreshadow"
def test_foreshadow_spec_is_read_only() -> None:
# 不变量 #3四审只读
assert foreshadow_spec.writes == ()
def test_foreshadow_spec_reads_foreshadow() -> None:
assert foreshadow_spec.reads == ("foreshadow",)
def test_foreshadow_spec_output_schema() -> None:
assert foreshadow_spec.output_schema is ForeshadowReview
def test_foreshadow_spec_has_nonempty_system_prompt() -> None:
assert foreshadow_spec.system_prompt.strip()
# ---- pace_spec 声明 ----
def test_pace_spec_is_light_tier() -> None:
# 不变量 #2节奏审用轻量档只声明 tier
assert pace_spec.tier == "light"
assert pace_spec.name == "pace"
def test_pace_spec_is_read_only() -> None:
assert pace_spec.writes == ()
def test_pace_spec_reads_rules() -> None:
# genre 模板 DSL 经 rulesgenre 级)注入
assert pace_spec.reads == ("rules",)
def test_pace_spec_output_schema() -> None:
assert pace_spec.output_schema is PaceReview
def test_pace_spec_has_nonempty_system_prompt() -> None:
assert pace_spec.system_prompt.strip()
# ---- 共性immutable + 是 AgentSpec ----
def test_review_specs_are_agent_specs() -> None:
assert isinstance(foreshadow_spec, AgentSpec)
assert isinstance(pace_spec, AgentSpec)
def test_review_specs_are_immutable() -> None:
with pytest.raises(ValidationError):
foreshadow_spec.tier = "writer"
with pytest.raises(ValidationError):
pace_spec.tier = "writer"