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

192 lines
5.9 KiB
Python
Raw Permalink 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.

"""T4.2 style-auditor 双轨契约测试style_extract / style_drift / refiner spec + schemaC6 扩)。
契约测试——构造符合 schema 的 mock 响应校验字段、tier、双轨读写权限。
提取轨 `StyleFingerprintResult`16 维带证据);第四审 `StyleDriftReview`(段级相似度);
回炉 `refiner_spec`writer 纯文本output_schema=None。不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import (
AgentSpec,
StyleDimension,
StyleDriftReview,
StyleDriftSegment,
StyleFingerprintResult,
refiner_spec,
style_drift_spec,
style_extract_spec,
)
# ---- StyleFingerprintResult schema提取轨----
def test_style_fingerprint_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化指纹产出(每维带原文证据)
mock = {
"dimensions": [
{
"name": "句长节奏",
"value": "短句为主、节奏明快",
"evidence": ["他一刀劈下。血溅三尺。", "风停了。"],
},
{
"name": "叙事人称",
"value": "第三人称限知",
"evidence": ["他不知道门后有什么。"],
},
]
}
# Act
fp = StyleFingerprintResult.model_validate(mock)
# Assert
assert len(fp.dimensions) == 2
assert fp.dimensions[0].name == "句长节奏"
assert fp.dimensions[0].evidence == ["他一刀劈下。血溅三尺。", "风停了。"]
assert fp.dimensions[1].value == "第三人称限知"
def test_style_fingerprint_defaults_to_empty_dimensions() -> None:
assert StyleFingerprintResult().dimensions == []
def test_style_dimension_evidence_defaults_empty() -> None:
dim = StyleDimension.model_validate({"name": "用词", "value": "古雅"})
assert dim.evidence == []
def test_style_dimension_requires_name_and_value() -> None:
with pytest.raises(ValidationError):
StyleDimension.model_validate({"name": "用词"})
# ---- StyleDriftReview schema第四审----
def test_style_drift_parses_mock_response() -> None:
mock = {
"score": 72,
"segments": [
{"idx": 3, "score": 40, "label": "机翻腔"},
{"idx": 7, "score": 55, "label": None},
],
}
review = StyleDriftReview.model_validate(mock)
assert review.score == 72
assert len(review.segments) == 2
assert review.segments[0].idx == 3
assert review.segments[0].label == "机翻腔"
assert review.segments[1].label is None
def test_style_drift_defaults_score_100_empty_segments() -> None:
# 无指纹优雅降级:默认整体相似度 100、无漂移段
review = StyleDriftReview()
assert review.score == 100
assert review.segments == []
def test_style_drift_segment_label_optional() -> None:
seg = StyleDriftSegment.model_validate({"idx": 1, "score": 30})
assert seg.label is None
def test_style_drift_segment_requires_idx_and_score() -> None:
with pytest.raises(ValidationError):
StyleDriftSegment.model_validate({"idx": 1})
# ---- style_extract_spec 声明 ----
def test_style_extract_spec_is_analyst_tier() -> None:
assert style_extract_spec.tier == "analyst"
assert style_extract_spec.name == "style_extract"
def test_style_extract_spec_reads_and_writes_fingerprint() -> None:
assert style_extract_spec.reads == ("style_fingerprint",)
assert style_extract_spec.writes == ("style_fingerprint",)
def test_style_extract_spec_output_schema() -> None:
assert style_extract_spec.output_schema is StyleFingerprintResult
def test_style_extract_spec_has_nonempty_system_prompt() -> None:
assert style_extract_spec.system_prompt.strip()
# ---- style_drift_spec 声明第四审name="style"----
def test_style_drift_spec_is_light_tier_named_style() -> None:
# 不变量 #2第四审用轻量档只声明 tier列名/section 名为 "style"
assert style_drift_spec.tier == "light"
assert style_drift_spec.name == "style"
def test_style_drift_spec_is_read_only() -> None:
# 不变量 #3四审只读
assert style_drift_spec.writes == ()
def test_style_drift_spec_reads_fingerprint() -> None:
assert style_drift_spec.reads == ("style_fingerprint",)
def test_style_drift_spec_output_schema() -> None:
assert style_drift_spec.output_schema is StyleDriftReview
def test_style_drift_spec_prompt_documents_graceful_degrade() -> None:
# 无指纹→返回 score=100/空段system_prompt 必须含降级指令,否则 LLM 不知如何兜底)
assert "100" in style_drift_spec.system_prompt
# ---- refiner_spec 声明writer 纯文本回炉)----
def test_refiner_spec_is_writer_tier() -> None:
assert refiner_spec.tier == "writer"
assert refiner_spec.name == "refiner"
def test_refiner_spec_output_schema_is_none() -> None:
# 回炉产纯文本(重写段),无结构化 schema
assert refiner_spec.output_schema is None
def test_refiner_spec_is_read_only_and_no_writes() -> None:
# 回炉非持久(不变量 #3不写库作者采纳经既有 draft 自动保存合入
assert refiner_spec.reads == ()
assert refiner_spec.writes == ()
def test_refiner_spec_has_nonempty_system_prompt() -> None:
assert refiner_spec.system_prompt.strip()
# ---- 共性immutable + 是 AgentSpec ----
def test_style_specs_are_agent_specs() -> None:
assert isinstance(style_extract_spec, AgentSpec)
assert isinstance(style_drift_spec, AgentSpec)
assert isinstance(refiner_spec, AgentSpec)
def test_style_specs_are_immutable() -> None:
with pytest.raises(ValidationError):
style_extract_spec.tier = "writer"
with pytest.raises(ValidationError):
style_drift_spec.tier = "writer"
with pytest.raises(ValidationError):
refiner_spec.tier = "analyst"