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:
Yaojia Wang
2026-06-23 19:40:11 +02:00
32 changed files with 2247 additions and 24 deletions

View File

@@ -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]

View File

@@ -19,9 +19,18 @@ from cryptography.fernet import Fernet
from fakes_projects import FakeProjectRepo, FakeSession
from test_projects import _empty_memory_repos
from ww_agents import Conflict, ContinuityReview
from ww_agents.schemas import Idea, IdeaListResult
from ww_agents.schemas import (
BookTeardownResult,
ContinuationResult,
DeAiResult,
Idea,
IdeaListResult,
PolishResult,
)
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
from ww_core.domain.outline_write_repo import OutlineWriteView
from ww_core.domain.project_repo import ProjectCreate
from ww_core.domain.repositories import OutlineView
from ww_core.domain.world_entity_repo import WorldEntityWriteView
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
from ww_shared import AppError, ErrorCode
@@ -85,6 +94,88 @@ class _FakeOutlineWriteRepo:
)
class _FakeOutlineReadRepo:
"""续写/按章工具读节拍用的内存替身:缺章 → None端点降级到空节拍"""
def __init__(self, beats_by_chapter: dict[int, list[str]] | None = None) -> None:
self._beats = beats_by_chapter or {}
async def get(self, project_id: uuid.UUID, chapter_no: int) -> Any:
beats = self._beats.get(chapter_no)
if beats is None:
return None
return OutlineView(
chapter_no=chapter_no, volume=1, beats={"beats": beats}, foreshadow_windows=[]
)
async def list_for_project(self, project_id: uuid.UUID) -> list[Any]:
return []
class _FakeChapterRepo:
"""续写读前文用的内存章节替身:按 chapter_no 存 accepted/draft 正文。"""
def __init__(
self,
*,
accepted: dict[int, str] | None = None,
drafts: dict[int, str] | None = None,
) -> None:
self._accepted = accepted or {}
self._drafts = drafts or {}
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> ChapterView | None:
content = self._accepted.get(chapter_no)
if content is None:
return None
return ChapterView(
project_id=project_id,
chapter_no=chapter_no,
volume=1,
content=content,
status="accepted",
version=1,
)
async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> ChapterDraftView | None:
content = self._drafts.get(chapter_no)
if content is None:
return None
return ChapterDraftView(
project_id=project_id,
chapter_no=chapter_no,
volume=1,
content=content,
status="draft",
version=1,
)
class _CaptureGateway:
"""记录最近一次 req.input验证续写读前文/原文路由进了 context返指定 parsed。"""
def __init__(self, parsed: Any) -> None:
self._parsed = parsed
self.last_input: str | None = None
async def run(self, req: LlmRequest) -> LlmResponse:
# 工具箱端点恒传序列化文本 contextstr存为 str 供断言。
self.last_input = req.input if isinstance(req.input, str) else str(req.input)
return LlmResponse(
text=self._parsed.model_dump_json(),
parsed=self._parsed,
usage=Usage(
provider="fake",
model="fake",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake"),
)
def _ideas() -> IdeaListResult:
return IdeaListResult(ideas=[Idea(premise="废柴觉醒系统", hook="开局即巅峰", genre_fit="玄幻")])
@@ -106,13 +197,17 @@ def _make_app(
session: FakeSession | None = None,
world_repo: _FakeWorldWriteRepo | None = None,
outline_write_repo: _FakeOutlineWriteRepo | None = None,
chapter_repo: _FakeChapterRepo | None = None,
outline_read_repo: _FakeOutlineReadRepo | None = None,
memory: Any = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]:
os.environ.setdefault("CREDENTIAL_ENC_KEY", Fernet.generate_key().decode())
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_chapter_repo,
get_memory_repos,
get_outline_read_repo,
get_outline_write_repo,
get_project_repo,
get_tier_gateway_builder,
@@ -123,6 +218,8 @@ def _make_app(
session = session or FakeSession()
world_repo = world_repo or _FakeWorldWriteRepo()
outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo()
chapter_repo = chapter_repo or _FakeChapterRepo()
outline_read_repo = outline_read_repo or _FakeOutlineReadRepo()
async def _build_ok(_tier: str) -> Any:
return gateway
@@ -137,6 +234,8 @@ def _make_app(
)
app.dependency_overrides[get_world_entity_write_repo] = lambda: world_repo
app.dependency_overrides[get_outline_write_repo] = lambda: outline_write_repo
app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
app.dependency_overrides[get_outline_read_repo] = lambda: outline_read_repo
app.dependency_overrides[get_session] = lambda: session
app.dependency_overrides[get_tier_gateway_builder] = lambda: (
_build_no_creds if no_creds else _build_ok
@@ -180,8 +279,16 @@ async def test_list_toolbox_contains_all_keys() -> None:
"glossary",
"opening",
"fine-outline",
"continue",
"expand",
"de-ai",
"teardown",
}
by_key = {t["key"]: t for t in tools}
# 4 个竞品生成器:非 legacy、纯预览不可入库
for key in ("continue", "expand", "de-ai", "teardown"):
assert by_key[key]["is_legacy"] is False
assert by_key[key]["ingestable"] is False
# legacy 携 legacy_route + is_legacy新工具 ingestable 标记。
assert by_key["worldbuilding"]["is_legacy"] is True
assert by_key["worldbuilding"]["legacy_route"]
@@ -272,6 +379,146 @@ async def test_generate_no_credentials_503() -> None:
assert session.commits == 0
# ---- POST .../generateScope B 竞品快赢:续写/扩写/降AI/拆书)----
@pytest.mark.asyncio
async def test_generate_continue_reads_prior_chapter() -> None:
# 续写:端点读该章最新 accepted 正文 → 注入 contextwith_prior_chapter 路由)。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
chapter_repo = _FakeChapterRepo(accepted={2: "他握紧了那柄剑,缓缓转身。"})
gateway = _CaptureGateway(ContinuationResult(text="剑光一闪,敌人应声倒地。"))
app, session, *_ = _make_app(project_repo=repo, gateway=gateway, chapter_repo=chapter_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/continue/generate", json={"chapter_no": 2, "brief": "续写战斗"}
)
assert resp.status_code == 200
body = resp.json()
assert body["tool_key"] == "continue"
assert body["output_kind"] == "ContinuationResult"
assert body["preview"]["text"] == "剑光一闪,敌人应声倒地。"
# 前文正文确实进了喂网关的 context读章路由生效
assert gateway.last_input is not None
assert "他握紧了那柄剑" in gateway.last_input
assert session.commits == 1 # 预览不写业务表,落 ledger
@pytest.mark.asyncio
async def test_generate_continue_falls_back_to_draft() -> None:
# 无 accepted → 退回草稿正文。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
chapter_repo = _FakeChapterRepo(drafts={1: "草稿里的开头一段。"})
gateway = _CaptureGateway(ContinuationResult(text="续写。"))
app, *_ = _make_app(project_repo=repo, gateway=gateway, chapter_repo=chapter_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/continue/generate", json={"chapter_no": 1}
)
assert resp.status_code == 200
assert gateway.last_input is not None
assert "草稿里的开头一段" in gateway.last_input
@pytest.mark.asyncio
async def test_generate_expand_routes_text_input() -> None:
# 扩写:原文经 text 字段 → 注入 contexttext_input 路由)。
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _CaptureGateway(PolishResult(text="扩写后的丰富正文。"))
app, session, *_ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/expand/generate",
json={"text": "原始简短的一句话。", "brief": "加强描写"},
)
assert resp.status_code == 200
body = resp.json()
assert body["output_kind"] == "PolishResult"
assert body["preview"]["text"] == "扩写后的丰富正文。"
assert gateway.last_input is not None
assert "原始简短的一句话" in gateway.last_input
assert session.commits == 1
@pytest.mark.asyncio
async def test_generate_de_ai_routes_text_input() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
gateway = _CaptureGateway(DeAiResult(text="更自然的人写质感正文。"))
app, *_ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/de-ai/generate",
json={"text": "充斥着 AI 腔的排比句。"},
)
assert resp.status_code == 200
assert resp.json()["output_kind"] == "DeAiResult"
assert gateway.last_input is not None
assert "充斥着 AI 腔的排比句" in gateway.last_input
@pytest.mark.asyncio
async def test_generate_teardown_returns_structured() -> None:
repo = FakeProjectRepo()
pid = await _seed_project(repo)
parsed = BookTeardownResult(
themes=["逆袭"],
archetypes=["废柴主角"],
structure="黄金三章立钩",
hooks=["开局即巅峰"],
)
gateway = _CaptureGateway(parsed)
app, *_ = _make_app(project_repo=repo, gateway=gateway)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/teardown/generate",
json={"kind": "某爆款", "text": "样本章节正文……"},
)
assert resp.status_code == 200
body = resp.json()
assert body["output_kind"] == "BookTeardownResult"
assert body["preview"]["themes"] == ["逆袭"]
assert body["preview"]["structure"] == "黄金三章立钩"
# 书名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 .../ingestworld_entities gate----

