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:
@@ -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 .../ingest(F1:拆书落库成 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 级 rules(owner 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 == [] # 预览不写库
|
||||
|
||||
Reference in New Issue
Block a user