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

@@ -31,6 +31,7 @@ from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
from ww_core.domain.outline_write_repo import OutlineWriteView
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.repositories import OutlineView
from ww_core.domain.rule_repo import RuleWriteView
from ww_core.domain.world_entity_repo import WorldEntityWriteView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
@@ -94,6 +95,17 @@ class _FakeOutlineWriteRepo:
)
class _FakeRuleWriteRepo:
"""拆书入库 rules 用的内存替身:按 (level, content) 收行(只 flush 语义)。"""
def __init__(self) -> None:
self.rows: list[dict[str, Any]] = []
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
self.rows.append({"project_id": project_id, "level": level, "content": content})
return RuleWriteView(project_id=project_id, level=level, content=content)
class _FakeOutlineReadRepo:
"""续写/按章工具读节拍用的内存替身:缺章 → None端点降级到空节拍"""
@@ -199,6 +211,7 @@ def _make_app(
outline_write_repo: _FakeOutlineWriteRepo | None = None,
chapter_repo: _FakeChapterRepo | None = None,
outline_read_repo: _FakeOutlineReadRepo | None = None,
rule_write_repo: _FakeRuleWriteRepo | None = None,
memory: Any = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]:
@@ -210,6 +223,7 @@ def _make_app(
get_outline_read_repo,
get_outline_write_repo,
get_project_repo,
get_rule_write_repo,
get_tier_gateway_builder,
get_world_entity_write_repo,
)
@@ -220,6 +234,7 @@ def _make_app(
outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo()
chapter_repo = chapter_repo or _FakeChapterRepo()
outline_read_repo = outline_read_repo or _FakeOutlineReadRepo()
rule_write_repo = rule_write_repo or _FakeRuleWriteRepo()
async def _build_ok(_tier: str) -> Any:
return gateway
@@ -236,6 +251,7 @@ def _make_app(
app.dependency_overrides[get_outline_write_repo] = lambda: outline_write_repo
app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
app.dependency_overrides[get_outline_read_repo] = lambda: outline_read_repo
app.dependency_overrides[get_rule_write_repo] = lambda: rule_write_repo
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_tier_gateway_builder] = lambda: (
_build_no_creds if no_creds else _build_ok
@@ -285,10 +301,13 @@ async def test_list_toolbox_contains_all_keys() -> None:
"teardown",
}
by_key = {t["key"]: t for t in tools}
# 4 个竞品生成器:非 legacy、纯预览不可入库
for key in ("continue", "expand", "de-ai", "teardown"):
# 续写/扩写/降AI:非 legacy、纯预览不可入库
for key in ("continue", "expand", "de-ai"):
assert by_key[key]["is_legacy"] is False
assert by_key[key]["ingestable"] is False
# F1拆书非 legacy、可入库落 rules
assert by_key["teardown"]["is_legacy"] is False
assert by_key["teardown"]["ingestable"] is True
# legacy 携 legacy_route + is_legacy新工具 ingestable 标记。
assert by_key["worldbuilding"]["is_legacy"] is True
assert by_key["worldbuilding"]["legacy_route"]
@@ -622,3 +641,72 @@ async def test_ingest_outline_upserts_scenes() -> None:
assert outline_repo.rows[0]["beats"] == ["主角觉醒", "初遇反派"]
assert outline_repo.rows[0]["chapter_no"] == 3
assert session.commits == 1
# ---- POST .../ingestF1拆书落库成 rules----
def _teardown_payload() -> dict[str, Any]:
return {
"teardown": {
"themes": ["逆袭", "复仇"],
"archetypes": ["废柴主角", "腹黑反派"],
"structure": "黄金三章立钩→铺垫→爆发",
"hooks": ["开局即巅峰", "扮猪吃虎"],
}
}
@pytest.mark.asyncio
async def test_ingest_teardown_writes_rules_201() -> None:
# F1拆书结论拍平为可读 rules 条目落库(无 continuity 预检,仿 outline 无 409
repo = FakeProjectRepo()
pid = await _seed_project(repo)
rule_repo = _FakeRuleWriteRepo()
gateway = _SchemaRoutingGateway({})
app, session, *_ = _make_app(project_repo=repo, gateway=gateway, rule_write_repo=rule_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/teardown/ingest", json=_teardown_payload()
)
assert resp.status_code == 201
body = resp.json()
assert body["table"] == "rules"
assert body["rejected_tables"] == []
# 真落行themes/archetypes/structure/hooks 各拼成可读规则条目。
assert len(rule_repo.rows) >= 1
contents = "\n".join(r["content"] for r in rule_repo.rows)
assert "逆袭" in contents
assert "废柴主角" in contents
assert "黄金三章立钩" in contents
assert "开局即巅峰" in contents
# 全部落到 project 级 rulesowner stub 单用户)。
for r in rule_repo.rows:
assert r["level"] == "project"
assert r["project_id"] == pid
assert session.commits == 1
@pytest.mark.asyncio
async def test_teardown_generate_preview_does_not_write_rules() -> None:
# 负向预览generate只产结构化产物绝不写 rules不变量 #3入库经 ingest gate
repo = FakeProjectRepo()
pid = await _seed_project(repo)
rule_repo = _FakeRuleWriteRepo()
parsed = BookTeardownResult(
themes=["逆袭"], archetypes=["废柴主角"], structure="立钩", hooks=["开局巅峰"]
)
gateway = _CaptureGateway(parsed)
app, _, *_ = _make_app(project_repo=repo, gateway=gateway, rule_write_repo=rule_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/teardown/generate",
json={"kind": "某爆款", "text": "样本章节正文……"},
)
assert resp.status_code == 200
assert resp.json()["output_kind"] == "BookTeardownResult"
assert rule_repo.rows == [] # 预览不写库