Files
writer-work-flow/apps/api/ww_api/routers/toolbox.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

454 lines
17 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.

"""创作工具箱通用端点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:
"""解析工具:未知 → 404legacy无 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)