diff --git a/apps/api/tests/test_toolbox_endpoints.py b/apps/api/tests/test_toolbox_endpoints.py index 06d839d..105e767 100644 --- a/apps/api/tests/test_toolbox_endpoints.py +++ b/apps/api/tests/test_toolbox_endpoints.py @@ -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 == [] # 预览不写库 diff --git a/apps/api/ww_api/routers/toolbox.py b/apps/api/ww_api/routers/toolbox.py index a4d3ef4..c7b754a 100644 --- a/apps/api/ww_api/routers/toolbox.py +++ b/apps/api/ww_api/routers/toolbox.py @@ -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) diff --git a/apps/api/ww_api/schemas/toolbox.py b/apps/api/ww_api/schemas/toolbox.py index 6401ed5..13d7e9e 100644 --- a/apps/api/ww_api/schemas/toolbox.py +++ b/apps/api/ww_api/schemas/toolbox.py @@ -105,12 +105,22 @@ class OutlineSceneIngestView(BaseModel): hook: str = "" +class TeardownIngestView(BaseModel): + """拆书入库产物(贴 ww_agents.BookTeardownResult;落 rules 表)。F1。""" + + themes: list[str] = Field(default_factory=list, description="核心主题/立意清单") + archetypes: list[str] = Field(default_factory=list, description="人物原型/角色模板清单") + structure: str = Field(default="", description="叙事结构概述") + hooks: list[str] = Field(default_factory=list, description="抓人钩子/爽点套路清单") + + class ToolIngestRequest(BaseModel): """通用入库请求:待持久化的产物 + 冲突确认。 仅声明了 `ingest` 的工具可用。按入库表取用对应字段: - world_entities(金手指/词条)→ `world_entities`(贴 WorldEntityCardView:type/name/rules); - - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView)。 + - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView); + - rules(拆书)→ `teardown`(贴 TeardownIngestView:themes/archetypes/structure/hooks)。 `acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate)。 """ @@ -123,6 +133,9 @@ class ToolIngestRequest(BaseModel): scenes: list[OutlineSceneIngestView] = Field( default_factory=list, description="outline 入库的场景行(细纲)" ) + teardown: TeardownIngestView | None = Field( + default=None, description="rules 入库的拆书结论(F1;拍平为可读规则条目)" + ) acknowledge_conflicts: bool = Field( default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库" ) diff --git a/packages/agents/tests/test_competitor_specs.py b/packages/agents/tests/test_competitor_specs.py index 39bc6b8..c0b42ce 100644 --- a/packages/agents/tests/test_competitor_specs.py +++ b/packages/agents/tests/test_competitor_specs.py @@ -1,7 +1,8 @@ """Scope B 竞品快赢 · 4 个生成器的 spec/schema 契约测试。 校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema, -对齐不变量 #2(只声明 tier)/ #3(4 生成器一律纯预览 writes=[])。不联网、无 DB。 +对齐不变量 #2(只声明 tier)/ #3(续写/扩写/降AI 纯预览 writes=[];拆书 F1 落库 rules)。 +不联网、无 DB。 """ from __future__ import annotations @@ -19,17 +20,25 @@ from ww_agents import ( teardown_spec, ) -# (spec, name, tier, reads, output_schema) +# (spec, name, tier, reads, writes, output_schema) _COMPETITOR_SPECS = [ - (continue_spec, "continue", "writer", ["projects", "outline", "chapters"], ContinuationResult), - (expand_spec, "expand", "writer", ["projects"], PolishResult), - (de_ai_spec, "de-ai", "analyst", ["projects"], DeAiResult), - (teardown_spec, "teardown", "analyst", ["projects"], BookTeardownResult), + ( + continue_spec, + "continue", + "writer", + ["projects", "outline", "chapters"], + [], + ContinuationResult, + ), + (expand_spec, "expand", "writer", ["projects"], [], PolishResult), + (de_ai_spec, "de-ai", "analyst", ["projects"], [], DeAiResult), + # F1:拆书结论可落库为项目 rules(writes=["rules"];其余三者仍纯预览)。 + (teardown_spec, "teardown", "analyst", ["projects"], ["rules"], BookTeardownResult), ] @pytest.mark.parametrize( - ("spec", "name", "tier", "reads", "output_schema"), + ("spec", "name", "tier", "reads", "writes", "output_schema"), _COMPETITOR_SPECS, ) def test_competitor_spec_declares_expected_contract( @@ -37,12 +46,13 @@ def test_competitor_spec_declares_expected_contract( name: str, tier: str, reads: list[str], + writes: list[str], output_schema: type, ) -> None: assert spec.name == name assert spec.tier == tier assert spec.reads == reads - assert spec.writes == [] # 不变量 #3:4 生成器一律纯预览 + assert spec.writes == writes # 不变量 #3:续写/扩写/降AI 纯预览;拆书落 rules assert spec.output_schema is output_schema assert spec.scope == "builtin" assert spec.input_schema is None # 注入材料为序列化文本,非结构化入参 diff --git a/packages/agents/ww_agents/schemas.py b/packages/agents/ww_agents/schemas.py index f2f58c4..20c857c 100644 --- a/packages/agents/ww_agents/schemas.py +++ b/packages/agents/ww_agents/schemas.py @@ -571,8 +571,8 @@ class DeAiResult(BaseModel): class BookTeardownResult(BaseModel): """拆书生成器结构化产出:样本作品的结构化拆解(Scope B 竞品快赢)。 - 纯预览产物——不映射任何业务表、不入库(`teardown_spec.writes=[]`,不变量 #3)。 - 拆书「落库成 rules」为可选 follow-up(需 `KNOWN_TABLES` + `_ingest_rules`),本期不做。 + F1:可经 ingest 端点把结论拍平为项目 `rules` 条目落库(`teardown_spec.writes=["rules"]`); + 入库仍经白名单 gate(不变量 #3),预览(generate)不写库。 """ themes: list[str] = Field( diff --git a/packages/agents/ww_agents/specs.py b/packages/agents/ww_agents/specs.py index 6a5c2a6..2bb854d 100644 --- a/packages/agents/ww_agents/specs.py +++ b/packages/agents/ww_agents/specs.py @@ -799,6 +799,6 @@ teardown_spec = AgentSpec( input_schema=None, # 注入材料为序列化文本(设定 + 样本 + 需求),非结构化入参 output_schema=BookTeardownResult, reads=["projects"], - writes=[], # 纯预览,不写库(不变量 #3) + writes=["rules"], # F1:拆书结论可落库为项目 rules(入库仍经 ingest 白名单 gate,#3) scope="builtin", ) diff --git a/packages/skills/tests/test_toolbox_registry.py b/packages/skills/tests/test_toolbox_registry.py index d1a0df2..a9bb288 100644 --- a/packages/skills/tests/test_toolbox_registry.py +++ b/packages/skills/tests/test_toolbox_registry.py @@ -19,13 +19,13 @@ _NEW_KEYS = { "glossary", "opening", "fine-outline", - # 竞品快赢(Scope B):续写 / 扩写 / 降AI率 / 拆书(全 preview-only)。 + # 竞品快赢(Scope B):续写/扩写/降AI率 preview-only;拆书 F1 可落库 rules。 "continue", "expand", "de-ai", "teardown", } -_INGEST_KEYS = {"golden-finger", "glossary", "fine-outline"} +_INGEST_KEYS = {"golden-finger", "glossary", "fine-outline", "teardown"} def test_toolbox_has_all_tools() -> None: @@ -70,6 +70,9 @@ def test_ingest_tools_declare_known_table() -> None: assert TOOLBOX["glossary"].ingest.table == "world_entities" assert TOOLBOX["fine-outline"].ingest is not None assert TOOLBOX["fine-outline"].ingest.table == "outline" + # F1:拆书结论落库为项目 rules。 + assert TOOLBOX["teardown"].ingest is not None + assert TOOLBOX["teardown"].ingest.table == "rules" def test_chapter_tools_have_chapter_no_field() -> None: diff --git a/packages/skills/ww_skills/toolbox_registry.py b/packages/skills/ww_skills/toolbox_registry.py index 675e549..fe25a0a 100644 --- a/packages/skills/ww_skills/toolbox_registry.py +++ b/packages/skills/ww_skills/toolbox_registry.py @@ -197,7 +197,7 @@ TOOLBOX: dict[str, GeneratorTool] = { input_fields=[_chapter_no_field(), _BRIEF_FIELD], ingest=IngestSpec(table="outline"), ), - # ---- 竞品快赢(Scope B):续写 / 扩写 / 降AI率 / 拆书(全 preview-only,writes=[])---- + # ---- 竞品快赢(Scope B):续写/扩写/降AI率 preview-only;拆书 F1 可落库 rules ---- "continue": GeneratorTool( key="continue", title="续写生成器", @@ -252,6 +252,7 @@ TOOLBOX: dict[str, GeneratorTool] = { _SOURCE_TEXT_FIELD, _BRIEF_FIELD, ], + ingest=IngestSpec(table="rules"), # F1:拆书结论拍平为项目 rules 条目落库 ), }