Files
writer-work-flow/apps/api/tests/test_toolbox_endpoints.py
Yaojia Wang f43ccd293f feat(toolbox): T6 创作工具箱通用生成器框架 — 8 新生成器 + 声明驱动落地页 + P2 收尾
通用执行路径驱动全部生成器("加生成器=加一份声明"):
- @llm: ww_agents +7 输出 schema + 7 spec(book-title/blurb/name/golden-finger/
  glossary/opening/fine-outline,只声明 tier)+ build_outline_chapter_context
- @backend: ww_skills GeneratorTool 描述符 + TOOLBOX(11) + get_tool;3 通用端点
  GET /skills/toolbox · POST .../skills/{tool_key}/generate(预览不写库,仅记账) ·
  POST .../ingest(复用 continuity 409 + partition_writes 白名单);纯 context 派发
- @frontend: 工具箱落地页 RSC + 声明驱动 GeneratorRunner + lib/toolbox 纯函数
  + LeftNav「工具箱」+ ⌘K nav-toolbox/action-gen-*;legacy 3 跳现页
- @qa: tests/test_t6_toolbox_e2e.py 5 用例真 pg + mock 网关零 token,无端点 bug
- P2 收尾: 限流→decisions.md 记延后(单用户原型);noopener/Committable 早已修

守不变量 #2(只声明 tier)/#3(预览不写库,入库经验收 gate)/#9(缓存前缀)。无 DB 迁移。
门禁绿: 后端 ruff/format/mypy 195/alembic 无漂移/pytest 583;前端 lint/tsc/vitest 279/build。
spec 回写 PRODUCT_SPEC §7 + ARCHITECTURE §7.2 端点表。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:37:55 +02:00

378 lines
13 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.

