feat(skills): Scope B A2 — 4 竞品生成器注册表+上下文分派+端点接线

注册表 TOOLBOX +4 entry(continue/expand/de-ai/teardown,全 preview-only writes=[]);
ContextStrategy +with_prior_chapter/text_input;ToolGenerateRequest +text 字段。
toolbox_context 分派新策略(续写→build_continuation_context、原文→build_text_input_context);
路由续写经 chapter_repo 读最新 accepted/draft 正文注入 builder(core 不 import apps/api,仿 accept_op)。
预览仅 commit ledger 不写业务表(#3);只声明 tier(#2);system_prompt 进缓存前缀(#9)。
单测:4 生成器 generate 预览 + 续写读前文/草稿路由 + 扩写/降AI text 路由 + 拆书结构化;
更新 registry/strategy 计数测试(15 工具 / 6 策略)。门禁绿:ruff/format/mypy 196 files/pytest 168(api)+32(skills)。
This commit is contained in:
Yaojia Wang
2026-06-23 19:10:35 +02:00
parent 87dd09a797
commit a5351320a2
9 changed files with 417 additions and 15 deletions

View File

@@ -79,6 +79,44 @@ def test_with_outline_chapter_missing_beats_does_not_error() -> None:
assert "暂无大纲节拍" in text 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: def test_unknown_strategy_raises() -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
build_toolbox_context("nope", brief="x", project_context="y") # type: ignore[arg-type] build_toolbox_context("nope", brief="x", project_context="y") # type: ignore[arg-type]

View File

@@ -19,9 +19,18 @@ from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo, FakeSession from fakes_projects import FakeProjectRepo, FakeSession
from test_projects import _empty_memory_repos from test_projects import _empty_memory_repos
from ww_agents import Conflict, ContinuityReview 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.outline_write_repo import OutlineWriteView
from ww_core.domain.project_repo import ProjectCreate 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_core.domain.world_entity_repo import WorldEntityWriteView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode 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:
# 工具箱端点恒传序列化文本 contextstr存为 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: def _ideas() -> IdeaListResult:
return IdeaListResult(ideas=[Idea(premise="废柴觉醒系统", hook="开局即巅峰", genre_fit="玄幻")]) return IdeaListResult(ideas=[Idea(premise="废柴觉醒系统", hook="开局即巅峰", genre_fit="玄幻")])
@@ -106,13 +197,17 @@ def _make_app(
session: FakeSession | None = None, session: FakeSession | None = None,
world_repo: _FakeWorldWriteRepo | None = None, world_repo: _FakeWorldWriteRepo | None = None,
outline_write_repo: _FakeOutlineWriteRepo | None = None, outline_write_repo: _FakeOutlineWriteRepo | None = None,
chapter_repo: _FakeChapterRepo | None = None,
outline_read_repo: _FakeOutlineReadRepo | None = None,
memory: Any = None, memory: Any = None,
no_creds: bool = False, no_creds: bool = False,
) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]: ) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode()) os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app from ww_api.main import create_app
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
get_chapter_repo,
get_memory_repos, get_memory_repos,
get_outline_read_repo,
get_outline_write_repo, get_outline_write_repo,
get_project_repo, get_project_repo,
get_tier_gateway_builder, get_tier_gateway_builder,
@@ -123,6 +218,8 @@ def _make_app(
session = session or FakeSession() session = session or FakeSession()
world_repo = world_repo or _FakeWorldWriteRepo() world_repo = world_repo or _FakeWorldWriteRepo()
outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo() 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: async def _build_ok(_tier: str) -> Any:
return gateway return gateway
@@ -137,6 +234,8 @@ def _make_app(
) )
app.dependency_overrides[get_world_entity_write_repo] = lambda: world_repo 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_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_session] = lambda: session
app.dependency_overrides[get_tier_gateway_builder] = lambda: ( app.dependency_overrides[get_tier_gateway_builder] = lambda: (
_build_no_creds if no_creds else _build_ok _build_no_creds if no_creds else _build_ok
@@ -180,8 +279,16 @@ async def test_list_toolbox_contains_all_keys() -> None:
"glossary", "glossary",
"opening", "opening",
"fine-outline", "fine-outline",
"continue",
"expand",
"de-ai",
"teardown",
} }
by_key = {t["key"]: t for t in tools} 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 标记。 # legacy 携 legacy_route + is_legacy新工具 ingestable 标记。
assert by_key["worldbuilding"]["is_legacy"] is True assert by_key["worldbuilding"]["is_legacy"] is True
assert by_key["worldbuilding"]["legacy_route"] assert by_key["worldbuilding"]["legacy_route"]
@@ -272,6 +379,121 @@ async def test_generate_no_credentials_503() -> None:
assert session.commits == 0 assert session.commits == 0
# ---- POST .../generateScope B 竞品快赢:续写/扩写/降AI/拆书)----
@pytest.mark.asyncio
async def test_generate_continue_reads_prior_chapter() -> None:
# 续写:端点读该章最新 accepted 正文 → 注入 contextwith_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 字段 → 注入 contexttext_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 .../ingestworld_entities gate---- # ---- POST .../ingestworld_entities gate----

