Files
writer-work-flow/tests/test_prompt_externalize_regression.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

118 lines
4.7 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.

"""Prompt 外置方案A集成回归 —— 设计 §6 #12 / #15。
#12 编排器无回归review / generation / outline / style_extract 节点据内置 `SPECS`
spec 构请求mock gateway不触真 LLM断言进缓存断点前块的 `system` 文本
== 旧行为(即 `load_prompt(name)` / `SPECS[name].system_prompt`)——证明 prompt
外迁到 `.md` 后字节级零变化(不变量 #9
#15 import-smoke`from ww_agents import AgentSpec/outliner_spec/refiner_spec/
style_extract_spec/continuity_spec` 不抛,且拿到的对象 `is SPECS[name]`(同一实例)
——证明 apps/api 路由 + 编排器(本波仍用 `*_spec`)的 import 路径未破。
"""
from __future__ import annotations
import uuid
from ww_agents import (
SPECS,
AgentSpec,
continuity_spec,
load_prompt,
outliner_spec,
refiner_spec,
style_extract_spec,
)
from ww_core.orchestrator.generation_node import _build_request
from ww_core.orchestrator.outline_node import build_outline_request
from ww_core.orchestrator.review_node import build_review_request
from ww_core.orchestrator.state import ChapterState
from ww_core.orchestrator.style_extract_node import build_style_extract_request
_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
_PROJECT_ID = uuid.UUID("00000000-0000-0000-0000-000000000002")
def _system_text(spec: AgentSpec, req_system: list) -> str:
"""从 LlmRequest.system 取唯一的缓存断点前块文本(不变量 #9"""
assert len(req_system) == 1, "system 应只有一个稳定块(缓存断点前)"
block = req_system[0]
assert block.cache is True, "system 稳定块须标 cache=True缓存断点前块"
return block.text
# ---- #12 编排器无回归:四节点 system 文本 == load_prompt(name) ----
def test_review_node_system_block_equals_load_prompt() -> None:
# Arrange — 用 continuity四审之一构造审稿 state
state: ChapterState = { # type: ignore[typeddict-item]
"review_context": "## 审稿材料\n世界观硬规则……\n## 待审本章草稿\n正文……",
"user_id": _USER_ID,
"project_id": _PROJECT_ID,
}
# Act
req = build_review_request(continuity_spec, state)
# Assert — system 块文本 == 外置 .md 的运行时值,且 == spec.system_prompt同一真相源
assert _system_text(continuity_spec, req.system) == load_prompt("continuity")
assert _system_text(continuity_spec, req.system) == continuity_spec.system_prompt
def test_generation_node_system_block_equals_load_prompt() -> None:
# Arrange — worldbuilder 走通用 _build_request与 character-gen 共用)
spec = SPECS["worldbuilder"]
# Act
req = _build_request(
spec, context="## 世界观需求\n灵气复苏", user_id=_USER_ID, project_id=_PROJECT_ID
)
# Assert
assert _system_text(spec, req.system) == load_prompt("worldbuilder")
assert _system_text(spec, req.system) == spec.system_prompt
def test_outline_node_system_block_equals_load_prompt() -> None:
# Act
req = build_outline_request(
outliner_spec, context="## 设定\n……", user_id=_USER_ID, project_id=_PROJECT_ID
)
# Assert
assert _system_text(outliner_spec, req.system) == load_prompt("outliner")
assert _system_text(outliner_spec, req.system) == outliner_spec.system_prompt
def test_style_extract_node_system_block_equals_load_prompt() -> None:
# Act
req = build_style_extract_request(
style_extract_spec,
samples_text="样本正文一\n样本正文二",
user_id=_USER_ID,
project_id=_PROJECT_ID,
)
# Assert
assert _system_text(style_extract_spec, req.system) == load_prompt("style_extract")
assert _system_text(style_extract_spec, req.system) == style_extract_spec.system_prompt
def test_review_request_passes_tier_and_schema_unchanged() -> None:
# 不变量 #2节点只透传 spec.tier绝不写死 modeloutput_schema 据 spec 派生(无回归)
state: ChapterState = { # type: ignore[typeddict-item]
"review_context": "材料……",
"user_id": _USER_ID,
"project_id": _PROJECT_ID,
}
req = build_review_request(continuity_spec, state)
assert req.tier == continuity_spec.tier
assert req.output_schema is continuity_spec.output_schema
# ---- #15 import-smokeapps/api + 编排器仍用 *_spec----
def test_import_smoke_specs_are_same_instance() -> None:
# 兼容期不变量:模块级 *_spec 与 SPECS[name] 是同一实例(不双真相,设计 §3.4 / #14
assert isinstance(AgentSpec, type)
assert outliner_spec is SPECS["outliner"]
assert refiner_spec is SPECS["refiner"]
assert style_extract_spec is SPECS["style_extract"]
assert continuity_spec is SPECS["continuity"]