角色入库端点与 toolbox world_entities 入库端点原逐卡/逐条 create(每行一次 SELECT/flush/refresh,N+1 round-trip)。新增 create_many:世界观用 add_all, 角色用一次 IN 查已有名 + 一次 flush(保持 (project_id,name) upsert 幂等与批内 重名去重、保序返回)。单条 create 薄封装 create_many,行为不变(既有调用方/E2E 复用)。复活并复用 CharacterWriteFields、新增 WorldEntityFields 作批量入参。 补两端点「多行一次落库」测试;既有幂等/冲突 gate 测试与 m5 真 pg E2E 全绿。
580 lines
22 KiB
Python
580 lines
22 KiB
Python
"""创作工具箱通用端点(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.chapter_repo import ChapterRepo
|
||
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.rule_repo import RuleWriteRepo
|
||
from ww_core.domain.world_entity_repo import WorldEntityFields, 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,
|
||
TeardownIngestView,
|
||
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_chapter_repo,
|
||
get_memory_repos,
|
||
get_outline_read_repo,
|
||
get_outline_write_repo,
|
||
get_project_repo,
|
||
get_rule_write_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)]
|
||
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
|
||
WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)]
|
||
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
|
||
RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_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 []
|
||
|
||
|
||
async def _prior_chapter_text(
|
||
chapter_repo: ChapterRepo, project_id: uuid.UUID, chapter_no: int
|
||
) -> str:
|
||
"""读续写承接的前文正文:优先该章最新 accepted,无则草稿,皆无 → 空串(端点降级)。
|
||
|
||
core 的 builder 不 import apps/api——正文在端点读 chapter 领域表后经参数注入
|
||
(仿链 accept_op 的注入模式),守不变量 #1(DB 为真相源)。
|
||
"""
|
||
accepted = await chapter_repo.latest_accepted(project_id, chapter_no)
|
||
if accepted is not None and accepted.content.strip():
|
||
return accepted.content
|
||
draft = await chapter_repo.get_draft(project_id, chapter_no)
|
||
if draft is not None and draft.content.strip():
|
||
return draft.content
|
||
return ""
|
||
|
||
|
||
@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,
|
||
chapter_repo: ChapterRepoDep,
|
||
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] = []
|
||
prior_text = ""
|
||
text = body.text or ""
|
||
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)
|
||
elif tool.context_strategy == "with_prior_chapter":
|
||
# 续写:端点先读该章最新 accepted/draft 正文(DB 为真相源,#1)+ 该章节拍,注入 builder。
|
||
chapter_no = body.chapter_no or 1
|
||
prior_text = await _prior_chapter_text(chapter_repo, project_id, chapter_no)
|
||
beats = await _chapter_beats(outline_repo, project_id, chapter_no)
|
||
elif tool.context_strategy == "text_input":
|
||
# text_input 工具(扩写/降AI/拆书)必须有原文样本——空原文会让 LLM 空跑(守输入校验纪律)。
|
||
if not text.strip():
|
||
raise AppError(
|
||
ErrorCode.VALIDATION,
|
||
f"工具 {tool_key} 需要原文输入(text 不可为空)",
|
||
{"tool_key": tool_key},
|
||
)
|
||
# 拆书的书名(body.kind)前置入原文,供拆解对照(teardown system prompt 期待书名)。
|
||
kind = (body.kind or "").strip()
|
||
if kind:
|
||
text = f"书名:{kind}\n\n{text}"
|
||
|
||
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,
|
||
prior_text=prior_text,
|
||
text=text,
|
||
)
|
||
|
||
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,
|
||
rule_write_repo: RuleWriteRepoDep,
|
||
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,
|
||
)
|
||
if table == "rules":
|
||
return await _ingest_rules(
|
||
spec,
|
||
body=body,
|
||
project_id=project_id,
|
||
rule_write_repo=rule_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,
|
||
)
|
||
|
||
fields = [
|
||
WorldEntityFields(type=entity.type, name=entity.name, rules=list(entity.rules))
|
||
for entity in allowed.get("world_entities", [])
|
||
]
|
||
# 批量插入(一次 flush 落全部),保序返回逐条 View。
|
||
created = [view.name for view in await world_write_repo.create_many(project_id, fields)]
|
||
|
||
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)
|
||
|
||
|
||
# 拆书入库的 rules 级别(meta 规则非设定卡,落项目级;单用户原型 owner stub)。
|
||
_TEARDOWN_RULE_LEVEL = "project"
|
||
|
||
|
||
def _flatten_teardown_rules(teardown: TeardownIngestView) -> list[tuple[str, str]]:
|
||
"""把拆书结论拍平成可读 rules 条目:返回 (label, content) 列表(确定性顺序,跳过空字段)。
|
||
|
||
主题/原型/钩子各一条(清单拼成可读文本);结构一条。空清单/空串的字段跳过,不落空规则。
|
||
"""
|
||
entries: list[tuple[str, str]] = []
|
||
if teardown.themes:
|
||
entries.append(("themes", f"核心主题:{';'.join(teardown.themes)}"))
|
||
if teardown.archetypes:
|
||
entries.append(("archetypes", f"人物原型:{';'.join(teardown.archetypes)}"))
|
||
if teardown.structure.strip():
|
||
entries.append(("structure", f"叙事结构:{teardown.structure.strip()}"))
|
||
if teardown.hooks:
|
||
entries.append(("hooks", f"钩子套路:{';'.join(teardown.hooks)}"))
|
||
return entries
|
||
|
||
|
||
async def _ingest_rules(
|
||
spec: AgentSpec,
|
||
*,
|
||
body: ToolIngestRequest,
|
||
project_id: uuid.UUID,
|
||
rule_write_repo: RuleWriteRepo,
|
||
session: AsyncSession,
|
||
request_id: str | None,
|
||
tool_key: str,
|
||
) -> ToolIngestResponse:
|
||
"""rules 入库(拆书):白名单 → 把拆书结论拍平为可读 rules 条目写入项目级 rules。
|
||
|
||
meta 规则非设定卡,无需 continuity 预检(仿 outline 无 409 路径);仍过 partition_writes
|
||
白名单(teardown spec 声明 writes=["rules"])。无 teardown 产物 → VALIDATION。
|
||
"""
|
||
if body.teardown is None:
|
||
raise AppError(ErrorCode.VALIDATION, "拆书入库需提供 teardown 产物", {"tool_key": tool_key})
|
||
|
||
entries = _flatten_teardown_rules(body.teardown)
|
||
|
||
# gate:权限白名单(teardown spec 声明 writes=["rules"])。
|
||
allowed, rejected = partition_writes(spec, {"rules": entries})
|
||
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 label, content in allowed.get("rules", []):
|
||
await rule_write_repo.create(project_id, level=_TEARDOWN_RULE_LEVEL, content=content)
|
||
created.append(label)
|
||
|
||
await session.commit()
|
||
log.info(
|
||
"toolbox_ingest_done",
|
||
tool_key=tool_key,
|
||
project_id=str(project_id),
|
||
request_id=request_id,
|
||
table="rules",
|
||
created_count=len(created),
|
||
)
|
||
return ToolIngestResponse(table="rules", created=created, rejected_tables=rejected)
|