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>
This commit is contained in:
377
apps/api/tests/test_toolbox_endpoints.py
Normal file
377
apps/api/tests/test_toolbox_endpoints.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""T6.2/T6.3 创作工具箱通用端点测试(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- GET /skills/toolbox:列出全部工具(legacy + 新)+ ingestable/legacy_route 形。
|
||||
- POST .../generate:按 key 解析 spec → 结构化预览;未知 key 404;legacy key 404;无凭据 503。
|
||||
- POST .../ingest:world_entities continuity 409 gate + acknowledge 放行 + partition_writes 丢越权;
|
||||
不可入库工具 422;outline 细纲入库 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` 返对应 parsed(generate / 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 .../ingest(world_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
|
||||
Reference in New Issue
Block a user