"""T6.2/T6.3 创作工具箱通用端点测试(内存替身,无 DB/无网络)。
覆盖:
- GET /skills/toolbox列出全部工具legacy + 新)+ ingestable/legacy_route 形。
- POST .../generate按 key 解析 spec → 结构化预览;未知 key 404legacy key 404无凭据 503。
- POST .../ingestworld_entities continuity 409 gate + acknowledge 放行 + partition_writes 丢越权;
不可入库工具 422outline 细纲入库 upsert。
"""
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 Conflict, ContinuityReview
from ww_agents.schemas import Idea, IdeaListResult
from ww_core.domain.outline_write_repo import OutlineWriteView
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.world_entity_repo import WorldEntityWriteView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
STUB_OWNER = uuid.UUID(int=1)
class _SchemaRoutingGateway:
"""按 `req.output_schema` 返对应 parsedgenerate / 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 _FakeWorldWriteRepo:
def __init__(self) -> None:
self.rows: list[dict[str, Any]] = []
async def create(
self, project_id: uuid.UUID, *, type: str, name: str, rules: list[str]
) -> WorldEntityWriteView:
self.rows.append({"type": type, "name": name, "rules": list(rules)})
return WorldEntityWriteView(id=uuid.uuid4(), type=type, name=name)
class _FakeOutlineWriteRepo:
def __init__(self) -> None:
self.rows: list[dict[str, Any]] = []
async def upsert_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]],
) -> OutlineWriteView:
self.rows.append({"chapter_no": chapter_no, "beats": list(beats)})
return OutlineWriteView(
chapter_no=chapter_no, volume=volume, beats=list(beats), foreshadow_windows=[]
)
def _ideas() -> IdeaListResult:
return IdeaListResult(ideas=[Idea(premise="废柴觉醒系统", hook="开局即巅峰", genre_fit="玄幻")])
def _no_conflicts() -> ContinuityReview:
return ContinuityReview(conflicts=[])
def _with_conflicts() -> ContinuityReview:
return ContinuityReview(
conflicts=[Conflict(type="设定违例", where="吞噬系统", refs=["灵脉"], suggestion="改设定")]
)
def _make_app(
*,
project_repo: FakeProjectRepo,
gateway: Any,
session: FakeSession | None = None,
world_repo: _FakeWorldWriteRepo | None = None,
outline_write_repo: _FakeOutlineWriteRepo | None = None,
memory: Any = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]:
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_memory_repos,
get_outline_write_repo,
get_project_repo,
get_tier_gateway_builder,
get_world_entity_write_repo,
)
from ww_db import get_session
session = session or FakeSession()
world_repo = world_repo or _FakeWorldWriteRepo()
outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo()
async def _build_ok(_tier: str) -> Any:
return gateway
async def _build_no_creds(_tier: str) -> Any:
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_world_entity_write_repo] = lambda: world_repo
app.dependency_overrides[get_outline_write_repo] = lambda: outline_write_repo
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_tier_gateway_builder] = lambda: (
_build_no_creds if no_creds else _build_ok
)
return app, session, world_repo, outline_write_repo
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")
# ---- GET /skills/toolbox ----
@pytest.mark.asyncio
async def test_list_toolbox_contains_all_keys() -> None:
repo = FakeProjectRepo()
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
async with _client(app) as client:
resp = await client.get("/skills/toolbox")
assert resp.status_code == 200
tools = resp.json()["tools"]
keys = {t["key"] for t in tools}
assert keys >= {
"worldbuilding",
"character",
"outline",
"brainstorm",
"book-title",
"blurb",
"name",
"golden-finger",
"glossary",
"opening",
"fine-outline",
}
by_key = {t["key"]: t for t in tools}
# legacy 携 legacy_route + is_legacy新工具 ingestable 标记。
assert by_key["worldbuilding"]["is_legacy"] is True
assert by_key["worldbuilding"]["legacy_route"]
assert by_key["brainstorm"]["is_legacy"] is False
assert by_key["golden-finger"]["ingestable"] is True
assert by_key["brainstorm"]["ingestable"] is False
# input_fields 形brief textarea
assert any(f["name"] == "brief" for f in by_key["brainstorm"]["input_fields"])
# ---- POST .../generate ----
@pytest.mark.asyncio
async def test_generate_resolves_spec_and_returns_preview() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({IdeaListResult: _ideas()})
app, session, *_ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/brainstorm/generate", json={"brief": "废柴流"}
)
assert resp.status_code == 200
body = resp.json()
assert body["tool_key"] == "brainstorm"
assert body["output_kind"] == "IdeaListResult"
assert body["preview"]["ideas"][0]["premise"] == "废柴觉醒系统"
assert session.commits == 1 # 预览不写业务表,但落 ledger
@pytest.mark.asyncio
async def test_generate_unknown_tool_404() -> 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.post(f"/projects/{pid}/skills/nope/generate", json={"brief": "x"})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_generate_legacy_tool_404() -> None:
# legacy 工具无通用执行路径(前端走 legacy_route
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/worldbuilding/generate", json={"brief": "x"}
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_generate_unknown_project_404() -> None:
repo = FakeProjectRepo()
app, *_ = _make_app(
project_repo=repo, gateway=_SchemaRoutingGateway({IdeaListResult: _ideas()})
)
async with _client(app) as client:
resp = await client.post(
f"/projects/{uuid.uuid4()}/skills/brainstorm/generate", json={"brief": "x"}
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_generate_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}/skills/brainstorm/generate", json={"brief": "x"})
assert resp.status_code == 503
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
assert session.commits == 0
# ---- POST .../ingestworld_entities gate----
def _gf_payload() -> dict[str, Any]:
return {
"world_entities": [
{"type": "力量体系", "name": "吞噬系统", "rules": ["每日上限", "不可越级"]}
]
}
@pytest.mark.asyncio
async def test_ingest_world_entities_no_conflict_writes_201() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _no_conflicts()})
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=_gf_payload())
assert resp.status_code == 201
body = resp.json()
assert body["table"] == "world_entities"
assert body["created"] == ["吞噬系统"]
assert body["rejected_tables"] == []
assert len(world_repo.rows) == 1
assert world_repo.rows[0]["rules"] == ["每日上限", "不可越级"]
assert session.commits == 1
@pytest.mark.asyncio
async def test_ingest_world_entities_conflict_blocks_409() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
app, session, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=_gf_payload())
assert resp.status_code == 409
err = resp.json()["error"]
assert err["code"] == ErrorCode.CONFLICT_UNRESOLVED
assert err["details"]["conflict_count"] == 1
assert len(world_repo.rows) == 0
assert session.commits == 1 # 落 precheck ledger不写业务表
@pytest.mark.asyncio
async def test_ingest_world_entities_acknowledged_writes() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({ContinuityReview: _with_conflicts()})
app, _, world_repo, _ = _make_app(project_repo=repo, gateway=gateway)
payload = {**_gf_payload(), "acknowledge_conflicts": True}
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/golden-finger/ingest", json=payload)
assert resp.status_code == 201
assert len(world_repo.rows) == 1
@pytest.mark.asyncio
async def test_ingest_non_ingestable_tool_422() -> None:
# brainstorm 纯预览 → 不可入库。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
app, *_ = _make_app(project_repo=repo, gateway=_SchemaRoutingGateway({}))
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/brainstorm/ingest", json={})
assert resp.status_code == 422
assert resp.json()["error"]["code"] == ErrorCode.VALIDATION
@pytest.mark.asyncio
async def test_ingest_outline_upserts_scenes() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _SchemaRoutingGateway({})
app, session, _, outline_repo = _make_app(project_repo=repo, gateway=gateway)
payload = {
"chapter_no": 3,
"scenes": [
{"idx": 1, "beat": "初遇反派", "purpose": "推进", "conflict": "对峙", "hook": "悬念"},
{"idx": 0, "beat": "主角觉醒", "purpose": "塑造", "conflict": "内心", "hook": "钩子"},
],
}
async with _client(app) as client:
resp = await client.post(f"/projects/{pid}/skills/fine-outline/ingest", json=payload)
assert resp.status_code == 201
body = resp.json()
assert body["table"] == "outline"
assert len(outline_repo.rows) == 1
# 场景按 idx 升序拼装成 beats确定性
assert outline_repo.rows[0]["beats"] == ["主角觉醒", "初遇反派"]
assert outline_repo.rows[0]["chapter_no"] == 3
assert session.commits == 1