View File

@@ -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 的注入模式),守不变量 #1DB 为真相源)。
"""
accepted = await chapter_repo.latest_accepted(project_id, chapter_no)
if accepted is not None and accepted.content.strip():
return accepted.content
draft = await chapter_repo.get_draft(project_id, chapter_no)
if draft is not None and draft.content.strip():
return draft.content
return ""
@router.post("/projects/{project_id}/skills/{tool_key}/generate", responses=_TOOL_ERRORS)
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)

View File

@@ -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="命名对象类别等(部分工具可用)")

View File

@@ -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 "(暂无世界观设定)"

View 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 取项目ChainPageClient承载
// 发起 → 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();
}
}

View 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 resumebuildResumeRequest 复用 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>
);
}

View 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],
);
// 续跑成功:重启轮询同一 jobresume 已 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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无凭据 → 503dep 解析阶段拦下);
* `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。
*
* 项目不存在 → 404job 不存在 / 不属于该项目 → 404同案不枚举非 awaiting 态 →
* 409CONFLICT。`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 受理回执202job_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 链受理回执202job_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;

View File

@@ -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 =

View 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: [] });
});
});

View 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 的裁决组装逻辑DRYresume.decisions 与
// accept.decisions 同为 ConflictDecision[]),仅丢弃 final_text链续跑不带终稿
export function buildResumeRequest(
drafts: readonly DecisionDraft[],
): ChainResumeRequest {
const { decisions } = buildAcceptRequest("", drafts);
return { decisions };
}

View File

@@ -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",

View File

@@ -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" },

View File

@@ -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", () => {

View File

@@ -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 缺失/正文为空 → 返 nullmapPreview 据此给空 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

View File

@@ -0,0 +1,89 @@
"""Scope B 竞品快赢 · 4 个生成器的 spec/schema 契约测试。
校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema
对齐不变量 #2只声明 tier/ #34 生成器一律纯预览 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 == [] # 不变量 #34 生成器一律纯预览
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 == ["章末反转", "扮猪吃虎"]

