feat(toolbox): F1 拆书 teardown 落库成项目 rules

- teardown_spec.writes=["rules"](只声明 tier,入库仍经白名单 gate,不变量 #3)
- toolbox_registry teardown entry 加 ingest=IngestSpec(table="rules")
- routers/toolbox.py 通用 ingest dispatcher 加 _ingest_rules:
  BookTeardownResult(themes/archetypes/structure/hooks) 拍平为可读 rules 条目,
  落项目级(无 continuity 预检,仿 _ingest_outline 无 409)
- schemas/toolbox.py 加 TeardownIngestView + ToolIngestRequest.teardown
- 单测:teardown ingest→rules 真落行 + 预览(generate)不写库

后端门禁绿:ruff/format/mypy clean · alembic no drift · pytest 644 passed
This commit is contained in:
Yaojia Wang
2026-06-23 20:00:25 +02:00
parent 44642c37b4
commit 61398a1452
8 changed files with 216 additions and 17 deletions

View File

@@ -24,6 +24,7 @@ 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 WorldEntityWriteRepo
from ww_core.orchestrator import run_generator
from ww_db import get_session
@@ -37,6 +38,7 @@ 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,
@@ -53,6 +55,7 @@ from ww_api.services.project_deps import (
get_outline_read_repo,
get_outline_write_repo,
get_project_repo,
get_rule_write_repo,
get_tier_gateway_builder,
get_world_entity_write_repo,
)
@@ -79,6 +82,7 @@ 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)]
@@ -316,6 +320,7 @@ async def ingest_with_tool(
memory: MemoryReposDep,
world_write_repo: WorldWriteRepoDep,
outline_write_repo: OutlineWriteRepoDep,
rule_write_repo: RuleWriteRepoDep,
build_gateway: GatewayBuilderDep,
session: SessionDep,
) -> ToolIngestResponse:
@@ -362,6 +367,16 @@ async def ingest_with_tool(
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})
@@ -493,3 +508,72 @@ async def _ingest_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)