merge: Scope B — 多章链前端 UI + 4 竞品生成器(续写/扩写/降AI率/拆书)
多 agent 逐学科建造 + 4 路对抗交叉评审 + 修 4 HIGH。 后端 mypy 196 / alembic 无漂移 / pytest 642;前端 lint/tsc/vitest 294/build 绿。
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,146 @@ 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"] == "黄金三章立钩"
|
||||
# 书名(body.kind)前置入原文 → 进了喂网关的 context(拆书 prompt 期待书名)。
|
||||
assert gateway.last_input is not None
|
||||
assert "书名:某爆款" in gateway.last_input
|
||||
assert "样本章节正文" in gateway.last_input
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_text_input_empty_text_422() -> None:
|
||||
# text_input 工具(扩写/降AI/拆书)原文为空/空白 → 422,不触达网关(不让 LLM 空跑)。
|
||||
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 == 422
|
||||
assert resp.json()["error"]["code"] == ErrorCode.VALIDATION
|
||||
# 校验在生成前拦下:网关未被调用、未 commit。
|
||||
assert gateway.last_input is None
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
# ---- POST .../ingest(world_entities gate)----
|
||||
|
||||
|
||||
|
||||
@@ -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,30 @@ async def generate_with_tool(
|
||||
|
||||
world_context = ""
|
||||
beats: list[str] = []
|
||||
prior_text = ""
|
||||
text = body.text or ""
|
||||
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)
|
||||
elif tool.context_strategy == "text_input":
|
||||
# text_input 工具(扩写/降AI/拆书)必须有原文样本——空原文会让 LLM 空跑(守输入校验纪律)。
|
||||
if not text.strip():
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"工具 {tool_key} 需要原文输入(text 不可为空)",
|
||||
{"tool_key": tool_key},
|
||||
)
|
||||
# 拆书的书名(body.kind)前置入原文,供拆解对照(teardown system prompt 期待书名)。
|
||||
kind = (body.kind or "").strip()
|
||||
if kind:
|
||||
text = f"书名:{kind}\n\n{text}"
|
||||
|
||||
context = build_toolbox_context(
|
||||
tool.context_strategy,
|
||||
@@ -183,6 +223,8 @@ async def generate_with_tool(
|
||||
world_context=world_context,
|
||||
chapter_no=body.chapter_no,
|
||||
beats=beats,
|
||||
prior_text=prior_text,
|
||||
text=text,
|
||||
)
|
||||
|
||||
gateway = await build_gateway(spec.tier)
|
||||
|
||||
@@ -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="命名对象类别等(部分工具可用)")
|
||||
|
||||
@@ -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 "(暂无世界观设定)"
|
||||
|
||||
20
apps/web/app/projects/[id]/chains/page.tsx
Normal file
20
apps/web/app/projects/[id]/chains/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ChainPage } from "@/components/chain/ChainPage";
|
||||
import { fetchProject } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 多章工作流链页(Scope B B1)。Server Component 取项目;ChainPage(Client)承载
|
||||
// 发起 → job 轮询进度 → interrupt 裁决 → resume 续跑交互。
|
||||
export default async function ChainsRoutePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
return <ChainPage project={project} />;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
176
apps/web/components/chain/ChainAdjudication.tsx
Normal file
176
apps/web/components/chain/ChainAdjudication.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { ConflictCard } from "@/components/review/ConflictCard";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { buildResumeRequest } from "@/lib/chain/chain";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import type { ReviewConflict } from "@/lib/review/sse";
|
||||
import {
|
||||
allResolved,
|
||||
emptyDecisions,
|
||||
setNote,
|
||||
setVerdict,
|
||||
unresolvedIndices,
|
||||
type DecisionDraft,
|
||||
type Verdict,
|
||||
} from "@/lib/review/decisions";
|
||||
import { latestReview, normalizeConflicts } from "@/lib/review/history";
|
||||
|
||||
interface ChainAdjudicationProps {
|
||||
projectId: string;
|
||||
jobId: string;
|
||||
// interrupt 命中的章号(读其最近审稿冲突供裁决)。
|
||||
chapterNo: number;
|
||||
// 续跑发起后回调(父层重启轮询)。
|
||||
onResumed: () => void;
|
||||
}
|
||||
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
|
||||
// 链裁决面板(净新但复用满):读 awaiting 章最近审稿 → 渲 ConflictCard(每冲突一张,复用审稿页
|
||||
// 组件 + decisions.ts 纯逻辑)→ 全决后 POST resume(buildResumeRequest 复用 accept 裁决组装)。
|
||||
// 链无在手草稿正文 → 无段内定位(snippet=null/locatable=false),裁决纯靠 suggestion + 补丁对。
|
||||
export function ChainAdjudication({
|
||||
projectId,
|
||||
jobId,
|
||||
chapterNo,
|
||||
onResumed,
|
||||
}: ChainAdjudicationProps) {
|
||||
const toast = useToast();
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
const [conflicts, setConflicts] = useState<ReviewConflict[]>([]);
|
||||
const [drafts, setDrafts] = useState<DecisionDraft[]>([]);
|
||||
const [resuming, setResuming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setLoadState("loading");
|
||||
void (async () => {
|
||||
try {
|
||||
const { data, error } = await api.GET(
|
||||
"/projects/{project_id}/chapters/{chapter_no}/reviews",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: projectId, chapter_no: chapterNo },
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!active) return;
|
||||
if (error || !data) {
|
||||
setLoadState("error");
|
||||
return;
|
||||
}
|
||||
const next = normalizeConflicts(latestReview(data.reviews));
|
||||
setConflicts(next);
|
||||
setDrafts(emptyDecisions(next.length));
|
||||
setLoadState("ready");
|
||||
} catch {
|
||||
if (active) setLoadState("error");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [projectId, chapterNo]);
|
||||
|
||||
const onVerdict = useCallback((index: number, verdict: Verdict): void => {
|
||||
setDrafts((prev) => setVerdict(prev, index, verdict));
|
||||
}, []);
|
||||
|
||||
const onNote = useCallback((index: number, note: string): void => {
|
||||
setDrafts((prev) => setNote(prev, index, note));
|
||||
}, []);
|
||||
|
||||
const onResume = useCallback(async (): Promise<void> => {
|
||||
if (!allResolved(drafts)) {
|
||||
toast("仍有未裁决的冲突,请先逐条裁决。", "error");
|
||||
return;
|
||||
}
|
||||
setResuming(true);
|
||||
try {
|
||||
const { error } = await api.POST(
|
||||
"/projects/{project_id}/chains/runs/{job_id}/resume",
|
||||
{
|
||||
params: { path: { project_id: projectId, job_id: jobId } },
|
||||
body: buildResumeRequest(drafts),
|
||||
},
|
||||
);
|
||||
if (error) {
|
||||
toast(friendlyError(undefined).text, "error");
|
||||
setResuming(false);
|
||||
return;
|
||||
}
|
||||
onResumed();
|
||||
} catch {
|
||||
toast("续跑请求异常,请检查网络。", "error");
|
||||
setResuming(false);
|
||||
}
|
||||
}, [drafts, projectId, jobId, onResumed, toast]);
|
||||
|
||||
if (loadState === "loading") {
|
||||
return <p className="text-sm text-ink-soft">加载第 {chapterNo} 章冲突…</p>;
|
||||
}
|
||||
if (loadState === "error") {
|
||||
return (
|
||||
<p className="text-sm text-conflict">
|
||||
加载第 {chapterNo} 章审稿冲突失败,请刷新重试。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const pending = unresolvedIndices(drafts);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
|
||||
aria-label={`第 ${chapterNo} 章冲突裁决`}
|
||||
>
|
||||
<h2 className="font-serif text-lg text-ink">
|
||||
第 {chapterNo} 章冲突裁决({conflicts.length})
|
||||
</h2>
|
||||
{conflicts.length === 0 ? (
|
||||
<p className="text-sm text-ink-soft">
|
||||
该章无未决冲突,可直接续跑。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{conflicts.map((conflict, i) => {
|
||||
const draft = drafts[i] ?? { verdict: null, note: "" };
|
||||
return (
|
||||
<ConflictCard
|
||||
key={i}
|
||||
index={i}
|
||||
conflict={conflict}
|
||||
draft={draft}
|
||||
missing={false}
|
||||
snippet={null}
|
||||
locatable={false}
|
||||
rewriting={false}
|
||||
onVerdict={onVerdict}
|
||||
onNote={onNote}
|
||||
onJump={() => {}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onResume()}
|
||||
disabled={resuming || !allResolved(drafts)}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{resuming ? "续跑中…" : "裁决完成,续跑"}
|
||||
</button>
|
||||
{pending.length > 0 ? (
|
||||
<p className="text-xs text-conflict">
|
||||
还有 {pending.length} 条冲突未裁决。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
121
apps/web/components/chain/ChainPage.tsx
Normal file
121
apps/web/components/chain/ChainPage.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import { api } from "@/lib/api/client";
|
||||
import { chainPhase, chainResultView } from "@/lib/chain/chain";
|
||||
import { friendlyError } from "@/lib/errors/messages";
|
||||
import { useJobPoll } from "@/lib/jobs/useJobPoll";
|
||||
import type { ProjectResponse } from "@/lib/api/types";
|
||||
import { ChainAdjudication } from "./ChainAdjudication";
|
||||
import { ChainProgress } from "./ChainProgress";
|
||||
import { ChainStarter } from "./ChainStarter";
|
||||
|
||||
interface ChainPageProps {
|
||||
project: ProjectResponse;
|
||||
}
|
||||
|
||||
const CHAIN_KEY = "draft_volume";
|
||||
|
||||
// 多章工作流链页(Scope B B1):发起链(POST run)→job 轮询进度→interrupt 命中则读该章冲突裁决→
|
||||
// POST resume 续跑。复用 useJobPoll(轮询)+ ConflictCard/decisions(裁决)+ friendlyError(文案)。
|
||||
// 链是后台任务,走 job 轮询而非 SSE。awaiting 时停轮询(后端把 job 置 awaiting_input,
|
||||
// narrowJob 抹平成 queued 会无限轮询)→ 裁决面板 resume 后再重启轮询同一 job。
|
||||
export function ChainPage({ project }: ChainPageProps) {
|
||||
const poll = useJobPoll();
|
||||
const toast = useToast();
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const job = poll.job;
|
||||
const result = useMemo(
|
||||
() => (job ? chainResultView(job) : null),
|
||||
[job],
|
||||
);
|
||||
const awaitingChapter = result?.awaitingChapter ?? null;
|
||||
const phase = chainPhase(poll.status, awaitingChapter);
|
||||
|
||||
// awaiting 命中:停轮询(后端 job=awaiting_input 被 narrowJob 抹成 queued 会空转)。
|
||||
useEffect(() => {
|
||||
if (phase === "awaiting") poll.reset();
|
||||
}, [phase, poll]);
|
||||
|
||||
const onStart = useCallback(
|
||||
async (startChapterNo: number, count: number): Promise<void> => {
|
||||
setStarting(true);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/chains/{chain_key}/run",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: project.id, chain_key: CHAIN_KEY },
|
||||
},
|
||||
body: { start_chapter_no: startChapterNo, count },
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
toast(friendlyError(undefined).text, "error");
|
||||
return;
|
||||
}
|
||||
setJobId(data.job_id);
|
||||
poll.poll(data.job_id);
|
||||
} catch {
|
||||
toast("发起链请求异常,请检查网络。", "error");
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
},
|
||||
[project.id, poll, toast],
|
||||
);
|
||||
|
||||
// 续跑成功:重启轮询同一 job(resume 已 202,图从检查点续)。
|
||||
const onResumed = useCallback((): void => {
|
||||
if (jobId) poll.poll(jobId);
|
||||
}, [jobId, poll]);
|
||||
|
||||
const showProgress = jobId !== null && result !== null;
|
||||
// 链运行中/等待裁决时禁止重复发起。
|
||||
const busy =
|
||||
starting ||
|
||||
(jobId !== null && (phase === "running" || phase === "awaiting"));
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title={project.title}
|
||||
subtitle="多章工作流链"
|
||||
projectId={project.id}
|
||||
activeNav="chains"
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-6">
|
||||
<ChainStarter onStart={(s, c) => void onStart(s, c)} disabled={busy} />
|
||||
|
||||
{showProgress && result ? (
|
||||
<ChainProgress phase={phase} progress={poll.progress} result={result} />
|
||||
) : null}
|
||||
|
||||
{phase === "awaiting" &&
|
||||
awaitingChapter !== null &&
|
||||
jobId !== null ? (
|
||||
<ChainAdjudication
|
||||
projectId={project.id}
|
||||
jobId={jobId}
|
||||
chapterNo={awaitingChapter}
|
||||
onResumed={onResumed}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{phase === "failed" ? (
|
||||
<p className="text-sm text-conflict">
|
||||
{poll.error ?? "链运行失败,请重试。"}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{phase === "completed" ? (
|
||||
<p className="text-sm text-pass">本链已全部跑完。</p>
|
||||
) : null}
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
63
apps/web/components/chain/ChainProgress.tsx
Normal file
63
apps/web/components/chain/ChainProgress.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import type { ChainPhase, ChainResultView } from "@/lib/chain/chain";
|
||||
|
||||
interface ChainProgressProps {
|
||||
phase: ChainPhase;
|
||||
progress: number;
|
||||
result: ChainResultView;
|
||||
}
|
||||
|
||||
const PHASE_LABEL: Record<ChainPhase, string> = {
|
||||
running: "运行中",
|
||||
awaiting: "等待裁决",
|
||||
completed: "已完成",
|
||||
failed: "已失败",
|
||||
};
|
||||
|
||||
// 链进度视图:相位徽标 + 进度条 + 已写章号清单(token/正文绝不展示,result 只含元数据)。
|
||||
export function ChainProgress({ phase, progress, result }: ChainProgressProps) {
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
|
||||
aria-label="链进度"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="font-serif text-lg text-ink">链进度</h2>
|
||||
<span
|
||||
className="rounded bg-cinnabar/10 px-2 py-0.5 text-xs text-cinnabar"
|
||||
aria-live="polite"
|
||||
>
|
||||
{PHASE_LABEL[phase]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-2 w-full overflow-hidden rounded bg-bg"
|
||||
role="progressbar"
|
||||
aria-valuenow={progress}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-cinnabar transition-all"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-ink-soft">
|
||||
已完成 {result.written.length} 章
|
||||
{result.written.length > 0 ? (
|
||||
<span className="ml-1 font-mono text-ink">
|
||||
({result.written.join("、")})
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
{phase === "awaiting" && result.awaitingChapter !== null ? (
|
||||
<p className="text-sm text-conflict">
|
||||
第 {result.awaitingChapter} 章存在未决冲突,请在下方逐条裁决后续跑。
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
apps/web/components/chain/ChainStarter.tsx
Normal file
80
apps/web/components/chain/ChainStarter.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface ChainStarterProps {
|
||||
// 发起一条链(起始章 + 章数);父层负责 POST run + 轮询。
|
||||
onStart: (startChapterNo: number, count: number) => void;
|
||||
// 链正在运行/等待裁决时禁用(避免重复发起)。
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_START = 1;
|
||||
const DEFAULT_COUNT = 3;
|
||||
const MAX_COUNT = 50;
|
||||
|
||||
// 链发起表单(净新):选起始章号 + 连续写几章 → 调 onStart。
|
||||
// count 1..50(对齐后端 ChainRunRequest Field 约束;前端先拦一道,越界后端 422 兜底)。
|
||||
export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
|
||||
const [start, setStart] = useState(String(DEFAULT_START));
|
||||
const [count, setCount] = useState(String(DEFAULT_COUNT));
|
||||
|
||||
const startNo = Number.parseInt(start, 10);
|
||||
const countNo = Number.parseInt(count, 10);
|
||||
const valid =
|
||||
Number.isInteger(startNo) &&
|
||||
startNo >= 1 &&
|
||||
Number.isInteger(countNo) &&
|
||||
countNo >= 1 &&
|
||||
countNo <= MAX_COUNT;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col gap-4 rounded border border-line bg-panel p-5"
|
||||
aria-label="发起多章链"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (valid && !disabled) onStart(startNo, countNo);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 className="font-serif text-lg text-ink">连续写多章</h2>
|
||||
<p className="mt-1 text-sm text-ink-soft">
|
||||
从指定章起循环「写章 → 四审 → 验收」;遇未决冲突会暂停等你裁决再续跑。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="block text-sm text-ink-soft">
|
||||
起始章号
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={start}
|
||||
onChange={(e) => setStart(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="起始章号"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-ink-soft">
|
||||
连续章数(1..{MAX_COUNT})
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={MAX_COUNT}
|
||||
value={count}
|
||||
onChange={(e) => setCount(e.target.value)}
|
||||
className="mt-1 w-32 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
|
||||
aria-label="连续章数"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disabled || !valid}
|
||||
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{disabled ? "运行中…" : "发起多章链"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
226
apps/web/lib/api/schema.d.ts
vendored
226
apps/web/lib/api/schema.d.ts
vendored
@@ -549,6 +549,55 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chains/{chain_key}/run": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Run Chain
|
||||
* @description 发起多章链:写一行 job 返 202,链经 BackgroundTask 异步跑。
|
||||
*
|
||||
* 项目不存在 → 404;未知 chain_key → 404;无凭据 → 503(dep 解析阶段拦下);
|
||||
* `count` 越界由 schema Field 在解析阶段 → 422。`gateway` 仅做请求阶段凭据探测——
|
||||
* 链本体在 BackgroundTask 里用独立 session 重建网关跑(请求 session 即将关闭)。
|
||||
*/
|
||||
post: operations["run_chain_projects__project_id__chains__chain_key__run_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/chains/runs/{job_id}/resume": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Resume Chain
|
||||
* @description 裁决续跑:仅当 job=awaiting_input,带裁决经 BackgroundTask resume。
|
||||
*
|
||||
* 项目不存在 → 404;job 不存在 / 不属于该项目 → 404(同案,不枚举);非 awaiting 态 →
|
||||
* 409(CONFLICT)。`awaiting→running` 在 HTTP 处理器内**原子抢占**(条件 UPDATE),避免两个
|
||||
* 并发 resume 都过守卫后双 `Command(resume=...)` 损坏图(审评 #3);抢不到(已被并发抢走/非
|
||||
* awaiting)→ 409。resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id)。
|
||||
*/
|
||||
post: operations["resume_chain_projects__project_id__chains_runs__job_id__resume_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings/providers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -721,6 +770,68 @@ export interface components {
|
||||
*/
|
||||
thinking: boolean;
|
||||
};
|
||||
/**
|
||||
* ChainResumeAccepted
|
||||
* @description resume 受理回执(202):job_id + chain_key(前端走 `GET /jobs/{id}` 轮询续跑进度)。
|
||||
*
|
||||
* 续跑无独立区间(图从检查点续),故不回显 start/count(会是无意义哨兵值)——只回 job 标识。
|
||||
*/
|
||||
ChainResumeAccepted: {
|
||||
/**
|
||||
* Job Id
|
||||
* Format: uuid
|
||||
*/
|
||||
job_id: string;
|
||||
/** Chain Key */
|
||||
chain_key: string;
|
||||
};
|
||||
/**
|
||||
* ChainResumeRequest
|
||||
* @description 裁决续跑:对 awaiting 章最近审稿的每个冲突逐条裁决(复用 `ConflictDecision`)。
|
||||
*/
|
||||
ChainResumeRequest: {
|
||||
/**
|
||||
* Decisions
|
||||
* @description 对 awaiting 章冲突的裁决清单(采纳/忽略/手改)
|
||||
*/
|
||||
decisions?: components["schemas"]["ConflictDecision"][];
|
||||
};
|
||||
/**
|
||||
* ChainRunAccepted
|
||||
* @description 链受理回执(202):job_id + 回显请求参数(前端走 `GET /jobs/{id}` 轮询进度/awaiting)。
|
||||
*/
|
||||
ChainRunAccepted: {
|
||||
/**
|
||||
* Job Id
|
||||
* Format: uuid
|
||||
*/
|
||||
job_id: string;
|
||||
/** Chain Key */
|
||||
chain_key: string;
|
||||
/** Start Chapter No */
|
||||
start_chapter_no: number;
|
||||
/** Count */
|
||||
count: number;
|
||||
};
|
||||
/**
|
||||
* ChainRunRequest
|
||||
* @description 发起多章链:从 `start_chapter_no` 起循环写 `count` 章(区间含两端)。
|
||||
*
|
||||
* `count` 1..50(>50 → 422 VALIDATION,由 Field 约束在边界拦下,不进 BackgroundTask);
|
||||
* `start_chapter_no >= 1`。
|
||||
*/
|
||||
ChainRunRequest: {
|
||||
/**
|
||||
* Start Chapter No
|
||||
* @description 起始章号(含),>=1
|
||||
*/
|
||||
start_chapter_no: number;
|
||||
/**
|
||||
* Count
|
||||
* @description 本 run 连续写的章数(1..50,含起始章)
|
||||
*/
|
||||
count: number;
|
||||
};
|
||||
/**
|
||||
* CharacterCardView
|
||||
* @description 单张角色卡(贴 ww_agents.CharacterCard;预览 + 入库请求共用形)。
|
||||
@@ -1723,9 +1834,14 @@ export interface components {
|
||||
* @default
|
||||
*/
|
||||
brief: string;
|
||||
/**
|
||||
* Text
|
||||
* @description 原文输入(扩写/降AI 的原文、拆书的样本;text_input 策略工具用)
|
||||
*/
|
||||
text?: string | null;
|
||||
/**
|
||||
* Chapter No
|
||||
* @description 按章展开类工具的章号(开篇/细纲)
|
||||
* @description 按章展开/续写类工具的章号(开篇/细纲/续写)
|
||||
*/
|
||||
chapter_no?: number | null;
|
||||
/**
|
||||
@@ -3074,6 +3190,114 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
run_chain_projects__project_id__chains__chain_key__run_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
chain_key: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChainRunRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChainRunAccepted"];
|
||||
};
|
||||
};
|
||||
/** @description 项目或链 key 不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 请求参数非法(count 越界等) */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description 无可用 LLM 凭据 */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
resume_chain_projects__project_id__chains_runs__job_id__resume_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
job_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChainResumeRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ChainResumeAccepted"];
|
||||
};
|
||||
};
|
||||
/** @description 项目或 job 不存在 */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description job 非 awaiting_input 态,不可续跑 */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorEnvelope"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_providers_settings_providers_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -30,6 +30,12 @@ export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||
export type AcceptRequest = components["schemas"]["AcceptRequest"];
|
||||
export type AcceptResponse = components["schemas"]["AcceptResponse"];
|
||||
|
||||
// Scope B 多章工作流链(发起 / 续跑;进度走 GET /jobs/{id} 轮询)。
|
||||
export type ChainRunRequest = components["schemas"]["ChainRunRequest"];
|
||||
export type ChainRunAccepted = components["schemas"]["ChainRunAccepted"];
|
||||
export type ChainResumeRequest = components["schemas"]["ChainResumeRequest"];
|
||||
export type ChainResumeAccepted = components["schemas"]["ChainResumeAccepted"];
|
||||
|
||||
// M3 伏笔 + 大纲(C3 扩 T3.2/T3.5)。
|
||||
export type ForeshadowView = components["schemas"]["ForeshadowView"];
|
||||
export type ForeshadowBoardResponse =
|
||||
|
||||
112
apps/web/lib/chain/chain.test.ts
Normal file
112
apps/web/lib/chain/chain.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { emptyDecisions, setNote, setVerdict } from "@/lib/review/decisions";
|
||||
import type { JobView } from "@/lib/jobs/job";
|
||||
import {
|
||||
buildResumeRequest,
|
||||
chainPhase,
|
||||
chainResultView,
|
||||
} from "./chain";
|
||||
|
||||
function job(result: Record<string, unknown> | null): JobView {
|
||||
return {
|
||||
id: "j1",
|
||||
kind: "chain",
|
||||
status: "running",
|
||||
progress: 0,
|
||||
result,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("chainResultView", () => {
|
||||
it("narrows a completed chain result", () => {
|
||||
const view = chainResultView(
|
||||
job({
|
||||
chain_key: "draft_volume",
|
||||
written: [1, 2, 3],
|
||||
completed: true,
|
||||
awaiting_chapter: null,
|
||||
}),
|
||||
);
|
||||
expect(view).toEqual({
|
||||
chainKey: "draft_volume",
|
||||
written: [1, 2, 3],
|
||||
awaitingChapter: null,
|
||||
completed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("narrows an awaiting result (interrupt on a chapter)", () => {
|
||||
const view = chainResultView(
|
||||
job({
|
||||
chain_key: "draft_volume",
|
||||
written: [1],
|
||||
completed: false,
|
||||
awaiting_chapter: 2,
|
||||
}),
|
||||
);
|
||||
expect(view.awaitingChapter).toBe(2);
|
||||
expect(view.completed).toBe(false);
|
||||
expect(view.written).toEqual([1]);
|
||||
});
|
||||
|
||||
it("returns safe defaults for null/garbage result", () => {
|
||||
expect(chainResultView(job(null))).toEqual({
|
||||
chainKey: null,
|
||||
written: [],
|
||||
awaitingChapter: null,
|
||||
completed: false,
|
||||
});
|
||||
expect(
|
||||
chainResultView(job({ written: "nope", awaiting_chapter: "x" })).written,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps only finite integer chapter numbers in written", () => {
|
||||
const view = chainResultView(
|
||||
job({ written: [1, "2", 3.5, null, 4] }),
|
||||
);
|
||||
expect(view.written).toEqual([1, 4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chainPhase", () => {
|
||||
it("maps polling with no awaiting chapter to running", () => {
|
||||
expect(chainPhase("polling", null)).toBe("running");
|
||||
});
|
||||
|
||||
it("maps any awaiting chapter to awaiting (interrupt signal is authoritative)", () => {
|
||||
// 后端 interrupt 时置 status=awaiting_input(被 narrowJob 抹平成 queued→仍 polling),
|
||||
// 但 result.awaiting_chapter 已置数;运行中该值恒 null,故它是暂停的权威信号。
|
||||
expect(chainPhase("polling", 2)).toBe("awaiting");
|
||||
expect(chainPhase("done", 2)).toBe("awaiting");
|
||||
});
|
||||
|
||||
it("maps done + no awaiting to completed", () => {
|
||||
expect(chainPhase("done", null)).toBe("completed");
|
||||
});
|
||||
|
||||
it("maps error to failed (even if an awaiting chapter lingers)", () => {
|
||||
expect(chainPhase("error", null)).toBe("failed");
|
||||
expect(chainPhase("error", 2)).toBe("failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResumeRequest", () => {
|
||||
it("includes only resolved decisions and trims notes (reuses accept decision logic)", () => {
|
||||
let drafts = emptyDecisions(3);
|
||||
drafts = setVerdict(drafts, 0, "accept");
|
||||
drafts = setVerdict(drafts, 2, "manual");
|
||||
drafts = setNote(drafts, 2, " 改成后天淬炼 ");
|
||||
const req = buildResumeRequest(drafts);
|
||||
expect(req.decisions).toEqual([
|
||||
{ conflict_index: 0, verdict: "accept" },
|
||||
{ conflict_index: 2, verdict: "manual", note: "改成后天淬炼" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("empty drafts → empty decisions list", () => {
|
||||
expect(buildResumeRequest([])).toEqual({ decisions: [] });
|
||||
});
|
||||
});
|
||||
67
apps/web/lib/chain/chain.ts
Normal file
67
apps/web/lib/chain/chain.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// 多章工作流链前端纯逻辑(Scope B):把 `GET /jobs/{id}` 的链 job result 安全收窄成
|
||||
// 视图模型;把轮询状态 + interrupt 标志映射成链相位;裁决草稿 → resume 请求体。
|
||||
// 纯逻辑(无 React / 无 fetch),便于 node 环境单测。链走 job 轮询(非 SSE)。
|
||||
|
||||
import type { ChainResumeRequest } from "@/lib/api/types";
|
||||
import type { JobView, PollStatus } from "@/lib/jobs/job";
|
||||
import { buildAcceptRequest, type DecisionDraft } from "@/lib/review/decisions";
|
||||
|
||||
// 链 job result 的视图模型(与后端 `_chain_result` 字段对齐:chain_key/written/completed/
|
||||
// awaiting_chapter)。token/正文绝不入 result(不变量 #5 / chain §5),故此处只有进度元数据。
|
||||
export interface ChainResultView {
|
||||
chainKey: string | null;
|
||||
// 已写完(已验收/已晋升)的章号清单。
|
||||
written: number[];
|
||||
// interrupt 命中、等作者裁决的章号;null = 未暂停(跑完或运行中)。
|
||||
awaitingChapter: number | null;
|
||||
// 全链跑完(无 interrupt 残留)。
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
function asIntList(v: unknown): number[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter(
|
||||
(n): n is number => typeof n === "number" && Number.isInteger(n),
|
||||
);
|
||||
}
|
||||
|
||||
function asIntOrNull(v: unknown): number | null {
|
||||
return typeof v === "number" && Number.isInteger(v) ? v : null;
|
||||
}
|
||||
|
||||
// 把链 job 的 result 收窄成 ChainResultView(缺字段/脏数据给安全默认,守边界 #not-trust)。
|
||||
export function chainResultView(job: JobView): ChainResultView {
|
||||
const r = job.result ?? {};
|
||||
return {
|
||||
chainKey: typeof r["chain_key"] === "string" ? r["chain_key"] : null,
|
||||
written: asIntList(r["written"]),
|
||||
awaitingChapter: asIntOrNull(r["awaiting_chapter"]),
|
||||
completed: r["completed"] === true,
|
||||
};
|
||||
}
|
||||
|
||||
// 链相位(驱动页面 UI 分支):运行中 / 待裁决 / 跑完 / 失败。
|
||||
export type ChainPhase = "running" | "awaiting" | "completed" | "failed";
|
||||
|
||||
// 后端 interrupt 时把 job 置 awaiting_input(非 done/failed)+ result.awaiting_chapter=N——
|
||||
// `narrowJob` 把未知态 awaiting_input 归到 "queued",故轮询 reducer 仍 polling;运行中
|
||||
// awaiting_chapter 恒为 null,仅 interrupt 命中才置数,故以 `awaitingChapter !== null` 为
|
||||
// 暂停的权威信号(不依赖被抹平的原始状态),让页面在暂停时进入裁决相而非空轮询(不变量 #5)。
|
||||
export function chainPhase(
|
||||
pollStatus: PollStatus,
|
||||
awaitingChapter: number | null,
|
||||
): ChainPhase {
|
||||
if (pollStatus === "error") return "failed";
|
||||
if (awaitingChapter !== null) return "awaiting";
|
||||
if (pollStatus === "done") return "completed";
|
||||
return "running";
|
||||
}
|
||||
|
||||
// 裁决草稿 → resume 请求体。复用 accept 的裁决组装逻辑(DRY;resume.decisions 与
|
||||
// accept.decisions 同为 ConflictDecision[]),仅丢弃 final_text(链续跑不带终稿)。
|
||||
export function buildResumeRequest(
|
||||
drafts: readonly DecisionDraft[],
|
||||
): ChainResumeRequest {
|
||||
const { decisions } = buildAcceptRequest("", drafts);
|
||||
return { decisions };
|
||||
}
|
||||
@@ -10,15 +10,16 @@ import {
|
||||
const PID = "p1";
|
||||
|
||||
describe("projectNavItems", () => {
|
||||
it("builds the nine project entries scoped to the project id", () => {
|
||||
it("builds the ten project entries scoped to the project id", () => {
|
||||
const items = projectNavItems(PID);
|
||||
|
||||
expect(items).toHaveLength(9);
|
||||
expect(items).toHaveLength(10);
|
||||
expect(items.map((i) => i.key)).toEqual([
|
||||
"write",
|
||||
"outline",
|
||||
"foreshadow",
|
||||
"review",
|
||||
"chains",
|
||||
"style",
|
||||
"codex",
|
||||
"toolbox",
|
||||
|
||||
@@ -10,7 +10,8 @@ export type ActiveNav =
|
||||
| "codex"
|
||||
| "rules"
|
||||
| "skills"
|
||||
| "toolbox";
|
||||
| "toolbox"
|
||||
| "chains";
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
@@ -35,6 +36,7 @@ export function projectNavItems(projectId: string): NavItem[] {
|
||||
key: "foreshadow",
|
||||
},
|
||||
{ href: `/projects/${projectId}/review`, label: "审稿", key: "review" },
|
||||
{ href: `/projects/${projectId}/chains`, label: "多章链", key: "chains" },
|
||||
{ href: `/projects/${projectId}/style`, label: "文风", key: "style" },
|
||||
{ href: `/projects/${projectId}/codex`, label: "设定库", key: "codex" },
|
||||
{ href: `/projects/${projectId}/toolbox`, label: "工具箱", key: "toolbox" },
|
||||
|
||||
@@ -74,6 +74,18 @@ describe("buildGenerateRequest", () => {
|
||||
it("defaults brief to empty string", () => {
|
||||
expect(buildGenerateRequest({})).toEqual({ brief: "" });
|
||||
});
|
||||
|
||||
it("forwards trimmed text (扩写/降AI/拆书 原文输入)", () => {
|
||||
expect(
|
||||
buildGenerateRequest({ brief: "扩写", text: " 原始正文片段 " }),
|
||||
).toEqual({ brief: "扩写", text: "原始正文片段" });
|
||||
});
|
||||
|
||||
it("omits blank/whitespace-only text", () => {
|
||||
expect(buildGenerateRequest({ brief: "x", text: " " })).toEqual({
|
||||
brief: "x",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewRows", () => {
|
||||
@@ -137,6 +149,37 @@ describe("mapPreview", () => {
|
||||
it("yields no items for empty preview", () => {
|
||||
expect(mapPreview("IdeaListResult", undefined).items).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps a text-only result (续写/扩写/降AI) into a single body item", () => {
|
||||
for (const kind of ["ContinuationResult", "PolishResult", "DeAiResult"]) {
|
||||
const model = mapPreview(kind, { text: "一段续写/改写后的正文" });
|
||||
expect(model.items).toHaveLength(1);
|
||||
expect(model.items[0]?.heading).toBeNull();
|
||||
expect(model.items[0]?.body).toBe("一段续写/改写后的正文");
|
||||
}
|
||||
});
|
||||
|
||||
it("maps BookTeardownResult structured fields into one card", () => {
|
||||
const model = mapPreview("BookTeardownResult", {
|
||||
themes: ["逆袭", "复仇"],
|
||||
archetypes: ["废柴主角"],
|
||||
structure: "三幕递进",
|
||||
hooks: ["开局打脸", "扮猪吃虎"],
|
||||
});
|
||||
expect(model.items).toHaveLength(1);
|
||||
const item = model.items[0];
|
||||
expect(item?.heading).toBe("三幕递进");
|
||||
const byLabel = (label: string) =>
|
||||
item?.fields.find((f) => f.label === label)?.value;
|
||||
expect(byLabel("主题")).toBe("逆袭;复仇");
|
||||
expect(byLabel("人物原型")).toBe("废柴主角");
|
||||
expect(byLabel("钩子套路")).toBe("开局打脸;扮猪吃虎");
|
||||
});
|
||||
|
||||
it("yields no items for an empty text result", () => {
|
||||
expect(mapPreview("ContinuationResult", { text: "" }).items).toEqual([]);
|
||||
expect(mapPreview("ContinuationResult", undefined).items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLegacyRoute", () => {
|
||||
|
||||
@@ -60,6 +60,9 @@ export function buildGenerateRequest(values: FieldValues): ToolGenerateRequest {
|
||||
}
|
||||
const kind = values["kind"]?.trim();
|
||||
if (kind) body.kind = kind;
|
||||
// 原文输入(扩写/降AI/拆书样本,text_input 策略):非空才带(空白省略)。
|
||||
const text = values["text"]?.trim();
|
||||
if (text) body.text = text;
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -142,16 +145,61 @@ export function previewRows(
|
||||
return asRecordArray(firstArrayEntry(preview));
|
||||
}
|
||||
|
||||
// 单对象产物(非列表)的 output_kind:续写/扩写/降AI(纯正文 {text})+ 拆书(结构化对象)。
|
||||
// 这些 preview 本身就是一个对象(无顶层数组),故不走 firstArrayEntry 的「记录列表」假设,
|
||||
// 而归一为「单张卡」(Scope B 竞品快赢生成器)。
|
||||
const SINGLE_OBJECT_KINDS = new Set([
|
||||
"ContinuationResult",
|
||||
"PolishResult",
|
||||
"DeAiResult",
|
||||
"BookTeardownResult",
|
||||
]);
|
||||
|
||||
// output_kind → 行渲染策略。未知 kind 走兜底(heading=首个字符串字段,其余键值列出)。
|
||||
export function mapPreview(
|
||||
outputKind: string,
|
||||
preview: Record<string, unknown> | undefined,
|
||||
): PreviewModel {
|
||||
if (SINGLE_OBJECT_KINDS.has(outputKind)) {
|
||||
const item = singleObjectItem(outputKind, preview);
|
||||
return { kind: outputKind, items: item ? [item] : [] };
|
||||
}
|
||||
const rows = asRecordArray(firstArrayEntry(preview));
|
||||
const items = rows.map((row) => previewItem(outputKind, row));
|
||||
return { kind: outputKind, items };
|
||||
}
|
||||
|
||||
// 把单对象产物归一为一张预览卡:纯正文类 → body;拆书 → structure 作 heading + 列表字段。
|
||||
// preview 缺失/正文为空 → 返 null(mapPreview 据此给空 items,不渲空卡)。
|
||||
function singleObjectItem(
|
||||
outputKind: string,
|
||||
preview: Record<string, unknown> | undefined,
|
||||
): PreviewItem | null {
|
||||
if (!preview) return null;
|
||||
if (outputKind === "BookTeardownResult") {
|
||||
return teardownItem(preview);
|
||||
}
|
||||
const text = asString(preview["text"]);
|
||||
return text ? { heading: null, fields: [], body: text } : null;
|
||||
}
|
||||
|
||||
// 拆书结构化产物 → 一张卡:structure 作标题,themes/archetypes/hooks 各拼成一行字段。
|
||||
function teardownItem(preview: Record<string, unknown>): PreviewItem {
|
||||
const listField = (key: string, label: string): PreviewField[] => {
|
||||
const joined = asStringArray(preview[key]).join(";");
|
||||
return joined ? [{ label, value: joined }] : [];
|
||||
};
|
||||
return {
|
||||
heading: asString(preview["structure"]) || null,
|
||||
fields: [
|
||||
...listField("themes", "主题"),
|
||||
...listField("archetypes", "人物原型"),
|
||||
...listField("hooks", "钩子套路"),
|
||||
],
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
|
||||
function previewItem(
|
||||
outputKind: string,
|
||||
row: Record<string, unknown>,
|
||||
|
||||
File diff suppressed because one or more lines are too long
89
packages/agents/tests/test_competitor_specs.py
Normal file
89
packages/agents/tests/test_competitor_specs.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Scope B 竞品快赢 · 4 个生成器的 spec/schema 契约测试。
|
||||
|
||||
校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema,
|
||||
对齐不变量 #2(只声明 tier)/ #3(4 生成器一律纯预览 writes=[])。不联网、无 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from ww_agents import (
|
||||
AgentSpec,
|
||||
BookTeardownResult,
|
||||
ContinuationResult,
|
||||
DeAiResult,
|
||||
PolishResult,
|
||||
continue_spec,
|
||||
de_ai_spec,
|
||||
expand_spec,
|
||||
teardown_spec,
|
||||
)
|
||||
|
||||
# (spec, name, tier, reads, output_schema)
|
||||
_COMPETITOR_SPECS = [
|
||||
(continue_spec, "continue", "writer", ["projects", "outline", "chapters"], ContinuationResult),
|
||||
(expand_spec, "expand", "writer", ["projects"], PolishResult),
|
||||
(de_ai_spec, "de-ai", "analyst", ["projects"], DeAiResult),
|
||||
(teardown_spec, "teardown", "analyst", ["projects"], BookTeardownResult),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("spec", "name", "tier", "reads", "output_schema"),
|
||||
_COMPETITOR_SPECS,
|
||||
)
|
||||
def test_competitor_spec_declares_expected_contract(
|
||||
spec: AgentSpec,
|
||||
name: str,
|
||||
tier: str,
|
||||
reads: list[str],
|
||||
output_schema: type,
|
||||
) -> None:
|
||||
assert spec.name == name
|
||||
assert spec.tier == tier
|
||||
assert spec.reads == reads
|
||||
assert spec.writes == [] # 不变量 #3:4 生成器一律纯预览
|
||||
assert spec.output_schema is output_schema
|
||||
assert spec.scope == "builtin"
|
||||
assert spec.input_schema is None # 注入材料为序列化文本,非结构化入参
|
||||
|
||||
|
||||
@pytest.mark.parametrize("spec", [row[0] for row in _COMPETITOR_SPECS])
|
||||
def test_competitor_spec_has_nonempty_system_prompt(spec: AgentSpec) -> None:
|
||||
assert spec.system_prompt.strip()
|
||||
|
||||
|
||||
# ---- schema 解析/默认值契约 ----
|
||||
|
||||
|
||||
def test_continuation_result_parses_text() -> None:
|
||||
parsed = ContinuationResult.model_validate({"text": "雷光再起,他迈步向前。"})
|
||||
assert parsed.text == "雷光再起,他迈步向前。"
|
||||
|
||||
|
||||
def test_polish_result_parses_text() -> None:
|
||||
parsed = PolishResult.model_validate({"text": "夜色更浓,风穿过断壁。"})
|
||||
assert parsed.text == "夜色更浓,风穿过断壁。"
|
||||
|
||||
|
||||
def test_de_ai_result_parses_text() -> None:
|
||||
parsed = DeAiResult.model_validate({"text": "他咧嘴一笑,没多说什么。"})
|
||||
assert parsed.text == "他咧嘴一笑,没多说什么。"
|
||||
|
||||
|
||||
def test_book_teardown_result_parses_and_lists_default_empty() -> None:
|
||||
empty = BookTeardownResult(structure="开篇立钩→铺垫→爆发")
|
||||
assert empty.themes == []
|
||||
assert empty.archetypes == []
|
||||
assert empty.hooks == []
|
||||
parsed = BookTeardownResult.model_validate(
|
||||
{
|
||||
"themes": ["逆袭", "复仇"],
|
||||
"archetypes": ["废柴主角", "腹黑反派"],
|
||||
"structure": "黄金三章立爽点,逐卷升级",
|
||||
"hooks": ["章末反转", "扮猪吃虎"],
|
||||
}
|
||||
)
|
||||
assert parsed.themes == ["逆袭", "复仇"]
|
||||
assert parsed.structure == "黄金三章立爽点,逐卷升级"
|
||||
assert parsed.hooks == ["章末反转", "扮猪吃虎"]
|
||||
@@ -8,12 +8,15 @@ from __future__ import annotations
|
||||
from .schemas import (
|
||||
Blurb,
|
||||
BlurbResult,
|
||||
BookTeardownResult,
|
||||
CharacterCard,
|
||||
CharacterGenResult,
|
||||
CharacterRelation,
|
||||
Conflict,
|
||||
ConflictType,
|
||||
ContinuationResult,
|
||||
ContinuityReview,
|
||||
DeAiResult,
|
||||
DetailedOutlineResult,
|
||||
ForeshadowReview,
|
||||
ForeshadowSuggestion,
|
||||
@@ -32,6 +35,7 @@ from .schemas import (
|
||||
OutlineResult,
|
||||
PaceIssue,
|
||||
PaceReview,
|
||||
PolishResult,
|
||||
Scene,
|
||||
StyleDimension,
|
||||
StyleDriftReview,
|
||||
@@ -48,7 +52,10 @@ from .specs import (
|
||||
book_title_spec,
|
||||
brainstorm_spec,
|
||||
character_gen_spec,
|
||||
continue_spec,
|
||||
continuity_spec,
|
||||
de_ai_spec,
|
||||
expand_spec,
|
||||
fine_outline_spec,
|
||||
foreshadow_spec,
|
||||
glossary_spec,
|
||||
@@ -60,6 +67,7 @@ from .specs import (
|
||||
refiner_spec,
|
||||
style_drift_spec,
|
||||
style_extract_spec,
|
||||
teardown_spec,
|
||||
worldbuilder_spec,
|
||||
)
|
||||
|
||||
@@ -67,12 +75,15 @@ __all__ = [
|
||||
"AgentSpec",
|
||||
"Blurb",
|
||||
"BlurbResult",
|
||||
"BookTeardownResult",
|
||||
"CharacterCard",
|
||||
"CharacterGenResult",
|
||||
"CharacterRelation",
|
||||
"Conflict",
|
||||
"ConflictType",
|
||||
"ContinuationResult",
|
||||
"ContinuityReview",
|
||||
"DeAiResult",
|
||||
"DetailedOutlineResult",
|
||||
"ForeshadowReview",
|
||||
"ForeshadowSuggestion",
|
||||
@@ -91,6 +102,7 @@ __all__ = [
|
||||
"OutlineResult",
|
||||
"PaceIssue",
|
||||
"PaceReview",
|
||||
"PolishResult",
|
||||
"Scene",
|
||||
"StyleDimension",
|
||||
"StyleDriftReview",
|
||||
@@ -104,7 +116,10 @@ __all__ = [
|
||||
"book_title_spec",
|
||||
"brainstorm_spec",
|
||||
"character_gen_spec",
|
||||
"continue_spec",
|
||||
"continuity_spec",
|
||||
"de_ai_spec",
|
||||
"expand_spec",
|
||||
"fine_outline_spec",
|
||||
"foreshadow_spec",
|
||||
"glossary_spec",
|
||||
@@ -116,5 +131,6 @@ __all__ = [
|
||||
"refiner_spec",
|
||||
"style_drift_spec",
|
||||
"style_extract_spec",
|
||||
"teardown_spec",
|
||||
"worldbuilder_spec",
|
||||
]
|
||||
|
||||
@@ -527,3 +527,64 @@ class DetailedOutlineResult(BaseModel):
|
||||
default_factory=list,
|
||||
description="展开的场景清单;无则空列表",
|
||||
)
|
||||
|
||||
|
||||
# ---- 竞品快赢 · 续写生成器(continue;writer;纯预览 writes=[])----
|
||||
|
||||
|
||||
class ContinuationResult(BaseModel):
|
||||
"""续写生成器结构化产出:承接前文的续写正文(Scope B 竞品快赢)。
|
||||
|
||||
纯预览产物——不映射任何业务表、不入库(`continue_spec.writes=[]`,不变量 #3)。
|
||||
"""
|
||||
|
||||
text: str = Field(description="续写正文(承接前文,可直接作为下文草稿)")
|
||||
|
||||
|
||||
# ---- 竞品快赢 · 扩写生成器(expand;writer;纯预览 writes=[])----
|
||||
|
||||
|
||||
class PolishResult(BaseModel):
|
||||
"""扩写生成器结构化产出:在原文基础上扩写/丰富后的正文(Scope B 竞品快赢)。
|
||||
|
||||
纯预览产物——不映射任何业务表、不入库(`expand_spec.writes=[]`,不变量 #3)。
|
||||
"""
|
||||
|
||||
text: str = Field(description="扩写后的正文(在原文基础上丰富细节/铺陈)")
|
||||
|
||||
|
||||
# ---- 竞品快赢 · 降 AI 率生成器(de-ai;analyst;纯预览 writes=[])----
|
||||
|
||||
|
||||
class DeAiResult(BaseModel):
|
||||
"""降 AI 率生成器结构化产出:去 AI 腔/口语化改写后的正文(Scope B 竞品快赢)。
|
||||
|
||||
纯预览产物——不映射任何业务表、不入库(`de_ai_spec.writes=[]`,不变量 #3)。
|
||||
"""
|
||||
|
||||
text: str = Field(description="降 AI 率改写后的正文(去机翻腔、更自然的人写质感)")
|
||||
|
||||
|
||||
# ---- 竞品快赢 · 拆书生成器(teardown;analyst;纯预览 writes=[])----
|
||||
|
||||
|
||||
class BookTeardownResult(BaseModel):
|
||||
"""拆书生成器结构化产出:样本作品的结构化拆解(Scope B 竞品快赢)。
|
||||
|
||||
纯预览产物——不映射任何业务表、不入库(`teardown_spec.writes=[]`,不变量 #3)。
|
||||
拆书「落库成 rules」为可选 follow-up(需 `KNOWN_TABLES` + `_ingest_rules`),本期不做。
|
||||
"""
|
||||
|
||||
themes: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="核心主题/立意清单;无则空列表",
|
||||
)
|
||||
archetypes: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="人物原型/角色模板清单;无则空列表",
|
||||
)
|
||||
structure: str = Field(description="叙事结构概述(开篇/铺垫/高潮/收束的脉络)")
|
||||
hooks: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="抓人钩子/爽点套路清单;无则空列表",
|
||||
)
|
||||
|
||||
@@ -14,8 +14,11 @@ from ww_llm_gateway.types import Tier
|
||||
|
||||
from .schemas import (
|
||||
BlurbResult,
|
||||
BookTeardownResult,
|
||||
CharacterGenResult,
|
||||
ContinuationResult,
|
||||
ContinuityReview,
|
||||
DeAiResult,
|
||||
DetailedOutlineResult,
|
||||
ForeshadowReview,
|
||||
GlossaryResult,
|
||||
@@ -25,6 +28,7 @@ from .schemas import (
|
||||
OpeningResult,
|
||||
OutlineResult,
|
||||
PaceReview,
|
||||
PolishResult,
|
||||
StyleDriftReview,
|
||||
StyleFingerprintResult,
|
||||
TitleListResult,
|
||||
@@ -661,3 +665,140 @@ fine_outline_spec = AgentSpec(
|
||||
writes=["outline"], # 声明式(真写库经入库端点,不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- continue(续写生成器;写手档;纯预览,Scope B 竞品快赢)----
|
||||
|
||||
CONTINUE_SYSTEM_PROMPT = """你是中文网文的「续写器」(continue,写手档)。\
|
||||
职责:依据作品立意/题材、前文正文与(可选的)本章大纲节拍,承接前文续写下文正文,\
|
||||
保持人物、世界观、文风与剧情走向的一致,让续写可直接作为下文草稿。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意、主线、卖点);
|
||||
- 前文正文(最新已写正文——续写须无缝承接其情节、语气、人称、文风);
|
||||
- 本章大纲节拍(outline:若给出则续写须服务这些节拍,不另起炉灶;可空则按前文自然推进);
|
||||
- 作者的一句话需求/方向(可空则按前文与节拍自然续写)。
|
||||
|
||||
产出:
|
||||
- text:续写正文(承接前文的成稿文本,信息密度合理、有推进、留钩子)。
|
||||
|
||||
纪律:
|
||||
- **无缝承接前文**——人物言行、世界观硬规则、文风(句长/用词/人称)与前文一致,不跑题;
|
||||
- 若给了大纲节拍则忠实服务节拍,不臆造与设定/前文冲突的剧情;
|
||||
- 你只产结构化续写文本,**不改稿、不写库**(纯预览,不变量 #3)。"""
|
||||
|
||||
|
||||
continue_spec = AgentSpec(
|
||||
name="continue",
|
||||
tier="writer", # 不变量 #2:只声明档位,不写 model(正文续写用写手档)
|
||||
system_prompt=CONTINUE_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 前文 + 节拍 + 需求),非结构化入参
|
||||
output_schema=ContinuationResult,
|
||||
reads=["projects", "outline", "chapters"],
|
||||
writes=[], # 纯预览,不写库(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- expand(扩写生成器;写手档;纯预览,Scope B 竞品快赢)----
|
||||
|
||||
EXPAND_SYSTEM_PROMPT = """你是中文网文的「扩写器」(expand,写手档)。\
|
||||
职责:在作者提供的原文基础上扩写、丰富——补足细节、铺陈描写、强化张力,\
|
||||
但**不改变原文的情节走向与关键事实**,让扩写后的正文更饱满可读。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意、主线、卖点);
|
||||
- 待扩写的原文片段;
|
||||
- 作者的一句话需求/方向(如「加强环境描写」「放慢节奏铺情绪」;可空则均衡扩写)。
|
||||
|
||||
产出:
|
||||
- text:扩写后的正文(在原文基础上丰富细节/描写/铺陈,保留原情节与事实)。
|
||||
|
||||
纪律:
|
||||
- **保留原文情节与关键事实**,不增删主线、不改人物言行的核心;
|
||||
- 贴合作品文风与原文语气(句长、用词、人称一致),扩写自然不注水;
|
||||
- 若给了需求则优先满足;你只产结构化扩写文本,**不改稿、不写库**(纯预览,不变量 #3)。"""
|
||||
|
||||
|
||||
expand_spec = AgentSpec(
|
||||
name="expand",
|
||||
tier="writer", # 不变量 #2:只声明档位,不写 model(正文扩写用写手档)
|
||||
system_prompt=EXPAND_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 原文 + 需求),非结构化入参
|
||||
output_schema=PolishResult,
|
||||
reads=["projects"],
|
||||
writes=[], # 纯预览,不写库(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- de-ai(降 AI 率生成器;分析档;纯预览,Scope B 竞品快赢)----
|
||||
|
||||
DE_AI_SYSTEM_PROMPT = """你是中文网文的「降 AI 率改写器」(de-ai,分析档)。\
|
||||
职责:把作者提供的原文改写得更像「人写的」——去除 AI 腔/机翻腔、模板化句式、\
|
||||
空泛排比与过度对仗,让行文更自然、有个人质感,但**保留原文的情节信息与事实**。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:题材、立意);
|
||||
- 待改写的原文片段;
|
||||
- 作者的一句话需求/方向(如「更口语」「去掉排比腔」;可空则按通用「去 AI 味」目标)。
|
||||
|
||||
产出:
|
||||
- text:降 AI 率改写后的正文(去机翻腔/模板感,更自然的人写质感,保留原情节与事实)。
|
||||
|
||||
典型 AI 腔特征(重点消除):
|
||||
- 空泛排比与过度对仗、千篇一律的「不是……而是……」句式;
|
||||
- 形容词堆砌、抽象大词(如「彰显」「诠释」)滥用、缺乏具体感官细节;
|
||||
- 段落起承转合过于工整、缺少口语停顿与个人语气。
|
||||
|
||||
纪律:
|
||||
- **保留原文情节与关键事实**,不增删主线、不改人物言行;
|
||||
- 贴合作品文风与原文语气,改写后更自然但不失原意;
|
||||
- 你只产结构化改写文本,**不改稿、不写库**(纯预览,不变量 #3)。"""
|
||||
|
||||
|
||||
de_ai_spec = AgentSpec(
|
||||
name="de-ai",
|
||||
tier="analyst", # 不变量 #2:只声明档位,不写 model(文风改写用分析档)
|
||||
system_prompt=DE_AI_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 原文 + 需求),非结构化入参
|
||||
output_schema=DeAiResult,
|
||||
reads=["projects"],
|
||||
writes=[], # 纯预览,不写库(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
|
||||
# ---- teardown(拆书生成器;分析档;纯预览,Scope B 竞品快赢)----
|
||||
|
||||
TEARDOWN_SYSTEM_PROMPT = """你是中文网文的「拆书师」(teardown,分析档)。\
|
||||
职责:依据作者提供的样本作品(书名 + 章节/简介样本),把它拆解为可学习的结构化要素——\
|
||||
核心主题、人物原型、叙事结构、抓人钩子,供作者借鉴套路而非照抄。
|
||||
|
||||
输入材料:
|
||||
- 作品设定(projects:作者自己作品的题材/立意,供对照借鉴方向);
|
||||
- 待拆解的样本(书名 + 章节/简介/正文样本);
|
||||
- 作者的一句话需求/方向(如「重点拆开篇钩子」;可空则全面拆解)。
|
||||
|
||||
产出:
|
||||
- themes:核心主题/立意清单(这本书在讲什么、卖什么爽点);
|
||||
- archetypes:人物原型/角色模板清单(主角/对手/导师等的设定套路);
|
||||
- structure:叙事结构概述(开篇立钩→铺垫→爆发→收束的脉络与节奏);
|
||||
- hooks:抓人钩子/爽点套路清单(黄金三章、章末钩子、反转套路等)。
|
||||
|
||||
纪律:
|
||||
- 只从给定样本提炼,**不臆造**样本未体现的内容;样本不足则相应清单留空/概述从简;
|
||||
- 产出是**可借鉴的套路**,不复制原文情节;
|
||||
- 你只产结构化拆解,**不改稿、不写库**(纯预览,不变量 #3)。"""
|
||||
|
||||
|
||||
teardown_spec = AgentSpec(
|
||||
name="teardown",
|
||||
tier="analyst", # 不变量 #2:只声明档位,不写 model(拆解分析用分析档)
|
||||
system_prompt=TEARDOWN_SYSTEM_PROMPT,
|
||||
input_schema=None, # 注入材料为序列化文本(设定 + 样本 + 需求),非结构化入参
|
||||
output_schema=BookTeardownResult,
|
||||
reads=["projects"],
|
||||
writes=[], # 纯预览,不写库(不变量 #3)
|
||||
scope="builtin",
|
||||
)
|
||||
|
||||
68
packages/core/tests/test_competitor_context_builders.py
Normal file
68
packages/core/tests/test_competitor_context_builders.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Scope B 竞品快赢 · 2 个新上下文 builder 的纯函数单测。
|
||||
|
||||
`build_continuation_context`(续写读前文)/ `build_text_input_context`(扩写/降AI/拆书原文)。
|
||||
确定性、无时间戳/UUID(守不变量 #9 可缓存)、空值降级。不联网、无 DB。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_core.orchestrator import build_continuation_context, build_text_input_context
|
||||
|
||||
# ---- build_continuation_context:作品设定 + 前文 + 可选节拍 ----
|
||||
|
||||
|
||||
def test_continuation_context_is_deterministic() -> None:
|
||||
a = build_continuation_context(
|
||||
project_context="题材:玄幻", prior_text="他握紧剑。", beats=["反击", "逃脱"]
|
||||
)
|
||||
b = build_continuation_context(
|
||||
project_context="题材:玄幻", prior_text="他握紧剑。", beats=["反击", "逃脱"]
|
||||
)
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_continuation_context_includes_prior_and_beats() -> None:
|
||||
ctx = build_continuation_context(
|
||||
project_context="题材:玄幻", prior_text="他握紧剑。", beats=["反击", "逃脱"]
|
||||
)
|
||||
assert "题材:玄幻" in ctx
|
||||
assert "他握紧剑。" in ctx
|
||||
assert "1. 反击" in ctx
|
||||
assert "2. 逃脱" in ctx
|
||||
|
||||
|
||||
def test_continuation_context_no_beats_falls_back() -> None:
|
||||
ctx = build_continuation_context(project_context="题材:都市", prior_text="夜深了。")
|
||||
assert "本章暂无大纲节拍" in ctx
|
||||
assert "夜深了。" in ctx
|
||||
|
||||
|
||||
def test_continuation_context_empty_prior_falls_back() -> None:
|
||||
ctx = build_continuation_context(project_context="题材:都市", prior_text=" ")
|
||||
assert "暂无前文" in ctx
|
||||
|
||||
|
||||
# ---- build_text_input_context:作品设定 + 原文 + 需求 ----
|
||||
|
||||
|
||||
def test_text_input_context_is_deterministic() -> None:
|
||||
a = build_text_input_context(project_context="题材:玄幻", text="原文片段", brief="加强描写")
|
||||
b = build_text_input_context(project_context="题材:玄幻", text="原文片段", brief="加强描写")
|
||||
assert a == b
|
||||
|
||||
|
||||
def test_text_input_context_includes_text_and_brief() -> None:
|
||||
ctx = build_text_input_context(project_context="题材:玄幻", text="原文片段", brief="加强描写")
|
||||
assert "题材:玄幻" in ctx
|
||||
assert "原文片段" in ctx
|
||||
assert "加强描写" in ctx
|
||||
|
||||
|
||||
def test_text_input_context_empty_brief_falls_back() -> None:
|
||||
ctx = build_text_input_context(project_context="题材:都市", text="原文片段", brief=" ")
|
||||
assert "按默认目标处理" in ctx
|
||||
|
||||
|
||||
def test_text_input_context_empty_text_falls_back() -> None:
|
||||
ctx = build_text_input_context(project_context="题材:都市", text=" ", brief="去AI味")
|
||||
assert "未提供原文" in ctx
|
||||
@@ -24,8 +24,10 @@ from .collect import (
|
||||
from .generation_node import (
|
||||
build_brief_context,
|
||||
build_character_gen_context,
|
||||
build_continuation_context,
|
||||
build_outline_chapter_context,
|
||||
build_precheck_context,
|
||||
build_text_input_context,
|
||||
build_worldbuilder_context,
|
||||
precheck_generated_cards,
|
||||
run_character_gen,
|
||||
@@ -106,6 +108,7 @@ __all__ = [
|
||||
"SseEvent",
|
||||
"build_brief_context",
|
||||
"build_character_gen_context",
|
||||
"build_continuation_context",
|
||||
"build_outline_chapter_context",
|
||||
"build_outline_request",
|
||||
"build_precheck_context",
|
||||
@@ -113,6 +116,7 @@ __all__ = [
|
||||
"build_review_graph",
|
||||
"build_review_request",
|
||||
"build_style_extract_request",
|
||||
"build_text_input_context",
|
||||
"build_worldbuilder_context",
|
||||
"build_write_request",
|
||||
"collect_reviews",
|
||||
|
||||
@@ -96,6 +96,42 @@ def build_outline_chapter_context(
|
||||
)
|
||||
|
||||
|
||||
def build_continuation_context(
|
||||
*, project_context: str, prior_text: str, beats: Sequence[str] | None = None
|
||||
) -> str:
|
||||
"""续写「with_prior_chapter」上下文:作品设定 + 前文正文 + 可选大纲节拍。
|
||||
|
||||
覆盖续写生成器:注入最新已写正文供无缝承接;给了本章大纲节拍则一并注入供服务。
|
||||
确定性拼接、无时间戳/UUID(守不变量 #9 可缓存)。前文为空时给降级占位,交由
|
||||
system_prompt 处理。
|
||||
"""
|
||||
prior_block = prior_text.strip() or "(暂无前文,请按设定与节拍起笔)"
|
||||
if beats:
|
||||
beats_block = "\n".join(f"{i + 1}. {beat}" for i, beat in enumerate(beats))
|
||||
else:
|
||||
beats_block = "(本章暂无大纲节拍,按前文自然推进)"
|
||||
return (
|
||||
f"## 作品设定\n{project_context or '(暂无设定)'}\n\n"
|
||||
f"## 前文正文(续写须无缝承接)\n{prior_block}\n\n"
|
||||
f"## 本章大纲节拍\n{beats_block}"
|
||||
)
|
||||
|
||||
|
||||
def build_text_input_context(*, project_context: str, text: str, brief: str) -> str:
|
||||
"""原文输入「text_input」上下文:作品设定 + 待处理原文 + 作者需求。
|
||||
|
||||
覆盖扩写/降AI率/拆书:注入作者提供的原文样本 + 一句话方向。确定性拼接、无时间戳。
|
||||
需求为空时给降级占位,交由 system_prompt 的纪律处理。
|
||||
"""
|
||||
text_block = text.strip() or "(未提供原文)"
|
||||
brief_block = brief.strip() or "(作者未填具体方向,按默认目标处理)"
|
||||
return (
|
||||
f"## 作品设定\n{project_context or '(暂无设定)'}\n\n"
|
||||
f"## 原文\n{text_block}\n\n"
|
||||
f"## 创作需求\n{brief_block}"
|
||||
)
|
||||
|
||||
|
||||
async def run_generator(
|
||||
spec: AgentSpec,
|
||||
*,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
401
tests/test_competitor_generators_e2e.py
Normal file
401
tests/test_competitor_generators_e2e.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""Scope B 端到端:4 个竞品生成器(续写/扩写/降AI率/拆书)通用生成端点。
|
||||
|
||||
真实 DB(pg,无则 skip)+ 零 token MOCK 网关。证明 Scope B 竞品快赢闭环:
|
||||
- 4 生成器均经一条通用执行路径 `POST /projects/{id}/skills/{tool_key}/generate`;
|
||||
- 续写(with_prior_chapter):端点先读该章最新 accepted/draft 正文,注入续写上下文
|
||||
(断 fake 适配器收到的 `req.input` 含前文正文 — 验证跨层数据流,不变量 #1 DB 为真相源);
|
||||
- 扩写/降AI率(text_input):作者原文经 `body.text` 路由进上下文(断 `req.input` 含原文);
|
||||
- 拆书(text_input):结构化输出 themes/archetypes/structure/hooks(断 preview 字段齐全);
|
||||
- **不变量 #3(预览不写库)**:4 者均仅记 usage_ledger、业务表(chapters/world_entities/
|
||||
outline)零新增;token/正文不入响应外日志(断响应体无敏感字样)。
|
||||
|
||||
镜像 `tests/test_t6_toolbox_e2e.py` 的范式:override `get_tier_gateway_builder` 返回单个真
|
||||
`Gateway`(假适配器据 `req.output_schema` 分支返回 parsed + 真实 `SqlAlchemyLedgerSink`)。
|
||||
fake 适配器额外按 output_schema 记下收到的 `req.input`,供路由断言。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from asgi_lifespan import LifespanManager
|
||||
from fastapi import Depends
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
from ww_agents.schemas import (
|
||||
BookTeardownResult,
|
||||
ContinuationResult,
|
||||
DeAiResult,
|
||||
PolishResult,
|
||||
)
|
||||
from ww_db import get_session, get_sessionmaker
|
||||
from ww_db.models import (
|
||||
Chapter,
|
||||
Outline,
|
||||
Project,
|
||||
UsageLedger,
|
||||
WorldEntity,
|
||||
)
|
||||
from ww_llm_gateway import Gateway, SqlAlchemyLedgerSink, resolve_route
|
||||
from ww_llm_gateway.adapters.base import (
|
||||
Capabilities,
|
||||
ProviderResult,
|
||||
ProviderUsage,
|
||||
StreamChunk,
|
||||
)
|
||||
from ww_llm_gateway.types import LlmRequest, Tier
|
||||
|
||||
# light/analyst/writer 默认都路由到 deepseek(config.tier_defaults)。
|
||||
_PROVIDER = "deepseek"
|
||||
|
||||
# 各档位假用量(喂记账;证明记账落库)。
|
||||
_USAGE = {
|
||||
"analyst": ProviderUsage(input_tokens=23, output_tokens=7),
|
||||
"writer": ProviderUsage(input_tokens=31, output_tokens=17),
|
||||
}
|
||||
|
||||
# 前一章已写正文(accepted)——续写须无缝承接,端点读它注入上下文。
|
||||
_PRIOR_ACCEPTED_TEXT = "林动握紧拳头,灵气在丹田翻涌,他知道下一步将踏入炼气九重。"
|
||||
# 续写假产物(writer)。
|
||||
_CONTINUATION = ContinuationResult(text="他深吸一口气,周身灵气如潮水般汇聚,瓶颈应声而破。")
|
||||
|
||||
# 扩写原文(作者经 body.text 提供)+ 扩写假产物(writer)。
|
||||
_EXPAND_SOURCE = "他跑了。"
|
||||
_POLISH = PolishResult(text="他撒腿狂奔,脚下碎石飞溅,心跳如擂鼓,只想逃离这片死地。")
|
||||
|
||||
# 降AI率原文 + 假产物(analyst)。
|
||||
_DE_AI_SOURCE = "该角色在此刻进行了一次具有重要意义的决策行为。"
|
||||
_DE_AI = DeAiResult(text="这一刻,他咬了咬牙,做出了那个改变一切的决定。")
|
||||
|
||||
# 拆书样本 + 结构化拆解假产物(analyst)。
|
||||
_TEARDOWN_SOURCE = "第一章:少年得宝,立志复仇……(样本片段)"
|
||||
_TEARDOWN = BookTeardownResult(
|
||||
themes=["逆袭", "复仇"],
|
||||
archetypes=["落魄天才", "腹黑反派"],
|
||||
structure="开篇遭难→获得机缘→步步升级→反攻高潮",
|
||||
hooks=["开局打脸", "扮猪吃虎"],
|
||||
)
|
||||
|
||||
|
||||
class _FakeGeneratorAdapter:
|
||||
"""实现 `ProviderAdapter` Protocol:按 `req.output_schema` 分支返回 parsed,绝不联网。
|
||||
|
||||
额外把每路收到的 `req.input` 记进 `seen_input`(供路由断言:续写注入前文、
|
||||
扩写/降AI 注入原文)。
|
||||
"""
|
||||
|
||||
def __init__(self, provider: str = _PROVIDER) -> None:
|
||||
self.provider = provider
|
||||
self.seen_input: dict[type, str] = {}
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
return Capabilities(structured_output=True)
|
||||
|
||||
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
|
||||
schema = req.output_schema
|
||||
assert schema is not None
|
||||
# 注入材料进 input(str;断点后),system 才是 list[Block]——记下供路由断言。
|
||||
assert isinstance(req.input, str)
|
||||
self.seen_input[schema] = req.input
|
||||
if schema is ContinuationResult:
|
||||
return ProviderResult(
|
||||
text=_CONTINUATION.model_dump_json(), parsed=_CONTINUATION, usage=_USAGE["writer"]
|
||||
)
|
||||
if schema is PolishResult:
|
||||
return ProviderResult(
|
||||
text=_POLISH.model_dump_json(), parsed=_POLISH, usage=_USAGE["writer"]
|
||||
)
|
||||
if schema is DeAiResult:
|
||||
return ProviderResult(
|
||||
text=_DE_AI.model_dump_json(), parsed=_DE_AI, usage=_USAGE["analyst"]
|
||||
)
|
||||
if schema is BookTeardownResult:
|
||||
return ProviderResult(
|
||||
text=_TEARDOWN.model_dump_json(), parsed=_TEARDOWN, usage=_USAGE["analyst"]
|
||||
)
|
||||
raise AssertionError(f"unexpected output_schema in Scope B fake adapter: {schema!r}")
|
||||
|
||||
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
|
||||
yield StreamChunk(usage=_USAGE["analyst"])
|
||||
raise AssertionError("competitor generators must not stream")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
"""真实 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose)。"""
|
||||
get_sessionmaker.cache_clear()
|
||||
maker = get_sessionmaker()
|
||||
try:
|
||||
async with maker() as probe:
|
||||
await probe.execute(select(1))
|
||||
except Exception:
|
||||
pytest.skip("postgres not reachable")
|
||||
yield maker
|
||||
await maker.kw["bind"].dispose()
|
||||
get_sessionmaker.cache_clear()
|
||||
|
||||
|
||||
def _tier_builder_override(
|
||||
adapter: _FakeGeneratorAdapter,
|
||||
) -> Callable[[AsyncSession], Callable[[Tier], Awaitable[Gateway]]]:
|
||||
"""依赖覆盖:`get_tier_gateway_builder` → 返回「按 tier 建网关」的 builder。
|
||||
|
||||
builder 忽略 tier 返回同一个真 `Gateway`(单 provider 假适配器据 output_schema 分支 +
|
||||
真实 ledger,请求 session)——覆盖 writer(续写/扩写)与 analyst(降AI/拆书)两路。
|
||||
"""
|
||||
|
||||
def _override(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Callable[[Tier], Awaitable[Gateway]]:
|
||||
async def _build(_tier: Tier) -> Gateway:
|
||||
return Gateway(
|
||||
adapters={adapter.provider: adapter},
|
||||
ledger=SqlAlchemyLedgerSink(session),
|
||||
resolver=resolve_route,
|
||||
)
|
||||
|
||||
return _build
|
||||
|
||||
return _override
|
||||
|
||||
|
||||
async def _count(
|
||||
sm: async_sessionmaker[AsyncSession],
|
||||
project_id_col: InstrumentedAttribute[uuid.UUID | None],
|
||||
project_uuid: uuid.UUID,
|
||||
) -> int:
|
||||
async with sm() as s:
|
||||
return int(
|
||||
(
|
||||
await s.execute(select(func.count()).where(project_id_col == project_uuid))
|
||||
).scalar_one()
|
||||
)
|
||||
|
||||
|
||||
async def _seed_prior_chapter(
|
||||
sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID, chapter_no: int
|
||||
) -> None:
|
||||
"""种入第 `chapter_no` 章的 accepted 正文(续写承接的前文,DB 为真相源 #1)。"""
|
||||
async with sm() as s:
|
||||
s.add(
|
||||
Chapter(
|
||||
project_id=project_uuid,
|
||||
volume=1,
|
||||
chapter_no=chapter_no,
|
||||
content=_PRIOR_ACCEPTED_TEXT,
|
||||
status="accepted",
|
||||
version=2,
|
||||
)
|
||||
)
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def _cleanup(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
|
||||
"""按 FK 顺序清理。"""
|
||||
async with sm() as cleanup:
|
||||
await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
|
||||
await cleanup.execute(delete(Chapter).where(Chapter.project_id == project_uuid))
|
||||
await cleanup.execute(delete(Outline).where(Outline.project_id == project_uuid))
|
||||
await cleanup.execute(delete(WorldEntity).where(WorldEntity.project_id == project_uuid))
|
||||
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
|
||||
await cleanup.commit()
|
||||
|
||||
|
||||
def _assert_no_secret_leak(payload: object) -> None:
|
||||
"""负向:响应体序列化后绝不含 token/secret/api_key 等敏感字样。"""
|
||||
import json
|
||||
|
||||
blob = json.dumps(payload, ensure_ascii=False).lower()
|
||||
for needle in ("api_key", "secret", "token", "bearer", "sk-", "credential"):
|
||||
assert needle not in blob, f"sensitive marker leaked into response: {needle!r}"
|
||||
|
||||
|
||||
async def _create_project(client: httpx.AsyncClient, title: str) -> uuid.UUID:
|
||||
resp = await client.post("/projects", json={"title": title, "genre": "玄幻"})
|
||||
assert resp.status_code == 201
|
||||
return uuid.UUID(resp.json()["id"])
|
||||
|
||||
|
||||
async def _assert_business_tables_untouched(
|
||||
sm: async_sessionmaker[AsyncSession],
|
||||
project_uuid: uuid.UUID,
|
||||
*,
|
||||
expected_chapters: int,
|
||||
) -> None:
|
||||
"""不变量 #3:预览路径不写任何业务表(chapters 仅有种入的前文、world_entities/outline 空)。"""
|
||||
assert await _count(sm, Chapter.project_id, project_uuid) == expected_chapters
|
||||
assert await _count(sm, WorldEntity.project_id, project_uuid) == 0
|
||||
assert await _count(sm, Outline.project_id, project_uuid) == 0
|
||||
|
||||
|
||||
async def test_continue_generate_injects_prior_chapter_text(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""续写:端点读该章 accepted 正文注入续写上下文 → 200 预览;ledger 1 行、业务表零新增。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
adapter = _FakeGeneratorAdapter()
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
project_uuid = await _create_project(client, "Scope B 续写作品")
|
||||
await _seed_prior_chapter(e2e_sm, project_uuid, chapter_no=1)
|
||||
|
||||
resp = await client.post(
|
||||
f"/projects/{project_uuid}/skills/continue/generate",
|
||||
json={"chapter_no": 1, "brief": "推进突破桥段"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["tool_key"] == "continue"
|
||||
assert body["output_kind"] == "ContinuationResult"
|
||||
assert body["preview"]["text"] == _CONTINUATION.text
|
||||
_assert_no_secret_leak(body)
|
||||
|
||||
# 路由断言:端点把前一章 accepted 正文注入了续写上下文(跨层数据流 #1)。
|
||||
injected = adapter.seen_input[ContinuationResult]
|
||||
assert _PRIOR_ACCEPTED_TEXT in injected
|
||||
|
||||
# DB 真源:ledger 落 1 行(writer);业务表零新增(除种入的 1 章前文,不变量 #3)。
|
||||
assert project_uuid is not None
|
||||
assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1
|
||||
await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=1)
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
|
||||
|
||||
async def test_expand_generate_routes_source_text_into_context(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""扩写:作者原文经 body.text 路由进上下文 → 200 预览;ledger 1 行、业务表零新增。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
adapter = _FakeGeneratorAdapter()
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
project_uuid = await _create_project(client, "Scope B 扩写作品")
|
||||
|
||||
resp = await client.post(
|
||||
f"/projects/{project_uuid}/skills/expand/generate",
|
||||
json={"text": _EXPAND_SOURCE, "brief": "丰富细节"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["tool_key"] == "expand"
|
||||
assert body["output_kind"] == "PolishResult"
|
||||
assert body["preview"]["text"] == _POLISH.text
|
||||
_assert_no_secret_leak(body)
|
||||
|
||||
# 路由断言:作者原文进入了扩写上下文(text_input 策略)。
|
||||
assert _EXPAND_SOURCE in adapter.seen_input[PolishResult]
|
||||
|
||||
assert project_uuid is not None
|
||||
assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1
|
||||
await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=0)
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
|
||||
|
||||
async def test_de_ai_generate_routes_source_text_into_context(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""降AI率:作者原文经 body.text 路由进上下文 → 200 预览;ledger 1 行、业务表零新增。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
adapter = _FakeGeneratorAdapter()
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
project_uuid = await _create_project(client, "Scope B 降AI作品")
|
||||
|
||||
resp = await client.post(
|
||||
f"/projects/{project_uuid}/skills/de-ai/generate",
|
||||
json={"text": _DE_AI_SOURCE, "brief": "去机翻腔"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["tool_key"] == "de-ai"
|
||||
assert body["output_kind"] == "DeAiResult"
|
||||
assert body["preview"]["text"] == _DE_AI.text
|
||||
_assert_no_secret_leak(body)
|
||||
|
||||
# 路由断言:作者原文进入了降AI上下文(text_input 策略)。
|
||||
assert _DE_AI_SOURCE in adapter.seen_input[DeAiResult]
|
||||
|
||||
assert project_uuid is not None
|
||||
assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1
|
||||
await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=0)
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
|
||||
|
||||
async def test_teardown_generate_returns_structured_output(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""拆书:text_input → 200 结构化预览(themes/archetypes/structure/hooks);业务表零新增。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
adapter = _FakeGeneratorAdapter()
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(adapter)
|
||||
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
project_uuid: uuid.UUID | None = None
|
||||
try:
|
||||
async with LifespanManager(app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
project_uuid = await _create_project(client, "Scope B 拆书作品")
|
||||
|
||||
resp = await client.post(
|
||||
f"/projects/{project_uuid}/skills/teardown/generate",
|
||||
json={"text": _TEARDOWN_SOURCE, "kind": "某爽文", "brief": "拆解套路"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["tool_key"] == "teardown"
|
||||
assert body["output_kind"] == "BookTeardownResult"
|
||||
preview = body["preview"]
|
||||
# 结构化输出四字段齐全(断结构而非仅 text)。
|
||||
assert preview["themes"] == ["逆袭", "复仇"]
|
||||
assert preview["archetypes"] == ["落魄天才", "腹黑反派"]
|
||||
assert preview["structure"] == _TEARDOWN.structure
|
||||
assert preview["hooks"] == ["开局打脸", "扮猪吃虎"]
|
||||
_assert_no_secret_leak(body)
|
||||
|
||||
# 路由断言:作者样本原文进入了拆书上下文(text_input 策略)。
|
||||
assert _TEARDOWN_SOURCE in adapter.seen_input[BookTeardownResult]
|
||||
|
||||
assert project_uuid is not None
|
||||
assert await _count(e2e_sm, UsageLedger.project_id, project_uuid) == 1
|
||||
await _assert_business_tables_untouched(e2e_sm, project_uuid, expected_chapters=0)
|
||||
finally:
|
||||
if project_uuid is not None:
|
||||
await _cleanup(e2e_sm, project_uuid)
|
||||
@@ -11,7 +11,7 @@ ingest 预检调 `build_gateway("analyst")`——故 override **这一个 builde
|
||||
`Gateway`(假适配器据 output_schema 分支:生成产物 vs ContinuityReview)即可覆盖两路。
|
||||
|
||||
用例:
|
||||
1. `GET /skills/toolbox` 返回全 11 key(legacy 3 带 legacy_route+is_legacy=true;新 8);
|
||||
1. `GET /skills/toolbox` 返回全 15 key(legacy 3 带 legacy_route+is_legacy=true;新 12);
|
||||
brainstorm/glossary/golden-finger 的 ingestable 标志符合预期。
|
||||
2. brainstorm `generate` → 200 ToolGeneratePreviewResponse(output_kind + preview.ideas);
|
||||
断言 **ledger 真落行** 且 **业务表零新增**(world_entities 计数不变)——不变量 #3。
|
||||
@@ -241,10 +241,10 @@ def _assert_no_secret_leak(payload: object) -> None:
|
||||
assert needle not in blob, f"sensitive marker leaked into response: {needle!r}"
|
||||
|
||||
|
||||
async def test_t6_list_toolbox_returns_all_eleven_tools(
|
||||
async def test_t6_list_toolbox_returns_all_fifteen_tools(
|
||||
e2e_sm: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""用例 1:GET /skills/toolbox 返回全 11 key;legacy 3 带 legacy_route,新 8 走通用执行器。"""
|
||||
"""用例 1:GET /skills/toolbox 返回全 15 key;legacy 3 带 legacy_route,新 12 走通用执行器。"""
|
||||
from ww_api.main import create_app
|
||||
|
||||
app = create_app()
|
||||
@@ -256,7 +256,7 @@ async def test_t6_list_toolbox_returns_all_eleven_tools(
|
||||
body = resp.json()
|
||||
|
||||
tools = {t["key"]: t for t in body["tools"]}
|
||||
# 全 11 key(legacy 3 + 新 8)。
|
||||
# 全 15 key(legacy 3 + 新 8 + 竞品快赢 4:续写/扩写/降AI率/拆书)。
|
||||
assert set(tools) == {
|
||||
"worldbuilding",
|
||||
"character",
|
||||
@@ -269,6 +269,10 @@ async def test_t6_list_toolbox_returns_all_eleven_tools(
|
||||
"glossary",
|
||||
"opening",
|
||||
"fine-outline",
|
||||
"continue",
|
||||
"expand",
|
||||
"de-ai",
|
||||
"teardown",
|
||||
}
|
||||
# legacy(spec=None):is_legacy=true + 带 legacy_route,不可经通用执行入库。
|
||||
legacy_keys = {"worldbuilding", "character", "outline"}
|
||||
|
||||
Reference in New Issue
Block a user