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:
@@ -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]
|
||||
|
||||
@@ -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)----
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user