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:
453
apps/api/ww_api/routers/toolbox.py
Normal file
453
apps/api/ww_api/routers/toolbox.py
Normal file
@@ -0,0 +1,453 @@
|
||||
"""创作工具箱通用端点(C3 扩 T6 / 通用生成器框架;不变量 #1/#2/#3/#9)。
|
||||
|
||||
声明驱动、一条执行路径——「加一个生成器」= 在 `ww_skills.TOOLBOX` 加一份声明,无需新端点:
|
||||
- `GET /skills/toolbox` 列出全部生成器描述符(legacy + 新工具)。
|
||||
- `POST /projects/:id/skills/:tool_key/generate` 按 context_strategy 组材料 → 网关结构化预览。
|
||||
- `POST /projects/:id/skills/:tool_key/ingest` 仅可入库工具:过 continuity 预检 + 白名单 → 写库。
|
||||
|
||||
legacy 工具(spec=None)无通用执行路径——generate/ingest 对其返 404(前端走 legacy_route 跳现页)。
|
||||
未知 tool_key→404;项目不存在→404;无凭据→503 LLM_UNAVAILABLE;缺章/大纲→空节拍不报错(预览)。
|
||||
|
||||
提交边界:网关 ledger + 写侧均只 flush → 端点末尾一次 `commit()`(含冲突 409 路径也 commit
|
||||
落 precheck usage,同 characters 入库纪律,不变量 #3)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import AgentSpec, Conflict, ContinuityReview, continuity_spec
|
||||
from ww_core.domain.outline_write_repo import OutlineWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos, OutlineRepo
|
||||
from ww_core.domain.world_entity_repo import WorldEntityWriteRepo
|
||||
from ww_core.orchestrator import run_generator
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_llm_gateway.types import Block, LlmRequest, Scope
|
||||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||||
from ww_skills import GeneratorTool, get_tool, partition_writes
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.routers.generation import _project_context, _world_context
|
||||
from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView
|
||||
from ww_api.schemas.toolbox import (
|
||||
OutlineSceneIngestView,
|
||||
ToolboxListResponse,
|
||||
ToolDescriptorView,
|
||||
ToolGeneratePreviewResponse,
|
||||
ToolGenerateRequest,
|
||||
ToolIngestRequest,
|
||||
ToolIngestResponse,
|
||||
ToolInputFieldView,
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.project_deps import (
|
||||
TierGatewayBuilder,
|
||||
get_memory_repos,
|
||||
get_outline_read_repo,
|
||||
get_outline_write_repo,
|
||||
get_project_repo,
|
||||
get_tier_gateway_builder,
|
||||
get_world_entity_write_repo,
|
||||
)
|
||||
from ww_api.services.toolbox_context import build_toolbox_context
|
||||
|
||||
log = get_logger("ww.api.toolbox")
|
||||
|
||||
router = APIRouter(tags=["toolbox"])
|
||||
|
||||
# OpenAPI 错误响应声明(类型化错误形,§7.1)。
|
||||
_TOOL_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
404: {"model": ErrorEnvelope, "description": "工具或项目不存在 / legacy 工具无通用执行"},
|
||||
422: {"model": ErrorEnvelope, "description": "工具不支持该操作(如不可入库)"},
|
||||
503: {"model": ErrorEnvelope, "description": "LLM 不可用"},
|
||||
}
|
||||
_INGEST_ERRORS: dict[int | str, dict[str, Any]] = {
|
||||
**_TOOL_ERRORS,
|
||||
409: {"model": ErrorEnvelope, "description": "存在未裁决 continuity 冲突"},
|
||||
}
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)]
|
||||
WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)]
|
||||
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
|
||||
GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _descriptor_view(tool: GeneratorTool) -> ToolDescriptorView:
|
||||
return ToolDescriptorView(
|
||||
key=tool.key,
|
||||
title=tool.title,
|
||||
subtitle=tool.subtitle,
|
||||
genre=tool.genre,
|
||||
is_legacy=tool.spec is None,
|
||||
ingestable=tool.ingest is not None,
|
||||
input_fields=[
|
||||
ToolInputFieldView(
|
||||
name=f.name,
|
||||
label=f.label,
|
||||
type=f.type,
|
||||
required=f.required,
|
||||
default=f.default,
|
||||
help=f.help,
|
||||
)
|
||||
for f in tool.input_fields
|
||||
],
|
||||
legacy_route=tool.legacy_route,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/skills/toolbox")
|
||||
async def list_toolbox() -> ToolboxListResponse:
|
||||
"""创作工具箱全量描述符(legacy + 新工具;前端据此渲染卡片栅格 + 表单 + 路由)。"""
|
||||
from ww_skills import TOOLBOX
|
||||
|
||||
# 确定性顺序(按 key 升序),便于前端稳定渲染与测试断言。
|
||||
tools = [_descriptor_view(TOOLBOX[key]) for key in sorted(TOOLBOX)]
|
||||
return ToolboxListResponse(tools=tools)
|
||||
|
||||
|
||||
def _require_real_tool(tool_key: str) -> GeneratorTool:
|
||||
"""解析工具:未知 → 404;legacy(无 spec)→ 404(前端走 legacy_route)。"""
|
||||
tool = get_tool(tool_key)
|
||||
if tool is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"未知工具:{tool_key}", {"tool_key": tool_key})
|
||||
if tool.spec is None:
|
||||
raise AppError(
|
||||
ErrorCode.NOT_FOUND,
|
||||
f"工具 {tool_key} 无通用执行路径,请使用其专属页面(legacy_route)",
|
||||
{"tool_key": tool_key, "legacy_route": tool.legacy_route},
|
||||
)
|
||||
return tool
|
||||
|
||||
|
||||
async def _chapter_beats(
|
||||
outline_repo: OutlineRepo, project_id: uuid.UUID, chapter_no: int
|
||||
) -> list[str]:
|
||||
"""读指定章的大纲粗节拍(缺章/缺大纲 → 空 list,不报错;DB JSONB dict→list 解包)。"""
|
||||
view = await outline_repo.get(project_id, chapter_no)
|
||||
if view is None:
|
||||
return []
|
||||
raw = view.beats or {}
|
||||
return list(raw.get("beats", [])) if isinstance(raw, dict) else []
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/skills/{tool_key}/generate", responses=_TOOL_ERRORS)
|
||||
async def generate_with_tool(
|
||||
project_id: uuid.UUID,
|
||||
tool_key: str,
|
||||
body: ToolGenerateRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
memory: MemoryReposDep,
|
||||
outline_repo: OutlineReadRepoDep,
|
||||
build_gateway: GatewayBuilderDep,
|
||||
session: SessionDep,
|
||||
) -> ToolGeneratePreviewResponse:
|
||||
"""通用生成(结构化预览,不入库)。未知/ legacy 工具→404;项目不存在→404;无凭据→503。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
tool = _require_real_tool(tool_key)
|
||||
spec = tool.spec
|
||||
assert spec is not None # _require_real_tool 已保证(mypy 收窄)
|
||||
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
# brief_only 给精简设定、其余给完整设定(确定性序列化,无时间戳/UUID)。
|
||||
if tool.context_strategy == "brief_only":
|
||||
project_context = _project_context(project.title, project.genre, None, None)
|
||||
else:
|
||||
project_context = _project_context(
|
||||
project.title, project.genre, project.premise, project.theme
|
||||
)
|
||||
|
||||
world_context = ""
|
||||
beats: list[str] = []
|
||||
if tool.context_strategy == "with_world":
|
||||
world_context = await _world_context(memory, project_id)
|
||||
elif tool.context_strategy == "with_outline_chapter":
|
||||
chapter_no = body.chapter_no or 1
|
||||
beats = await _chapter_beats(outline_repo, project_id, chapter_no)
|
||||
|
||||
context = build_toolbox_context(
|
||||
tool.context_strategy,
|
||||
brief=body.brief,
|
||||
project_context=project_context,
|
||||
world_context=world_context,
|
||||
chapter_no=body.chapter_no,
|
||||
beats=beats,
|
||||
)
|
||||
|
||||
gateway = await build_gateway(spec.tier)
|
||||
parsed = await run_generator(
|
||||
spec,
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
# 提交边界:预览不写业务表,但网关 ledger 只 flush → 末尾 commit 落 usage(否则丢失)。
|
||||
await session.commit()
|
||||
log.info(
|
||||
"toolbox_generate_done",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
output_kind=type(parsed).__name__,
|
||||
)
|
||||
return ToolGeneratePreviewResponse(
|
||||
tool_key=tool_key,
|
||||
output_kind=type(parsed).__name__,
|
||||
preview=parsed.model_dump(),
|
||||
)
|
||||
|
||||
|
||||
async def _precheck_world_entities(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
items: list[WorldEntityCardView],
|
||||
world_context: str,
|
||||
gateway: Gateway,
|
||||
project_id: uuid.UUID,
|
||||
) -> list[Conflict]:
|
||||
"""入库前 continuity 预检(world_entities):把待入库实体 vs 既有世界观真相源比对。
|
||||
|
||||
仿 `precheck_generated_cards` 但服务于世界观实体(金手指/词条)。复用 `continuity_spec`
|
||||
(analyst 档,只读,不变量 #3)——直接构 LlmRequest 走网关,节点逻辑无 LLM 非确定性。
|
||||
"""
|
||||
lines = [
|
||||
f"- [{e.type}] {e.name}:{(';'.join(e.rules) if e.rules else '(无硬规则)')}"
|
||||
for e in items
|
||||
]
|
||||
context = (
|
||||
"## 待校验:本次生成的世界观实体(尚未入库)\n"
|
||||
f"{chr(10).join(lines) or '(无)'}\n\n"
|
||||
"## 真相源:既有世界观硬规则\n"
|
||||
f"{world_context or '(暂无世界观设定)'}"
|
||||
)
|
||||
req = LlmRequest(
|
||||
tier=spec.tier,
|
||||
system=[Block(text=spec.system_prompt, cache=True)],
|
||||
input=context,
|
||||
output_schema=spec.output_schema,
|
||||
scope=Scope(user_id=STUB_OWNER_ID, project_id=project_id),
|
||||
)
|
||||
resp = await gateway.run(req)
|
||||
parsed = resp.parsed
|
||||
if not isinstance(parsed, ContinuityReview):
|
||||
raise ValueError("gateway returned no parsed ContinuityReview for toolbox precheck")
|
||||
return list(parsed.conflicts)
|
||||
|
||||
|
||||
def _conflict_409(conflicts: list[Conflict]) -> AppError:
|
||||
return AppError(
|
||||
ErrorCode.CONFLICT_UNRESOLVED,
|
||||
"生成内容与现有设定存在 continuity 冲突,请裁决后确认入库",
|
||||
{
|
||||
"conflicts": [
|
||||
IngestConflictView(
|
||||
type=c.type, where=c.where, refs=list(c.refs), suggestion=c.suggestion
|
||||
).model_dump()
|
||||
for c in conflicts
|
||||
],
|
||||
"conflict_count": len(conflicts),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/projects/{project_id}/skills/{tool_key}/ingest", status_code=201, responses=_INGEST_ERRORS
|
||||
)
|
||||
async def ingest_with_tool(
|
||||
project_id: uuid.UUID,
|
||||
tool_key: str,
|
||||
body: ToolIngestRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
memory: MemoryReposDep,
|
||||
world_write_repo: WorldWriteRepoDep,
|
||||
outline_write_repo: OutlineWriteRepoDep,
|
||||
build_gateway: GatewayBuilderDep,
|
||||
session: SessionDep,
|
||||
) -> ToolIngestResponse:
|
||||
"""通用入库(仅可入库工具):过 continuity 预检 + 白名单 → 写 ingest.table。
|
||||
|
||||
未知/legacy 工具→404;不可入库工具→422;有冲突且未确认→409;越权写表丢弃+审计。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
tool = _require_real_tool(tool_key)
|
||||
spec = tool.spec
|
||||
assert spec is not None
|
||||
if tool.ingest is None:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"工具 {tool_key} 不支持入库(纯预览)",
|
||||
{"tool_key": tool_key},
|
||||
)
|
||||
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
table = tool.ingest.table
|
||||
|
||||
if table == "world_entities":
|
||||
return await _ingest_world_entities(
|
||||
spec,
|
||||
body=body,
|
||||
project_id=project_id,
|
||||
memory=memory,
|
||||
world_write_repo=world_write_repo,
|
||||
session=session,
|
||||
build_gateway=build_gateway,
|
||||
request_id=request_id,
|
||||
tool_key=tool_key,
|
||||
)
|
||||
if table == "outline":
|
||||
return await _ingest_outline(
|
||||
spec,
|
||||
body=body,
|
||||
project_id=project_id,
|
||||
outline_write_repo=outline_write_repo,
|
||||
session=session,
|
||||
request_id=request_id,
|
||||
tool_key=tool_key,
|
||||
)
|
||||
# 描述符的 IngestSpec.table 已限白名单;其余表本期通用入库未实现。
|
||||
raise AppError(ErrorCode.VALIDATION, f"入库表 {table} 暂未支持通用入库", {"table": table})
|
||||
|
||||
|
||||
async def _ingest_world_entities(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
body: ToolIngestRequest,
|
||||
project_id: uuid.UUID,
|
||||
memory: MemoryRepos,
|
||||
world_write_repo: WorldEntityWriteRepo,
|
||||
session: AsyncSession,
|
||||
build_gateway: TierGatewayBuilder,
|
||||
request_id: str | None,
|
||||
tool_key: str,
|
||||
) -> ToolIngestResponse:
|
||||
"""world_entities 入库(金手指/词条):continuity 预检 + 白名单 → 写 world_entities。"""
|
||||
items = body.world_entities
|
||||
# gate 1:入库前 continuity 预检(analyst 网关;编排器追加的检查,守不变量 #1/#3)。
|
||||
world_context = await _world_context(memory, project_id)
|
||||
precheck_gateway = await build_gateway("analyst")
|
||||
conflicts = await _precheck_world_entities(
|
||||
continuity_spec,
|
||||
items=items,
|
||||
world_context=world_context,
|
||||
gateway=precheck_gateway,
|
||||
project_id=project_id,
|
||||
)
|
||||
if conflicts and not body.acknowledge_conflicts:
|
||||
await session.commit() # 落 precheck 网关 usage;不写业务表。
|
||||
log.info(
|
||||
"toolbox_ingest_blocked_by_conflicts",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
conflict_count=len(conflicts),
|
||||
)
|
||||
raise _conflict_409(conflicts)
|
||||
|
||||
# gate 2:权限白名单——按 spec 声明的 writes 过滤(越权表丢弃 + 审计)。
|
||||
allowed, rejected = partition_writes(spec, {"world_entities": items})
|
||||
if rejected:
|
||||
log.warning(
|
||||
"toolbox_ingest_rejected_over_permission",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
created: list[str] = []
|
||||
for entity in allowed.get("world_entities", []):
|
||||
view = await world_write_repo.create(
|
||||
project_id, type=entity.type, name=entity.name, rules=list(entity.rules)
|
||||
)
|
||||
created.append(view.name)
|
||||
|
||||
await session.commit()
|
||||
log.info(
|
||||
"toolbox_ingest_done",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
table="world_entities",
|
||||
created_count=len(created),
|
||||
)
|
||||
return ToolIngestResponse(table="world_entities", created=created, rejected_tables=rejected)
|
||||
|
||||
|
||||
async def _ingest_outline(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
body: ToolIngestRequest,
|
||||
project_id: uuid.UUID,
|
||||
outline_write_repo: OutlineWriteRepo,
|
||||
session: AsyncSession,
|
||||
request_id: str | None,
|
||||
tool_key: str,
|
||||
) -> ToolIngestResponse:
|
||||
"""outline 入库(细纲):白名单 → 把场景节拍 upsert 到目标章的 outline 行。
|
||||
|
||||
细纲不做 continuity 预检(场景是粗节拍的展开,非与世界观真相源比对的设定/角色);
|
||||
仍过 partition_writes 白名单。无章号 → VALIDATION。
|
||||
"""
|
||||
if body.chapter_no is None:
|
||||
raise AppError(ErrorCode.VALIDATION, "细纲入库需指定 chapter_no", {"tool_key": tool_key})
|
||||
|
||||
# gate:权限白名单(细纲 spec 声明 writes=["outline"])。
|
||||
scenes: list[OutlineSceneIngestView] = body.scenes
|
||||
allowed, rejected = partition_writes(spec, {"outline": scenes})
|
||||
if rejected:
|
||||
log.warning(
|
||||
"toolbox_ingest_rejected_over_permission",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
rejected_tables=rejected,
|
||||
)
|
||||
|
||||
allowed_scenes: list[OutlineSceneIngestView] = allowed.get("outline", [])
|
||||
# 场景节拍序列化为该章 beats(按 idx 升序拼装,确定性)。
|
||||
ordered = sorted(allowed_scenes, key=lambda s: s.idx)
|
||||
beats = [s.beat for s in ordered]
|
||||
created: list[str] = []
|
||||
if "outline" in allowed:
|
||||
view = await outline_write_repo.upsert_chapter(
|
||||
project_id,
|
||||
volume=1,
|
||||
chapter_no=body.chapter_no,
|
||||
beats=beats,
|
||||
foreshadow_windows=[],
|
||||
)
|
||||
created = [str(s.idx) for s in ordered]
|
||||
log.info(
|
||||
"toolbox_outline_upserted",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
chapter_no=view.chapter_no,
|
||||
scene_count=len(ordered),
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
log.info(
|
||||
"toolbox_ingest_done",
|
||||
tool_key=tool_key,
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
table="outline",
|
||||
created_count=len(created),
|
||||
)
|
||||
return ToolIngestResponse(table="outline", created=created, rejected_tables=rejected)
|
||||
Reference in New Issue
Block a user