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:
@@ -26,6 +26,7 @@ from ww_api.routers import (
|
||||
rules,
|
||||
settings_providers,
|
||||
style,
|
||||
toolbox,
|
||||
)
|
||||
from ww_api.security.credentials import _fernet
|
||||
from ww_api.services.project_deps import seed_stub_user
|
||||
@@ -115,6 +116,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(style.router)
|
||||
app.include_router(generation.router)
|
||||
app.include_router(generation.skills_router)
|
||||
app.include_router(toolbox.router)
|
||||
app.include_router(settings_providers.router)
|
||||
app.include_router(kimi_oauth.router)
|
||||
return app
|
||||
|
||||
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)
|
||||
141
apps/api/ww_api/schemas/toolbox.py
Normal file
141
apps/api/ww_api/schemas/toolbox.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""创作工具箱通用端点的请求/响应 schema(C3 扩 T6 / 通用生成器框架)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
|
||||
|
||||
- `GET /skills/toolbox` → `ToolboxListResponse`(声明式描述符视图,含 legacy_route 供前端路由)。
|
||||
- `POST .../skills/{tool_key}/generate` → `ToolGeneratePreviewResponse`(结构化预览,不入库)。
|
||||
- `POST .../skills/{tool_key}/ingest` → `ToolIngestResponse`(过 continuity gate + 白名单后入库)。
|
||||
|
||||
预览/入库共用既有 schema:world_entities 入库走 `WorldEntityCardView`(贴 worldbuilder 形),
|
||||
outline 入库走 `OutlineSceneIngestView`(贴 fine-outline 场景行)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView
|
||||
|
||||
# 一次性生成数量上限沿用 generation 的保守值(避免巨量调用);章号下限。
|
||||
MIN_CHAPTER_NO = 1
|
||||
|
||||
|
||||
# ---- GET /skills/toolbox(描述符列表)----
|
||||
|
||||
|
||||
class ToolInputFieldView(BaseModel):
|
||||
"""单个声明式表单字段视图(贴 ww_skills.InputField)。"""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
type: str = Field(description="控件类型:text / textarea / number / select 等")
|
||||
required: bool = True
|
||||
default: str | None = None
|
||||
help: str | None = None
|
||||
|
||||
|
||||
class ToolDescriptorView(BaseModel):
|
||||
"""单个生成器卡片描述符视图(前端据此渲染卡片栅格 + 输入表单)。"""
|
||||
|
||||
key: str = Field(description="路由用稳定标识(brainstorm / book-title ...)")
|
||||
title: str
|
||||
subtitle: str
|
||||
genre: str | None = None
|
||||
is_legacy: bool = Field(description="legacy 工具(spec=None)→ 前端走 legacy_route 跳现有页面")
|
||||
ingestable: bool = Field(description="是否可入库(声明了 ingest 目标)")
|
||||
input_fields: list[ToolInputFieldView] = Field(default_factory=list)
|
||||
legacy_route: str | None = Field(
|
||||
default=None, description="legacy 工具指向的现有页面路径(含 {id} 占位)"
|
||||
)
|
||||
|
||||
|
||||
class ToolboxListResponse(BaseModel):
|
||||
"""GET /skills/toolbox:创作工具箱全量描述符(legacy + 新工具)。"""
|
||||
|
||||
tools: list[ToolDescriptorView] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---- POST .../skills/{tool_key}/generate(结构化预览)----
|
||||
|
||||
|
||||
class ToolGenerateRequest(BaseModel):
|
||||
"""通用生成请求(覆盖全部工具的可选入参;按工具 input_fields 取用,snake_case)。"""
|
||||
|
||||
brief: str = Field(default="", description="一句话需求/方向(可空,空则按题材发散)")
|
||||
chapter_no: int | None = Field(
|
||||
default=None, ge=MIN_CHAPTER_NO, description="按章展开类工具的章号(开篇/细纲)"
|
||||
)
|
||||
count: int | None = Field(default=None, ge=1, le=12, description="生成数量(部分工具可用)")
|
||||
kind: str | None = Field(default=None, description="命名对象类别等(部分工具可用)")
|
||||
|
||||
|
||||
class ToolGeneratePreviewResponse(BaseModel):
|
||||
"""通用生成预览:结构化产物(不持久化;仅记 ledger)。
|
||||
|
||||
`output_kind` = 该工具产物的 schema 名(如 IdeaListResult),供前端按类型选预览渲染器;
|
||||
`preview` = 解析后的结构化产物(按各工具 output_schema 的形,键名见 ww_agents.schemas)。
|
||||
"""
|
||||
|
||||
tool_key: str
|
||||
output_kind: str = Field(description="产物 schema 名(前端按此选预览渲染器)")
|
||||
preview: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="结构化产物(各工具 output_schema 的 model_dump;不入库)",
|
||||
)
|
||||
|
||||
|
||||
# ---- POST .../skills/{tool_key}/ingest(入库;过 continuity gate + 白名单)----
|
||||
|
||||
|
||||
class OutlineSceneIngestView(BaseModel):
|
||||
"""细纲入库的单章场景行(贴 ww_agents.Scene;落 outline 表)。"""
|
||||
|
||||
idx: int
|
||||
beat: str
|
||||
purpose: str = ""
|
||||
conflict: str = ""
|
||||
hook: str = ""
|
||||
|
||||
|
||||
class ToolIngestRequest(BaseModel):
|
||||
"""通用入库请求:待持久化的产物 + 冲突确认。
|
||||
|
||||
仅声明了 `ingest` 的工具可用。按入库表取用对应字段:
|
||||
- world_entities(金手指/词条)→ `world_entities`(贴 WorldEntityCardView:type/name/rules);
|
||||
- outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView)。
|
||||
`acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate)。
|
||||
"""
|
||||
|
||||
world_entities: list[WorldEntityCardView] = Field(
|
||||
default_factory=list, description="world_entities 入库项(金手指/词条)"
|
||||
)
|
||||
chapter_no: int | None = Field(
|
||||
default=None, ge=MIN_CHAPTER_NO, description="outline 入库的目标章号(细纲)"
|
||||
)
|
||||
scenes: list[OutlineSceneIngestView] = Field(
|
||||
default_factory=list, description="outline 入库的场景行(细纲)"
|
||||
)
|
||||
acknowledge_conflicts: bool = Field(
|
||||
default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库"
|
||||
)
|
||||
|
||||
|
||||
class ToolIngestResponse(BaseModel):
|
||||
"""通用入库结果(201):写入项标识 + 被白名单丢弃的越权表(审计)。"""
|
||||
|
||||
table: str = Field(description="入库目标表")
|
||||
created: list[str] = Field(
|
||||
default_factory=list, description="写入项的标识(实体名 / 场景索引),按入参顺序"
|
||||
)
|
||||
rejected_tables: list[str] = Field(
|
||||
default_factory=list, description="被权限白名单丢弃的越权写表名(审计;正常为空)"
|
||||
)
|
||||
|
||||
|
||||
class ToolIngestConflictDetails(BaseModel):
|
||||
"""409 冲突详情(贴 generation 的 IngestConflictView)。"""
|
||||
|
||||
conflicts: list[IngestConflictView] = Field(default_factory=list)
|
||||
conflict_count: int = 0
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
@@ -211,6 +212,27 @@ def get_outline_read_repo(
|
||||
return SqlOutlineRepo(session)
|
||||
|
||||
|
||||
# 创作工具箱通用端点的网关来源:按 tier 动态建网关(工具的 tier 在运行时才知)。
|
||||
# 测试经 `app.dependency_overrides[get_tier_gateway_builder]` 注入返回 mock 网关的 builder。
|
||||
TierGatewayBuilder = Callable[[Tier], Awaitable[Gateway]]
|
||||
|
||||
|
||||
def get_tier_gateway_builder(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> TierGatewayBuilder:
|
||||
"""返回「按 tier 建网关」的可注入缝(创作工具箱按工具 spec.tier 动态路由)。
|
||||
|
||||
无凭据时 builder 调用内 `build_gateway_for_tier` 抛 `LLM_UNAVAILABLE`(503)——
|
||||
与固定档位网关缝语义一致。测试 override 返回固定 mock 网关的 builder(绝不联网)。
|
||||
"""
|
||||
store = SqlCredentialStore(session)
|
||||
|
||||
async def _build(tier: Tier) -> Gateway:
|
||||
return await build_gateway_for_tier(session, store, tier)
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
async def get_worldbuilder_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
|
||||
60
apps/api/ww_api/services/toolbox_context.py
Normal file
60
apps/api/ww_api/services/toolbox_context.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""创作工具箱 · context 派发(PURE,LLM-free,确定性)。
|
||||
|
||||
把 `ContextStrategy` 映射成喂给网关的注入文本。**纯函数**:给定已组装好的材料
|
||||
(项目设定 / 世界观文本 / 本章节拍),同输入同输出、无 IO、无时间戳——便于单测与缓存。
|
||||
|
||||
IO(读 projects/world_entities/outline)发生在端点;本模块只负责「材料 → 注入文本」的
|
||||
确定性拼装,复用 `ww_core.orchestrator` 的既有 context builders(brief / outline-chapter)
|
||||
与 `routers/generation` 的 `_project_context` / `_world_context` 序列化口径。
|
||||
|
||||
策略 → 材料:
|
||||
- `brief_only` :作品设定(仅标题/题材级)+ 一句话需求;
|
||||
- `with_project` :作品设定(含前提/主题)+ 一句话需求;
|
||||
- `with_world` :作品设定 + world_entities 硬规则卡 + 一句话需求;
|
||||
- `with_outline_chapter`:作品设定 + 指定章大纲节拍(缺章/缺大纲 → 空节拍,不报错)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from ww_core.orchestrator import build_brief_context, build_outline_chapter_context
|
||||
from ww_skills import ContextStrategy
|
||||
|
||||
|
||||
def build_toolbox_context(
|
||||
strategy: ContextStrategy,
|
||||
*,
|
||||
brief: str,
|
||||
project_context: str,
|
||||
world_context: str = "",
|
||||
chapter_no: int | None = None,
|
||||
beats: Sequence[str] = (),
|
||||
) -> str:
|
||||
"""据注入策略组装喂网关的文本(PURE,确定性)。
|
||||
|
||||
`project_context` 由调用方按策略序列化(brief_only 给精简设定、with_project 给完整设定);
|
||||
`world_context` 仅 with_world 用(已序列化的硬规则卡);`chapter_no`/`beats` 仅
|
||||
with_outline_chapter 用(缺章/缺大纲时 beats 为空,降级到空节拍而非报错)。
|
||||
"""
|
||||
if strategy in ("brief_only", "with_project"):
|
||||
return build_brief_context(brief=brief, project_context=project_context)
|
||||
|
||||
if strategy == "with_world":
|
||||
base = build_brief_context(brief=brief, project_context=project_context)
|
||||
world_block = world_context.strip() or "(暂无世界观设定)"
|
||||
return f"{base}\n\n## 世界观(硬规则供契合/校验)\n{world_block}"
|
||||
|
||||
if strategy == "with_outline_chapter":
|
||||
# 缺章号时退回首章占位(端点对需要章号的工具会先校验,这里只做确定性兜底)。
|
||||
no = chapter_no if chapter_no is not None else 1
|
||||
outline_block = build_outline_chapter_context(
|
||||
chapter_no=no, beats=beats, project_context=project_context
|
||||
)
|
||||
brief_block = brief.strip()
|
||||
if brief_block:
|
||||
return f"{outline_block}\n\n## 创作需求\n{brief_block}"
|
||||
return outline_block
|
||||
|
||||
# 受限枚举不应到达此分支;保守抛错而非静默错配策略。
|
||||
raise ValueError(f"unknown context strategy: {strategy!r}")
|
||||
Reference in New Issue
Block a user