View File

@@ -20,6 +20,7 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ww_agents import AgentSpec, Conflict, ContinuityReview, continuity_spec 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.outline_write_repo import OutlineWriteRepo
from ww_core.domain.project_repo import ProjectRepo from ww_core.domain.project_repo import ProjectRepo
from ww_core.domain.repositories import MemoryRepos, OutlineRepo 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.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import ( from ww_api.services.project_deps import (
TierGatewayBuilder, TierGatewayBuilder,
get_chapter_repo,
get_memory_repos, get_memory_repos,
get_outline_read_repo, get_outline_read_repo,
get_outline_write_repo, get_outline_write_repo,
@@ -74,6 +76,7 @@ _INGEST_ERRORS: dict[int | str, dict[str, Any]] = {
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)] OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)]
ChapterRepoDep = Annotated[ChapterRepo, Depends(get_chapter_repo)]
WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)] WorldWriteRepoDep = Annotated[WorldEntityWriteRepo, Depends(get_world_entity_write_repo)]
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)] OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)] 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 [] 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 的注入模式),守不变量 #1DB 为真相源)。
"""
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) @router.post("/projects/{project_id}/skills/{tool_key}/generate", responses=_TOOL_ERRORS)
async def generate_with_tool( async def generate_with_tool(
project_id: uuid.UUID, project_id: uuid.UUID,
@@ -147,6 +167,7 @@ async def generate_with_tool(
project_repo: ProjectRepoDep, project_repo: ProjectRepoDep,
memory: MemoryReposDep, memory: MemoryReposDep,
outline_repo: OutlineReadRepoDep, outline_repo: OutlineReadRepoDep,
chapter_repo: ChapterRepoDep,
build_gateway: GatewayBuilderDep, build_gateway: GatewayBuilderDep,
session: SessionDep, session: SessionDep,
) -> ToolGeneratePreviewResponse: ) -> ToolGeneratePreviewResponse:
@@ -170,11 +191,17 @@ async def generate_with_tool(
world_context = "" world_context = ""
beats: list[str] = [] beats: list[str] = []
prior_text = ""
if tool.context_strategy == "with_world": if tool.context_strategy == "with_world":
world_context = await _world_context(memory, project_id) world_context = await _world_context(memory, project_id)
elif tool.context_strategy == "with_outline_chapter": elif tool.context_strategy == "with_outline_chapter":
chapter_no = body.chapter_no or 1 chapter_no = body.chapter_no or 1
beats = await _chapter_beats(outline_repo, project_id, chapter_no) 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( context = build_toolbox_context(
tool.context_strategy, tool.context_strategy,
@@ -183,6 +210,8 @@ async def generate_with_tool(
world_context=world_context, world_context=world_context,
chapter_no=body.chapter_no, chapter_no=body.chapter_no,
beats=beats, beats=beats,
prior_text=prior_text,
text=body.text or "",
) )
gateway = await build_gateway(spec.tier) gateway = await build_gateway(spec.tier)

View File

@@ -64,8 +64,14 @@ class ToolGenerateRequest(BaseModel):
"""通用生成请求(覆盖全部工具的可选入参;按工具 input_fields 取用snake_case""" """通用生成请求(覆盖全部工具的可选入参;按工具 input_fields 取用snake_case"""
brief: str = Field(default="", description="一句话需求/方向(可空,空则按题材发散)") brief: str = Field(default="", description="一句话需求/方向(可空,空则按题材发散)")
text: str | None = Field(
default=None,
description="原文输入(扩写/降AI 的原文、拆书的样本text_input 策略工具用)",
)
chapter_no: int | None = Field( 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="生成数量(部分工具可用)") count: int | None = Field(default=None, ge=1, le=12, description="生成数量(部分工具可用)")
kind: str | None = Field(default=None, description="命名对象类别等(部分工具可用)") kind: str | None = Field(default=None, description="命名对象类别等(部分工具可用)")

View File

@@ -11,14 +11,21 @@ IO读 projects/world_entities/outline发生在端点本模块只负责
- `brief_only` :作品设定(仅标题/题材级)+ 一句话需求; - `brief_only` :作品设定(仅标题/题材级)+ 一句话需求;
- `with_project` :作品设定(含前提/主题)+ 一句话需求; - `with_project` :作品设定(含前提/主题)+ 一句话需求;
- `with_world` :作品设定 + world_entities 硬规则卡 + 一句话需求; - `with_world` :作品设定 + world_entities 硬规则卡 + 一句话需求;
- `with_outline_chapter`:作品设定 + 指定章大纲节拍(缺章/缺大纲 → 空节拍,不报错) - `with_outline_chapter`:作品设定 + 指定章大纲节拍(缺章/缺大纲 → 空节拍,不报错)
- `with_prior_chapter` :作品设定 + 最新已写正文(端点先读 accepted/draft+ 可选节拍;
- `text_input` :作品设定 + 作者提供的原文(扩写/降AI/拆书样本)+ 一句话需求。
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence 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 from ww_skills import ContextStrategy
@@ -30,16 +37,30 @@ def build_toolbox_context(
world_context: str = "", world_context: str = "",
chapter_no: int | None = None, chapter_no: int | None = None,
beats: Sequence[str] = (), beats: Sequence[str] = (),
prior_text: str = "",
text: str = "",
) -> str: ) -> str:
"""据注入策略组装喂网关的文本PURE确定性 """据注入策略组装喂网关的文本PURE确定性
`project_context` 由调用方按策略序列化brief_only 给精简设定、with_project 给完整设定); `project_context` 由调用方按策略序列化brief_only 给精简设定、with_project 给完整设定);
`world_context` 仅 with_world 用(已序列化的硬规则卡);`chapter_no`/`beats` 仅 `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"): if strategy in ("brief_only", "with_project"):
return build_brief_context(brief=brief, project_context=project_context) 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": if strategy == "with_world":
base = build_brief_context(brief=brief, project_context=project_context) base = build_brief_context(brief=brief, project_context=project_context)
world_block = world_context.strip() or "(暂无世界观设定)" world_block = world_context.strip() or "(暂无世界观设定)"

View File

@@ -1,7 +1,7 @@
"""T6 创作工具箱声明式描述符类型单测Wave-0 契约)。 """T6 创作工具箱声明式描述符类型单测Wave-0 契约)。
仅校验描述符**类型**本身(无 TOOLBOX 注册表 seeding——那是 Wave A 仅校验描述符**类型**本身(无 TOOLBOX 注册表 seeding——那是 Wave A
- `ContextStrategy`4 种注入策略字面量; - `ContextStrategy`:注入策略字面量4 基础 + 续写/原文输入 2 竞品策略)
- `InputField`声明式表单字段label/type/required/default/help - `InputField`声明式表单字段label/type/required/default/help
- `IngestSpec`可选入库目标table 必须在 KNOWN_TABLESNone=纯预览); - `IngestSpec`可选入库目标table 必须在 KNOWN_TABLESNone=纯预览);
- `GeneratorTool`生成器声明legacy 带 legacy_route 且 spec 可为 None新工具带 spec+schema - `GeneratorTool`生成器声明legacy 带 legacy_route 且 spec 可为 None新工具带 spec+schema
@@ -45,18 +45,20 @@ def _spec() -> AgentSpec:
# ---- ContextStrategy ---- # ---- ContextStrategy ----
def test_context_strategy_has_four_values() -> None: def test_context_strategy_values() -> None:
# Arrange / Act # Arrange / Act
from typing import get_args from typing import get_args
values = set(get_args(ContextStrategy)) values = set(get_args(ContextStrategy))
# Assert # Assert4 基础策略 + 竞品快赢 2 策略(续写读前文 / 原文输入)。
assert values == { assert values == {
"brief_only", "brief_only",
"with_project", "with_project",
"with_world", "with_world",
"with_outline_chapter", "with_outline_chapter",
"with_prior_chapter",
"text_input",
} }

View File

@@ -1,7 +1,8 @@
"""T6.2 创作工具箱注册表(`TOOLBOX`)单测。 """T6.2 创作工具箱注册表(`TOOLBOX`)单测。
校验11 条齐全legacy 3 + 新 8legacy 携 legacy_route 且 spec=None新工具携 spec + 校验15 条齐全legacy 3 + 新 8 + 竞品快赢 4legacy 携 legacy_route 且 spec=None
output_schema与 spec.output_schema 一致ingest 工具的 table 在白名单内;`get_tool` 解析。 新工具携 spec + output_schema与 spec.output_schema 一致ingest 工具的 table 在白名单内;
`get_tool` 解析。
""" """
from __future__ import annotations from __future__ import annotations
@@ -18,17 +19,22 @@ _NEW_KEYS = {
"glossary", "glossary",
"opening", "opening",
"fine-outline", "fine-outline",
# 竞品快赢Scope B续写 / 扩写 / 降AI率 / 拆书(全 preview-only
"continue",
"expand",
"de-ai",
"teardown",
} }
_INGEST_KEYS = {"golden-finger", "glossary", "fine-outline"} _INGEST_KEYS = {"golden-finger", "glossary", "fine-outline"}
def test_toolbox_has_all_eleven_tools() -> None: def test_toolbox_has_all_tools() -> None:
# Arrange / Act # Arrange / Act
keys = set(TOOLBOX.keys()) keys = set(TOOLBOX.keys())
# Assert # Assertlegacy 3 + 新 8 + 竞品快赢 4 = 15。
assert keys == _LEGACY_KEYS | _NEW_KEYS assert keys == _LEGACY_KEYS | _NEW_KEYS
assert len(TOOLBOX) == 11 assert len(TOOLBOX) == 15
def test_legacy_tools_have_route_and_no_spec() -> None: def test_legacy_tools_have_route_and_no_spec() -> None:

View File

@@ -24,16 +24,20 @@ from ww_shared import AppError, ErrorCode
from ww_skills.skill_permissions import KNOWN_TABLES from ww_skills.skill_permissions import KNOWN_TABLES
# 注入策略:决定为该生成器组哪些材料喂给网关(覆盖全部生成器~4 种)。 # 注入策略:决定为该生成器组哪些材料喂给网关(覆盖全部生成器)。
# - brief_only项目立意 + 用户一句话(脑洞/书名); # - brief_only项目立意 + 用户一句话(脑洞/书名);
# - with_project+ 主线/卖点(简介); # - with_project+ 主线/卖点(简介);
# - with_world+ world_entities 卡(名字/金手指/词条——须契合世界观硬规则); # - 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[ ContextStrategy = Literal[
"brief_only", "brief_only",
"with_project", "with_project",
"with_world", "with_world",
"with_outline_chapter", "with_outline_chapter",
"with_prior_chapter",
"text_input",
] ]

View File

@@ -20,20 +20,28 @@ from ww_agents import (
blurb_spec, blurb_spec,
book_title_spec, book_title_spec,
brainstorm_spec, brainstorm_spec,
continue_spec,
de_ai_spec,
expand_spec,
fine_outline_spec, fine_outline_spec,
glossary_spec, glossary_spec,
golden_finger_spec, golden_finger_spec,
name_spec, name_spec,
opening_spec, opening_spec,
teardown_spec,
) )
from ww_agents.schemas import ( from ww_agents.schemas import (
BlurbResult, BlurbResult,
BookTeardownResult,
ContinuationResult,
DeAiResult,
DetailedOutlineResult, DetailedOutlineResult,
GlossaryResult, GlossaryResult,
GoldenFingerResult, GoldenFingerResult,
IdeaListResult, IdeaListResult,
NameListResult, NameListResult,
OpeningResult, OpeningResult,
PolishResult,
TitleListResult, 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 条。 # 创作工具箱注册表key → 描述符。legacy 3 + 新 8 = 11 条。
TOOLBOX: dict[str, GeneratorTool] = { TOOLBOX: dict[str, GeneratorTool] = {
# ---- legacyspec=None前端走 legacy_route 跳现有页面)---- # ---- legacyspec=None前端走 legacy_route 跳现有页面)----
@@ -179,6 +197,62 @@ TOOLBOX: dict[str, GeneratorTool] = {
input_fields=[_chapter_no_field(), _BRIEF_FIELD], input_fields=[_chapter_no_field(), _BRIEF_FIELD],
ingest=IngestSpec(table="outline"), ingest=IngestSpec(table="outline"),
), ),
# ---- 竞品快赢Scope B续写 / 扩写 / 降AI率 / 拆书(全 preview-onlywrites=[]----
"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,
],
),
} }