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

@@ -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:
# 工具箱端点恒传序列化文本 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:
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 .../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----