View File

@@ -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",
]

View File

@@ -527,3 +527,64 @@ class DetailedOutlineResult(BaseModel):
default_factory=list,
description="展开的场景清单;无则空列表",
)
# ---- 竞品快赢 · 续写生成器continuewriter纯预览 writes=[]----
class ContinuationResult(BaseModel):
"""续写生成器结构化产出承接前文的续写正文Scope B 竞品快赢)。
纯预览产物——不映射任何业务表、不入库(`continue_spec.writes=[]`,不变量 #3
"""
text: str = Field(description="续写正文(承接前文,可直接作为下文草稿)")
# ---- 竞品快赢 · 扩写生成器expandwriter纯预览 writes=[]----
class PolishResult(BaseModel):
"""扩写生成器结构化产出:在原文基础上扩写/丰富后的正文Scope B 竞品快赢)。
纯预览产物——不映射任何业务表、不入库(`expand_spec.writes=[]`,不变量 #3
"""
text: str = Field(description="扩写后的正文(在原文基础上丰富细节/铺陈)")
# ---- 竞品快赢 · 降 AI 率生成器de-aianalyst纯预览 writes=[]----
class DeAiResult(BaseModel):
"""降 AI 率生成器结构化产出:去 AI 腔/口语化改写后的正文Scope B 竞品快赢)。
纯预览产物——不映射任何业务表、不入库(`de_ai_spec.writes=[]`,不变量 #3
"""
text: str = Field(description="降 AI 率改写后的正文(去机翻腔、更自然的人写质感)")
# ---- 竞品快赢 · 拆书生成器teardownanalyst纯预览 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="抓人钩子/爽点套路清单;无则空列表",
)

