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
|
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]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
# 工具箱端点恒传序列化文本 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:
|
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 .../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)----
|
# ---- POST .../ingest(world_entities gate)----
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 的注入模式),守不变量 #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)
|
@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)
|
||||||
|
|||||||
@@ -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="命名对象类别等(部分工具可用)")
|
||||||
|
|||||||
@@ -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 "(暂无世界观设定)"
|
||||||
|
|||||||
@@ -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_TABLES,None=纯预览);
|
- `IngestSpec`:可选入库目标(table 必须在 KNOWN_TABLES,None=纯预览);
|
||||||
- `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
|
# Assert:4 基础策略 + 竞品快赢 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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""T6.2 创作工具箱注册表(`TOOLBOX`)单测。
|
"""T6.2 创作工具箱注册表(`TOOLBOX`)单测。
|
||||||
|
|
||||||
校验:11 条齐全(legacy 3 + 新 8);legacy 携 legacy_route 且 spec=None;新工具携 spec +
|
校验:15 条齐全(legacy 3 + 新 8 + 竞品快赢 4);legacy 携 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
|
# Assert:legacy 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:
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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] = {
|
||||||
# ---- legacy(spec=None,前端走 legacy_route 跳现有页面)----
|
# ---- legacy(spec=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-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,
|
||||||
|
],
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user