diff --git a/apps/api/tests/test_toolbox_context.py b/apps/api/tests/test_toolbox_context.py index d5a1439..d141e4b 100644 --- a/apps/api/tests/test_toolbox_context.py +++ b/apps/api/tests/test_toolbox_context.py @@ -79,6 +79,44 @@ def test_with_outline_chapter_missing_beats_does_not_error() -> None: assert "暂无大纲节拍" in text +def test_with_prior_chapter_injects_prior_text_and_beats() -> None: + text = build_toolbox_context( + "with_prior_chapter", + brief="续写下文", + project_context="标题:测试", + prior_text="他推开门,看见了那道身影。", + beats=["主角揭穿阴谋"], + ) + assert "前文正文" in text + assert "他推开门" in text + assert "主角揭穿阴谋" in text + + +def test_with_prior_chapter_empty_prior_degrades() -> None: + text = build_toolbox_context( + "with_prior_chapter", brief="", project_context="标题:测试", prior_text="" + ) + # 无前文 → 降级占位(不报错),交由 system_prompt 处理。 + assert "暂无前文" in text + + +def test_text_input_injects_source_text() -> None: + text = build_toolbox_context( + "text_input", + brief="加强环境描写", + project_context="标题:测试", + text="原始的一段正文。", + ) + assert "## 原文" in text + assert "原始的一段正文" in text + assert "加强环境描写" in text + + +def test_text_input_empty_text_degrades() -> None: + text = build_toolbox_context("text_input", brief="", project_context="标题:测试", text="") + assert "未提供原文" in text + + def test_unknown_strategy_raises() -> None: with pytest.raises(ValueError): build_toolbox_context("nope", brief="x", project_context="y") # type: ignore[arg-type] diff --git a/apps/api/tests/test_toolbox_endpoints.py b/apps/api/tests/test_toolbox_endpoints.py index 68ab02b..6ee1590 100644 --- a/apps/api/tests/test_toolbox_endpoints.py +++ b/apps/api/tests/test_toolbox_endpoints.py @@ -19,9 +19,18 @@ from cryptography.fernet import Fernet from fakes_projects import FakeProjectRepo, FakeSession from test_projects import _empty_memory_repos from ww_agents import Conflict, ContinuityReview -from ww_agents.schemas import Idea, IdeaListResult +from ww_agents.schemas import ( + BookTeardownResult, + ContinuationResult, + DeAiResult, + Idea, + IdeaListResult, + PolishResult, +) +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.world_entity_repo import WorldEntityWriteView from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage from ww_shared import AppError, ErrorCode @@ -85,6 +94,88 @@ class _FakeOutlineWriteRepo: ) +class _FakeOutlineReadRepo: + """续写/按章工具读节拍用的内存替身:缺章 → None(端点降级到空节拍)。""" + + def __init__(self, beats_by_chapter: dict[int, list[str]] | None = None) -> None: + self._beats = beats_by_chapter or {} + + async def get(self, project_id: uuid.UUID, chapter_no: int) -> Any: + beats = self._beats.get(chapter_no) + if beats is None: + return None + return OutlineView( + chapter_no=chapter_no, volume=1, beats={"beats": beats}, foreshadow_windows=[] + ) + + async def list_for_project(self, project_id: uuid.UUID) -> list[Any]: + return [] + + +class _FakeChapterRepo: + """续写读前文用的内存章节替身:按 chapter_no 存 accepted/draft 正文。""" + + def __init__( + self, + *, + accepted: dict[int, str] | None = None, + drafts: dict[int, str] | None = None, + ) -> None: + self._accepted = accepted or {} + self._drafts = drafts or {} + + async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> ChapterView | None: + content = self._accepted.get(chapter_no) + if content is None: + return None + return ChapterView( + project_id=project_id, + chapter_no=chapter_no, + volume=1, + content=content, + status="accepted", + version=1, + ) + + async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> ChapterDraftView | None: + content = self._drafts.get(chapter_no) + if content is None: + return None + return ChapterDraftView( + project_id=project_id, + chapter_no=chapter_no, + volume=1, + content=content, + status="draft", + version=1, + ) + + +class _CaptureGateway: + """记录最近一次 req.input(验证续写读前文/原文路由进了 context),返指定 parsed。""" + + def __init__(self, parsed: Any) -> None: + self._parsed = parsed + self.last_input: str | None = None + + async def run(self, req: LlmRequest) -> LlmResponse: + # 工具箱端点恒传序列化文本 context(str);存为 str 供断言。 + self.last_input = req.input if isinstance(req.input, str) else str(req.input) + return LlmResponse( + text=self._parsed.model_dump_json(), + parsed=self._parsed, + usage=Usage( + provider="fake", + model="fake", + input_tokens=1, + output_tokens=1, + cost_minor=0, + currency="USD", + ), + served_by=ServedBy(provider="fake", model="fake"), + ) + + def _ideas() -> IdeaListResult: return IdeaListResult(ideas=[Idea(premise="废柴觉醒系统", hook="开局即巅峰", genre_fit="玄幻")]) @@ -106,13 +197,17 @@ def _make_app( session: FakeSession | None = None, world_repo: _FakeWorldWriteRepo | None = None, outline_write_repo: _FakeOutlineWriteRepo | None = None, + chapter_repo: _FakeChapterRepo | None = None, + outline_read_repo: _FakeOutlineReadRepo | None = None, memory: Any = None, no_creds: bool = False, ) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]: os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) from ww_api.main import create_app from ww_api.services.project_deps import ( + get_chapter_repo, get_memory_repos, + get_outline_read_repo, get_outline_write_repo, get_project_repo, get_tier_gateway_builder, @@ -123,6 +218,8 @@ def _make_app( session = session or FakeSession() world_repo = world_repo or _FakeWorldWriteRepo() outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo() + chapter_repo = chapter_repo or _FakeChapterRepo() + outline_read_repo = outline_read_repo or _FakeOutlineReadRepo() async def _build_ok(_tier: str) -> Any: return gateway @@ -137,6 +234,8 @@ def _make_app( ) app.dependency_overrides[get_world_entity_write_repo] = lambda: world_repo 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_session] = lambda: session app.dependency_overrides[get_tier_gateway_builder] = lambda: ( _build_no_creds if no_creds else _build_ok @@ -180,8 +279,16 @@ async def test_list_toolbox_contains_all_keys() -> None: "glossary", "opening", "fine-outline", + "continue", + "expand", + "de-ai", + "teardown", } by_key = {t["key"]: t for t in tools} + # 4 个竞品生成器:非 legacy、纯预览(不可入库)。 + for key in ("continue", "expand", "de-ai", "teardown"): + assert by_key[key]["is_legacy"] is False + assert by_key[key]["ingestable"] is False # legacy 携 legacy_route + is_legacy;新工具 ingestable 标记。 assert by_key["worldbuilding"]["is_legacy"] is True assert by_key["worldbuilding"]["legacy_route"] @@ -272,6 +379,121 @@ async def test_generate_no_credentials_503() -> None: assert session.commits == 0 +# ---- POST .../generate(Scope B 竞品快赢:续写/扩写/降AI/拆书)---- + + +@pytest.mark.asyncio +async def test_generate_continue_reads_prior_chapter() -> None: + # 续写:端点读该章最新 accepted 正文 → 注入 context(with_prior_chapter 路由)。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + chapter_repo = _FakeChapterRepo(accepted={2: "他握紧了那柄剑,缓缓转身。"}) + gateway = _CaptureGateway(ContinuationResult(text="剑光一闪,敌人应声倒地。")) + app, session, *_ = _make_app(project_repo=repo, gateway=gateway, chapter_repo=chapter_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/skills/continue/generate", json={"chapter_no": 2, "brief": "续写战斗"} + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["tool_key"] == "continue" + assert body["output_kind"] == "ContinuationResult" + assert body["preview"]["text"] == "剑光一闪,敌人应声倒地。" + # 前文正文确实进了喂网关的 context(读章路由生效)。 + assert gateway.last_input is not None + assert "他握紧了那柄剑" in gateway.last_input + assert session.commits == 1 # 预览不写业务表,落 ledger + + +@pytest.mark.asyncio +async def test_generate_continue_falls_back_to_draft() -> None: + # 无 accepted → 退回草稿正文。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + chapter_repo = _FakeChapterRepo(drafts={1: "草稿里的开头一段。"}) + gateway = _CaptureGateway(ContinuationResult(text="续写。")) + app, *_ = _make_app(project_repo=repo, gateway=gateway, chapter_repo=chapter_repo) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/skills/continue/generate", json={"chapter_no": 1} + ) + + assert resp.status_code == 200 + assert gateway.last_input is not None + assert "草稿里的开头一段" in gateway.last_input + + +@pytest.mark.asyncio +async def test_generate_expand_routes_text_input() -> None: + # 扩写:原文经 text 字段 → 注入 context(text_input 路由)。 + repo = FakeProjectRepo() + pid = await _seed_project(repo) + gateway = _CaptureGateway(PolishResult(text="扩写后的丰富正文。")) + app, session, *_ = _make_app(project_repo=repo, gateway=gateway) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/skills/expand/generate", + json={"text": "原始简短的一句话。", "brief": "加强描写"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["output_kind"] == "PolishResult" + assert body["preview"]["text"] == "扩写后的丰富正文。" + assert gateway.last_input is not None + assert "原始简短的一句话" in gateway.last_input + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_generate_de_ai_routes_text_input() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + gateway = _CaptureGateway(DeAiResult(text="更自然的人写质感正文。")) + app, *_ = _make_app(project_repo=repo, gateway=gateway) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/skills/de-ai/generate", + json={"text": "充斥着 AI 腔的排比句。"}, + ) + + assert resp.status_code == 200 + assert resp.json()["output_kind"] == "DeAiResult" + assert gateway.last_input is not None + assert "充斥着 AI 腔的排比句" in gateway.last_input + + +@pytest.mark.asyncio +async def test_generate_teardown_returns_structured() -> None: + repo = FakeProjectRepo() + pid = await _seed_project(repo) + parsed = BookTeardownResult( + themes=["逆袭"], + archetypes=["废柴主角"], + structure="黄金三章立钩", + hooks=["开局即巅峰"], + ) + gateway = _CaptureGateway(parsed) + app, *_ = _make_app(project_repo=repo, gateway=gateway) + + async with _client(app) as client: + resp = await client.post( + f"/projects/{pid}/skills/teardown/generate", + json={"kind": "某爆款", "text": "样本章节正文……"}, + ) + + assert resp.status_code == 200 + body = resp.json() + assert body["output_kind"] == "BookTeardownResult" + assert body["preview"]["themes"] == ["逆袭"] + assert body["preview"]["structure"] == "黄金三章立钩" + + # ---- POST .../ingest(world_entities gate)---- diff --git a/apps/api/ww_api/routers/toolbox.py b/apps/api/ww_api/routers/toolbox.py index c24fb3f..8a08fde 100644 --- a/apps/api/ww_api/routers/toolbox.py +++ b/apps/api/ww_api/routers/toolbox.py @@ -20,6 +20,7 @@ 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 @@ -47,6 +48,7 @@ from ww_api.schemas.toolbox import ( 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, @@ -74,6 +76,7 @@ _INGEST_ERRORS: dict[int | str, dict[str, Any]] = { 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)] GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)] @@ -138,6 +141,23 @@ async def _chapter_beats( 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, @@ -147,6 +167,7 @@ async def generate_with_tool( project_repo: ProjectRepoDep, memory: MemoryReposDep, outline_repo: OutlineReadRepoDep, + chapter_repo: ChapterRepoDep, build_gateway: GatewayBuilderDep, session: SessionDep, ) -> ToolGeneratePreviewResponse: @@ -170,11 +191,17 @@ async def generate_with_tool( world_context = "" beats: list[str] = [] + prior_text = "" 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) context = build_toolbox_context( tool.context_strategy, @@ -183,6 +210,8 @@ async def generate_with_tool( world_context=world_context, chapter_no=body.chapter_no, beats=beats, + prior_text=prior_text, + text=body.text or "", ) gateway = await build_gateway(spec.tier) diff --git a/apps/api/ww_api/schemas/toolbox.py b/apps/api/ww_api/schemas/toolbox.py index c087d4a..6401ed5 100644 --- a/apps/api/ww_api/schemas/toolbox.py +++ b/apps/api/ww_api/schemas/toolbox.py @@ -64,8 +64,14 @@ class ToolGenerateRequest(BaseModel): """通用生成请求(覆盖全部工具的可选入参;按工具 input_fields 取用,snake_case)。""" brief: str = Field(default="", description="一句话需求/方向(可空,空则按题材发散)") + text: str | None = Field( + default=None, + description="原文输入(扩写/降AI 的原文、拆书的样本;text_input 策略工具用)", + ) chapter_no: int | None = Field( - default=None, ge=MIN_CHAPTER_NO, description="按章展开类工具的章号(开篇/细纲)" + default=None, + ge=MIN_CHAPTER_NO, + description="按章展开/续写类工具的章号(开篇/细纲/续写)", ) count: int | None = Field(default=None, ge=1, le=12, description="生成数量(部分工具可用)") kind: str | None = Field(default=None, description="命名对象类别等(部分工具可用)") diff --git a/apps/api/ww_api/services/toolbox_context.py b/apps/api/ww_api/services/toolbox_context.py index 9a6f6de..efc929f 100644 --- a/apps/api/ww_api/services/toolbox_context.py +++ b/apps/api/ww_api/services/toolbox_context.py @@ -11,14 +11,21 @@ IO(读 projects/world_entities/outline)发生在端点;本模块只负责 - `brief_only` :作品设定(仅标题/题材级)+ 一句话需求; - `with_project` :作品设定(含前提/主题)+ 一句话需求; - `with_world` :作品设定 + world_entities 硬规则卡 + 一句话需求; -- `with_outline_chapter`:作品设定 + 指定章大纲节拍(缺章/缺大纲 → 空节拍,不报错)。 +- `with_outline_chapter`:作品设定 + 指定章大纲节拍(缺章/缺大纲 → 空节拍,不报错); +- `with_prior_chapter` :作品设定 + 最新已写正文(端点先读 accepted/draft)+ 可选节拍; +- `text_input` :作品设定 + 作者提供的原文(扩写/降AI/拆书样本)+ 一句话需求。 """ from __future__ import annotations from collections.abc import Sequence -from ww_core.orchestrator import build_brief_context, build_outline_chapter_context +from ww_core.orchestrator import ( + build_brief_context, + build_continuation_context, + build_outline_chapter_context, + build_text_input_context, +) from ww_skills import ContextStrategy @@ -30,16 +37,30 @@ def build_toolbox_context( world_context: str = "", chapter_no: int | None = None, beats: Sequence[str] = (), + prior_text: str = "", + text: str = "", ) -> str: """据注入策略组装喂网关的文本(PURE,确定性)。 `project_context` 由调用方按策略序列化(brief_only 给精简设定、with_project 给完整设定); `world_context` 仅 with_world 用(已序列化的硬规则卡);`chapter_no`/`beats` 仅 - with_outline_chapter 用(缺章/缺大纲时 beats 为空,降级到空节拍而非报错)。 + with_outline_chapter 用(缺章/缺大纲时 beats 为空,降级到空节拍而非报错); + `prior_text` 仅 with_prior_chapter 用(端点先读最新 accepted/draft 正文注入); + `text` 仅 text_input 用(作者提供的原文样本)。 """ if strategy in ("brief_only", "with_project"): return build_brief_context(brief=brief, project_context=project_context) + if strategy == "with_prior_chapter": + return build_continuation_context( + project_context=project_context, + prior_text=prior_text, + beats=list(beats) if beats else None, + ) + + if strategy == "text_input": + return build_text_input_context(project_context=project_context, text=text, brief=brief) + if strategy == "with_world": base = build_brief_context(brief=brief, project_context=project_context) world_block = world_context.strip() or "(暂无世界观设定)" diff --git a/packages/skills/tests/test_toolbox.py b/packages/skills/tests/test_toolbox.py index 46d1533..6d48bd8 100644 --- a/packages/skills/tests/test_toolbox.py +++ b/packages/skills/tests/test_toolbox.py @@ -1,7 +1,7 @@ """T6 创作工具箱声明式描述符类型单测(Wave-0 契约)。 仅校验描述符**类型**本身(无 TOOLBOX 注册表 seeding——那是 Wave A): -- `ContextStrategy`:4 种注入策略字面量; +- `ContextStrategy`:注入策略字面量(4 基础 + 续写/原文输入 2 竞品策略); - `InputField`:声明式表单字段(label/type/required/default/help); - `IngestSpec`:可选入库目标(table 必须在 KNOWN_TABLES,None=纯预览); - `GeneratorTool`:生成器声明(legacy 带 legacy_route 且 spec 可为 None;新工具带 spec+schema)。 @@ -45,18 +45,20 @@ def _spec() -> AgentSpec: # ---- ContextStrategy ---- -def test_context_strategy_has_four_values() -> None: +def test_context_strategy_values() -> None: # Arrange / Act from typing import get_args values = set(get_args(ContextStrategy)) - # Assert + # Assert:4 基础策略 + 竞品快赢 2 策略(续写读前文 / 原文输入)。 assert values == { "brief_only", "with_project", "with_world", "with_outline_chapter", + "with_prior_chapter", + "text_input", } diff --git a/packages/skills/tests/test_toolbox_registry.py b/packages/skills/tests/test_toolbox_registry.py index 8d1531c..d1a0df2 100644 --- a/packages/skills/tests/test_toolbox_registry.py +++ b/packages/skills/tests/test_toolbox_registry.py @@ -1,7 +1,8 @@ """T6.2 创作工具箱注册表(`TOOLBOX`)单测。 -校验:11 条齐全(legacy 3 + 新 8);legacy 携 legacy_route 且 spec=None;新工具携 spec + -output_schema(与 spec.output_schema 一致);ingest 工具的 table 在白名单内;`get_tool` 解析。 +校验:15 条齐全(legacy 3 + 新 8 + 竞品快赢 4);legacy 携 legacy_route 且 spec=None; +新工具携 spec + output_schema(与 spec.output_schema 一致);ingest 工具的 table 在白名单内; +`get_tool` 解析。 """ from __future__ import annotations @@ -18,17 +19,22 @@ _NEW_KEYS = { "glossary", "opening", "fine-outline", + # 竞品快赢(Scope B):续写 / 扩写 / 降AI率 / 拆书(全 preview-only)。 + "continue", + "expand", + "de-ai", + "teardown", } _INGEST_KEYS = {"golden-finger", "glossary", "fine-outline"} -def test_toolbox_has_all_eleven_tools() -> None: +def test_toolbox_has_all_tools() -> None: # Arrange / Act keys = set(TOOLBOX.keys()) - # Assert + # Assert:legacy 3 + 新 8 + 竞品快赢 4 = 15。 assert keys == _LEGACY_KEYS | _NEW_KEYS - assert len(TOOLBOX) == 11 + assert len(TOOLBOX) == 15 def test_legacy_tools_have_route_and_no_spec() -> None: diff --git a/packages/skills/ww_skills/toolbox.py b/packages/skills/ww_skills/toolbox.py index 2d18f76..a2a3beb 100644 --- a/packages/skills/ww_skills/toolbox.py +++ b/packages/skills/ww_skills/toolbox.py @@ -24,16 +24,20 @@ from ww_shared import AppError, ErrorCode from ww_skills.skill_permissions import KNOWN_TABLES -# 注入策略:决定为该生成器组哪些材料喂给网关(覆盖全部生成器,~4 种)。 +# 注入策略:决定为该生成器组哪些材料喂给网关(覆盖全部生成器)。 # - brief_only:项目立意 + 用户一句话(脑洞/书名); # - with_project:+ 主线/卖点(简介); # - with_world:+ world_entities 卡(名字/金手指/词条——须契合世界观硬规则); -# - with_outline_chapter:+ 指定章 outline beats(细纲/黄金开篇)。 +# - with_outline_chapter:+ 指定章 outline beats(细纲/黄金开篇); +# - with_prior_chapter:+ 最新已写正文(续写——端点先读最新 accepted/draft 正文注入); +# - text_input:+ 作者提供的原文(扩写/降AI率/拆书样本——经请求 text 字段注入)。 ContextStrategy = Literal[ "brief_only", "with_project", "with_world", "with_outline_chapter", + "with_prior_chapter", + "text_input", ] diff --git a/packages/skills/ww_skills/toolbox_registry.py b/packages/skills/ww_skills/toolbox_registry.py index 01a8660..675e549 100644 --- a/packages/skills/ww_skills/toolbox_registry.py +++ b/packages/skills/ww_skills/toolbox_registry.py @@ -20,20 +20,28 @@ from ww_agents import ( blurb_spec, book_title_spec, brainstorm_spec, + continue_spec, + de_ai_spec, + expand_spec, fine_outline_spec, glossary_spec, golden_finger_spec, name_spec, opening_spec, + teardown_spec, ) from ww_agents.schemas import ( BlurbResult, + BookTeardownResult, + ContinuationResult, + DeAiResult, DetailedOutlineResult, GlossaryResult, GoldenFingerResult, IdeaListResult, NameListResult, OpeningResult, + PolishResult, TitleListResult, ) @@ -61,6 +69,16 @@ def _chapter_no_field() -> InputField: ) +# 原文输入(扩写/降AI率/拆书样本所需的大段正文)。 +_SOURCE_TEXT_FIELD = InputField( + name="text", + label="原文", + type="textarea", + required=True, + help="待处理的正文/样本片段(扩写/降AI 的原文;拆书的章节样本)", +) + + # 创作工具箱注册表:key → 描述符。legacy 3 + 新 8 = 11 条。 TOOLBOX: dict[str, GeneratorTool] = { # ---- legacy(spec=None,前端走 legacy_route 跳现有页面)---- @@ -179,6 +197,62 @@ TOOLBOX: dict[str, GeneratorTool] = { input_fields=[_chapter_no_field(), _BRIEF_FIELD], ingest=IngestSpec(table="outline"), ), + # ---- 竞品快赢(Scope B):续写 / 扩写 / 降AI率 / 拆书(全 preview-only,writes=[])---- + "continue": GeneratorTool( + key="continue", + title="续写生成器", + subtitle="承接前文,无缝续写下文", + spec=continue_spec, + output_schema=ContinuationResult, + context_strategy="with_prior_chapter", + input_fields=[ + InputField( + name="chapter_no", + label="续写章号", + type="number", + required=False, + help="续写的目标章号(读取该章已写正文承接;缺则按设定起笔)", + ), + _BRIEF_FIELD, + ], + ), + "expand": GeneratorTool( + key="expand", + title="扩写生成器", + subtitle="在原文基础上丰富细节铺陈", + spec=expand_spec, + output_schema=PolishResult, + context_strategy="text_input", + input_fields=[_SOURCE_TEXT_FIELD, _BRIEF_FIELD], + ), + "de-ai": GeneratorTool( + key="de-ai", + title="降 AI 率生成器", + subtitle="去机翻腔,更自然的人写质感", + spec=de_ai_spec, + output_schema=DeAiResult, + context_strategy="text_input", + input_fields=[_SOURCE_TEXT_FIELD, _BRIEF_FIELD], + ), + "teardown": GeneratorTool( + key="teardown", + title="拆书生成器", + subtitle="拆解主题/原型/结构/钩子套路", + spec=teardown_spec, + output_schema=BookTeardownResult, + context_strategy="text_input", + input_fields=[ + InputField( + name="kind", + label="书名", + type="text", + required=False, + help="待拆解作品的书名(供拆解对照)", + ), + _SOURCE_TEXT_FIELD, + _BRIEF_FIELD, + ], + ), }