把 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 / 打包冒烟)
508 lines
17 KiB
Python
508 lines
17 KiB
Python
"""T5.2 生成/入库 + 读端点测试(内存替身,无 DB/无网络)。
|
||
|
||
覆盖:
|
||
- POST /world/generate:mock 网关产 WorldGenResult → 预览 + commit(落 ledger);项目不存在 404。
|
||
- POST /characters/generate:mock 网关产 CharacterGenResult → 预览(含已有角色注入防雷同)。
|
||
- POST /characters(入库):precheck 无冲突 → 写库 + 201;有冲突且未确认 → 409;确认后放行写库。
|
||
- 无凭据 → 503(override 网关 dep 抛 LLM_UNAVAILABLE)。
|
||
- GET /rules:规则列表;GET /skills:技能库列表。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import uuid
|
||
from typing import Any
|
||
|
||
import httpx
|
||
import pytest
|
||
from cryptography.fernet import Fernet
|
||
from fakes_projects import FakeProjectRepo, FakeSession
|
||
from test_projects import _empty_memory_repos
|
||
from ww_agents import (
|
||
AgentSpec,
|
||
CharacterCard,
|
||
CharacterGenResult,
|
||
Conflict,
|
||
ContinuityReview,
|
||
WorldEntityCard,
|
||
WorldGenResult,
|
||
)
|
||
from ww_core.domain.character_repo import CharacterWriteView
|
||
from ww_core.domain.project_repo import ProjectCreate
|
||
from ww_core.domain.repositories import RuleView
|
||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||
from ww_shared import AppError, ErrorCode
|
||
from ww_skills import SkillRegistry
|
||
|
||
STUB_OWNER = uuid.UUID(int=1)
|
||
|
||
|
||
class _SchemaRoutingGateway:
|
||
"""按 `req.output_schema` 返对应 parsed(world/character/precheck 各拿自己的产物)。"""
|
||
|
||
def __init__(self, by_schema: dict[type[Any] | None, Any]) -> None:
|
||
self._by_schema = by_schema
|
||
self.calls: list[type[Any] | None] = []
|
||
|
||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||
schema = req.output_schema
|
||
self.calls.append(schema)
|
||
parsed = self._by_schema.get(schema)
|
||
return LlmResponse(
|
||
text=parsed.model_dump_json() if parsed is not None else "{}",
|
||
parsed=parsed,
|
||
usage=Usage(
|
||
provider="fake",
|
||
model="fake",
|
||
input_tokens=1,
|
||
output_tokens=1,
|
||
cost_minor=0,
|
||
currency="USD",
|
||
),
|
||
served_by=ServedBy(provider="fake", model="fake"),
|
||
)
|
||
|
||
|
||
class _FakeCharacterWriteRepo:
|
||
def __init__(self) -> None:
|
||
self.rows: list[dict[str, Any]] = []
|
||
|
||
async def create(
|
||
self,
|
||
project_id: uuid.UUID,
|
||
*,
|
||
name: str,
|
||
role: str,
|
||
traits: list[str],
|
||
backstory: str,
|
||
arc: str,
|
||
speech_tics: list[str],
|
||
tags: list[Any],
|
||
relations: list[dict[str, Any]],
|
||
) -> CharacterWriteView:
|
||
self.rows.append(
|
||
{
|
||
"project_id": project_id,
|
||
"name": name,
|
||
"role": role,
|
||
"traits": list(traits),
|
||
"arc": arc,
|
||
"speech_tics": list(speech_tics),
|
||
"tags": list(tags),
|
||
"relations": [dict(r) for r in relations],
|
||
}
|
||
)
|
||
return CharacterWriteView(id=uuid.uuid4(), name=name, role=role)
|
||
|
||
|
||
class _FakeRulesReadRepo:
|
||
def __init__(self, rules: list[RuleView] | None = None) -> None:
|
||
self._rules = rules or []
|
||
|
||
async def all_for_project(self, project_id: uuid.UUID) -> list[RuleView]:
|
||
return list(self._rules)
|
||
|
||
|
||
def _world_result() -> WorldGenResult:
|
||
return WorldGenResult(
|
||
entities=[WorldEntityCard(type="力量体系", name="灵脉", rules=["灵力守恒", "禁止跨界"])]
|
||
)
|
||
|
||
|
||
def _character_result() -> CharacterGenResult:
|
||
return CharacterGenResult(
|
||
cards=[
|
||
CharacterCard(
|
||
name="叶寒",
|
||
role="主角",
|
||
traits=["腹黑", "护短"],
|
||
backstory="孤儿出身",
|
||
arc="从孤儿到帝王",
|
||
speech_tics=["哼"],
|
||
tags=["天才"],
|
||
relations=[],
|
||
)
|
||
]
|
||
)
|
||
|
||
|
||
def _no_conflicts() -> ContinuityReview:
|
||
return ContinuityReview(conflicts=[])
|
||
|
||
|
||
def _with_conflicts() -> ContinuityReview:
|
||
return ContinuityReview(
|
||
conflicts=[Conflict(type="设定违例", where="叶寒卡", refs=["灵脉"], suggestion="改设定")]
|
||
)
|
||
|
||
|
||
def _skill_registry() -> SkillRegistry:
|
||
spec = AgentSpec(
|
||
name="custom-namer",
|
||
tier="light",
|
||
system_prompt="取名",
|
||
input_schema=None,
|
||
output_schema=None,
|
||
reads=("characters",),
|
||
writes=("characters",),
|
||
scope="custom",
|
||
)
|
||
return SkillRegistry({spec.name: spec})
|
||
|
||
|
||
def _make_app(
|
||
*,
|
||
project_repo: FakeProjectRepo,
|
||
gateway: Any,
|
||
char_repo: _FakeCharacterWriteRepo | None = None,
|
||
session: FakeSession | None = None,
|
||
rules_repo: _FakeRulesReadRepo | None = None,
|
||
registry: SkillRegistry | None = None,
|
||
memory: Any = None,
|
||
no_creds: bool = False,
|
||
) -> tuple[Any, FakeSession]:
|
||
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
|
||
from ww_api.main import create_app
|
||
from ww_api.services.project_deps import (
|
||
get_character_gen_gateway,
|
||
get_character_write_repo,
|
||
get_memory_repos,
|
||
get_precheck_gateway,
|
||
get_project_repo,
|
||
get_rules_read_repo,
|
||
get_skill_registry,
|
||
get_worldbuilder_gateway,
|
||
)
|
||
from ww_db import get_session
|
||
|
||
session = session or FakeSession()
|
||
char_repo = char_repo or _FakeCharacterWriteRepo()
|
||
rules_repo = rules_repo or _FakeRulesReadRepo()
|
||
registry = registry or _skill_registry()
|
||
|
||
async def _raise_no_creds() -> object:
|
||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||
|
||
app = create_app()
|
||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||
app.dependency_overrides[get_memory_repos] = (
|
||
(lambda: memory) if memory is not None else _empty_memory_repos
|
||
)
|
||
app.dependency_overrides[get_character_write_repo] = lambda: char_repo
|
||
app.dependency_overrides[get_rules_read_repo] = lambda: rules_repo
|
||
app.dependency_overrides[get_skill_registry] = lambda: registry
|
||
app.dependency_overrides[get_session] = lambda: session
|
||
gw = _raise_no_creds if no_creds else (lambda: gateway)
|
||
app.dependency_overrides[get_worldbuilder_gateway] = gw
|
||
app.dependency_overrides[get_character_gen_gateway] = gw
|
||
app.dependency_overrides[get_precheck_gateway] = gw
|
||
return app, session
|
||
|
||
|
||
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
|
||
view = await repo.create(STUB_OWNER, ProjectCreate(title="测试作品", genre="玄幻"))
|
||
return uuid.UUID(str(view.id))
|
||
|
||
|
||
def _client(app: Any) -> httpx.AsyncClient:
|
||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||
return httpx.AsyncClient(transport=transport, base_url="http://test")
|
||
|
||
|
||
# ---- 世界观生成 ----
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_generate_world_returns_preview_and_commits_ledger() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()})
|
||
app, session = _make_app(project_repo=repo, gateway=gateway)
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "东方玄幻"})
|
||
|
||
assert resp.status_code == 200
|
||
body = resp.json()
|
||
assert body["entities"][0]["name"] == "灵脉"
|
||
assert body["entities"][0]["rules"] == ["灵力守恒", "禁止跨界"]
|
||
assert session.commits == 1 # 预览不写业务表,但落 ledger
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_generate_world_unknown_project_404() -> None:
|
||
repo = FakeProjectRepo()
|
||
gateway = _SchemaRoutingGateway({WorldGenResult: _world_result()})
|
||
app, _ = _make_app(project_repo=repo, gateway=gateway)
|
||
async with _client(app) as client:
|
||
resp = await client.post(f"/projects/{uuid.uuid4()}/world/generate", json={"brief": "x"})
|
||
assert resp.status_code == 404
|
||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_generate_world_no_credentials_503() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
app, session = _make_app(project_repo=repo, gateway=object(), no_creds=True)
|
||
async with _client(app) as client:
|
||
resp = await client.post(f"/projects/{pid}/world/generate", json={"brief": "x"})
|
||
assert resp.status_code == 503
|
||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||
assert session.commits == 0
|
||
|
||
|
||
# ---- 角色生成 ----
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_generate_characters_returns_preview() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
gateway = _SchemaRoutingGateway({CharacterGenResult: _character_result()})
|
||
app, session = _make_app(project_repo=repo, gateway=gateway)
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/characters/generate", json={"brief": "天才主角", "count": 1}
|
||
)
|
||
|
||
assert resp.status_code == 200
|
||
card = resp.json()["cards"][0]
|
||
assert card["name"] == "叶寒"
|
||
assert card["traits"] == ["腹黑", "护短"]
|
||
assert card["arc"] == "从孤儿到帝王"
|
||
assert session.commits == 1
|
||
|
||
|
||
# ---- 角色入库(gate)----
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ingest_characters_no_conflict_writes_and_201() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
|
||
char_repo = _FakeCharacterWriteRepo()
|
||
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/characters",
|
||
json={
|
||
"cards": [
|
||
{
|
||
"name": "叶寒",
|
||
"role": "主角",
|
||
"traits": ["腹黑"],
|
||
"backstory": "孤儿",
|
||
"arc": "成长",
|
||
"speech_tics": ["哼"],
|
||
"tags": ["天才"],
|
||
"relations": [],
|
||
}
|
||
]
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 201
|
||
body = resp.json()
|
||
assert body["created"] == ["叶寒"]
|
||
assert body["rejected_tables"] == []
|
||
assert len(char_repo.rows) == 1
|
||
assert char_repo.rows[0]["traits"] == ["腹黑"]
|
||
assert session.commits == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ingest_characters_with_conflict_blocks_409() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
|
||
char_repo = _FakeCharacterWriteRepo()
|
||
app, session = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/characters",
|
||
json={"cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}]},
|
||
)
|
||
|
||
assert resp.status_code == 409
|
||
err = resp.json()["error"]
|
||
assert err["code"] == ErrorCode.CONFLICT_UNRESOLVED
|
||
assert err["details"]["conflict_count"] == 1
|
||
assert err["details"]["conflicts"][0]["type"] == "设定违例"
|
||
# 未写库(gate 拦下),但落 precheck ledger。
|
||
assert len(char_repo.rows) == 0
|
||
assert session.commits == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ingest_characters_acknowledged_conflict_writes() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
|
||
char_repo = _FakeCharacterWriteRepo()
|
||
app, _ = _make_app(project_repo=repo, gateway=gateway, char_repo=char_repo)
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.post(
|
||
f"/projects/{pid}/characters",
|
||
json={
|
||
"cards": [{"name": "叶寒", "role": "主角", "backstory": "孤儿", "arc": "成长"}],
|
||
"acknowledge_conflicts": True,
|
||
},
|
||
)
|
||
|
||
assert resp.status_code == 201
|
||
assert len(char_repo.rows) == 1
|
||
|
||
|
||
# ---- 读端点 ----
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_rules_returns_rules() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
rules_repo = _FakeRulesReadRepo(
|
||
[
|
||
RuleView(level="project", content="主角不复活"),
|
||
RuleView(level="global", content="无脏话"),
|
||
]
|
||
)
|
||
gateway = _SchemaRoutingGateway({})
|
||
app, _ = _make_app(project_repo=repo, gateway=gateway, rules_repo=rules_repo)
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.get(f"/projects/{pid}/rules")
|
||
|
||
assert resp.status_code == 200
|
||
rules = resp.json()["rules"]
|
||
assert [r["content"] for r in rules] == ["主角不复活", "无脏话"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_skills_returns_registry() -> None:
|
||
repo = FakeProjectRepo()
|
||
gateway = _SchemaRoutingGateway({})
|
||
app, _ = _make_app(project_repo=repo, gateway=gateway)
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.get("/skills")
|
||
|
||
assert resp.status_code == 200
|
||
skills = resp.json()["skills"]
|
||
assert skills[0]["name"] == "custom-namer"
|
||
assert skills[0]["scope"] == "custom"
|
||
assert skills[0]["writes"] == ["characters"]
|
||
|
||
|
||
# ---- 设定库 Codex 读端点(GET characters / world_entities)----
|
||
|
||
|
||
def _codex_memory() -> Any:
|
||
"""MemoryRepos:角色/世界观各一行(DB JSONB dict 形,验反向解包到 API list/str)。"""
|
||
from test_projects import (
|
||
_EmptyDigestRepo,
|
||
_EmptyForeshadowRepo,
|
||
_EmptyOutlineRepo,
|
||
_EmptyRulesRepo,
|
||
_EmptyStyleRepo,
|
||
_StubProjectSpecRepo,
|
||
)
|
||
from ww_core.domain.repositories import (
|
||
CharacterView,
|
||
MemoryRepos,
|
||
WorldEntityView,
|
||
)
|
||
|
||
class _CharRepo:
|
||
async def list_for_project(self, project_id: uuid.UUID) -> list[CharacterView]:
|
||
return [
|
||
CharacterView(
|
||
name="叶寒",
|
||
role="主角",
|
||
traits={"items": ["腹黑", "护短"]},
|
||
backstory="孤儿出身",
|
||
arc={"text": "从孤儿到帝王"},
|
||
speech_tics={"items": ["哼"]},
|
||
tags=["天才"],
|
||
relations=[],
|
||
)
|
||
]
|
||
|
||
class _WorldRepo:
|
||
async def list_for_project(self, project_id: uuid.UUID) -> list[WorldEntityView]:
|
||
return [
|
||
WorldEntityView(
|
||
type="力量体系",
|
||
name="灵脉",
|
||
rules={"rules": ["灵力守恒", "禁止跨界"]},
|
||
)
|
||
]
|
||
|
||
return MemoryRepos(
|
||
outline=_EmptyOutlineRepo(),
|
||
character=_CharRepo(),
|
||
world_entity=_WorldRepo(),
|
||
digest=_EmptyDigestRepo(),
|
||
foreshadow=_EmptyForeshadowRepo(),
|
||
style=_EmptyStyleRepo(),
|
||
rules=_EmptyRulesRepo(),
|
||
project=_StubProjectSpecRepo(),
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_characters_unpacks_jsonb_to_api_shape() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory())
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.get(f"/projects/{pid}/characters")
|
||
|
||
assert resp.status_code == 200
|
||
chars = resp.json()["characters"]
|
||
assert len(chars) == 1
|
||
card = chars[0]
|
||
# DB JSONB dict → API list/str(反向解包,入库形变的逆向)。
|
||
assert card["name"] == "叶寒"
|
||
assert card["traits"] == ["腹黑", "护短"]
|
||
assert card["speech_tics"] == ["哼"]
|
||
assert card["arc"] == "从孤儿到帝王"
|
||
assert card["tags"] == ["天才"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_world_entities_unpacks_rules_to_list() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}), memory=_codex_memory())
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.get(f"/projects/{pid}/world_entities")
|
||
|
||
assert resp.status_code == 200
|
||
entities = resp.json()["world_entities"]
|
||
assert len(entities) == 1
|
||
assert entities[0]["type"] == "力量体系"
|
||
assert entities[0]["name"] == "灵脉"
|
||
assert entities[0]["rules"] == ["灵力守恒", "禁止跨界"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_characters_empty_when_none_ingested() -> None:
|
||
repo = FakeProjectRepo()
|
||
pid = await _seed_project(repo)
|
||
app, _ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
|
||
|
||
async with _client(app) as client:
|
||
resp = await client.get(f"/projects/{pid}/characters")
|
||
|
||
assert resp.status_code == 200
|
||
assert resp.json()["characters"] == []
|