View File

@@ -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",
)

View 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

View File

@@ -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",

View File

@@ -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,
*,

View File

@@ -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_TABLESNone=纯预览);
- `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
# Assert4 基础策略 + 竞品快赢 2 策略(续写读前文 / 原文输入)。
assert values == {
"brief_only",
"with_project",
"with_world",
"with_outline_chapter",
"with_prior_chapter",
"text_input",
}

View File

@@ -1,7 +1,8 @@
"""T6.2 创作工具箱注册表(`TOOLBOX`)单测。
校验11 条齐全legacy 3 + 新 8legacy 携 legacy_route 且 spec=None新工具携 spec +
output_schema与 spec.output_schema 一致ingest 工具的 table 在白名单内;`get_tool` 解析。
校验15 条齐全legacy 3 + 新 8 + 竞品快赢 4legacy 携 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
# Assertlegacy 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:

View File

@@ -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",
]

View File

@@ -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] = {
# ---- legacyspec=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-onlywrites=[]----
"continue": GeneratorTool(
key="continue",
title="续写生成器",
subtitle="承接前文,无缝续写下文",
spec=continue_spec,
output_schema=ContinuationResult,
context_strategy="with_prior_chapter",
input_fields=[
InputField(
name="chapter_no",
label="续写章号",
type="number",
required=False,
help="续写的目标章号(读取该章已写正文承接;缺则按设定起笔)",
),
_BRIEF_FIELD,
],
),
"expand": GeneratorTool(
key="expand",
title="扩写生成器",
subtitle="在原文基础上丰富细节铺陈",
spec=expand_spec,
output_schema=PolishResult,
context_strategy="text_input",
input_fields=[_SOURCE_TEXT_FIELD, _BRIEF_FIELD],
),
"de-ai": GeneratorTool(
key="de-ai",
title="降 AI 率生成器",
subtitle="去机翻腔,更自然的人写质感",
spec=de_ai_spec,
output_schema=DeAiResult,
context_strategy="text_input",
input_fields=[_SOURCE_TEXT_FIELD, _BRIEF_FIELD],
),
"teardown": GeneratorTool(
key="teardown",
title="拆书生成器",
subtitle="拆解主题/原型/结构/钩子套路",
spec=teardown_spec,
output_schema=BookTeardownResult,
context_strategy="text_input",
input_fields=[
InputField(
name="kind",
label="书名",
type="text",
required=False,
help="待拆解作品的书名(供拆解对照)",
),
_SOURCE_TEXT_FIELD,
_BRIEF_FIELD,
],
),
}

View File

@@ -0,0 +1,401 @@
"""Scope B 端到端4 个竞品生成器(续写/扩写/降AI率/拆书)通用生成端点。
真实 DBpg无则 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 默认都路由到 deepseekconfig.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
# 注入材料进 inputstr断点后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)

View File

@@ -11,7 +11,7 @@ ingest 预检调 `build_gateway("analyst")`——故 override **这一个 builde
`Gateway`(假适配器据 output_schema 分支:生成产物 vs ContinuityReview即可覆盖两路。
用例:
1. `GET /skills/toolbox` 返回全 11 keylegacy 3 带 legacy_route+is_legacy=true8
1. `GET /skills/toolbox` 返回全 15 keylegacy 3 带 legacy_route+is_legacy=true12
brainstorm/glossary/golden-finger 的 ingestable 标志符合预期。
2. brainstorm `generate` → 200 ToolGeneratePreviewResponseoutput_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:
"""用例 1GET /skills/toolbox 返回全 11 keylegacy 3 带 legacy_route8 走通用执行器。"""
"""用例 1GET /skills/toolbox 返回全 15 keylegacy 3 带 legacy_route12 走通用执行器。"""
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 keylegacy 3 + 新 8
# 全 15 keylegacy 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",
}
# legacyspec=Noneis_legacy=true + 带 legacy_route不可经通用执行入库。
legacy_keys = {"worldbuilding", "character", "outline"}