merge: 续补三件 — 拆书入库rules / 续写式链 continue_volume / 模板库

多 agent 逐学科建造 + 4 路对抗交叉评审 + 修 3 HIGH。
后端 mypy 202 / alembic 无漂移 / pytest 670;前端 vitest 309/build 绿。
This commit is contained in:
Yaojia Wang
2026-06-23 20:45:56 +02:00
45 changed files with 2655 additions and 48 deletions

View File

@@ -252,6 +252,28 @@ async def test_run_chain_returns_202_and_schedules_job(
assert len(_noop_run_chain_job) == 1
async def test_run_continue_volume_chain_returns_202_and_forwards_chain_key(
_noop_run_chain_job: list[dict[str, Any]],
) -> None:
"""F2continue_volume 链受支持 → 202且 chain_key 透传给 run_chain_job续写模式"""
project_repo = FakeProjectRepo()
job_repo = FakeJobRepo()
pid = await _seed_project(project_repo)
app = _app(project_repo, job_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chains/continue_volume/run",
json={"start_chapter_no": 2, "count": 2},
)
assert resp.status_code == 202
body = resp.json()
assert body["chain_key"] == "continue_volume"
assert len(_noop_run_chain_job) == 1
assert _noop_run_chain_job[0]["kwargs"]["chain_key"] == "continue_volume"
async def test_run_chain_unknown_key_returns_404(
_noop_run_chain_job: list[dict[str, Any]],
) -> None:
@@ -343,6 +365,31 @@ async def test_resume_chain_awaiting_returns_202(
assert len(_noop_run_chain_job) == 1
async def test_resume_continue_volume_reports_true_chain_key(
_noop_run_chain_job: list[dict[str, Any]],
) -> None:
"""回归守卫 #1resume continue_volume run → 回执 chain_key="continue_volume"(读自
job.resultrun 时由 _chain_result 持久化),而非旧 bug 按 job.kind 推断的 "draft_volume"
且转发给 run_chain_job 的 chain_key 也是真值(续写模式不被错降为 draft_volume"""
project_repo = FakeProjectRepo()
job_repo = FakeJobRepo()
pid = await _seed_project(project_repo)
job = await job_repo.create(pid, "chain") # kind 恒为通用常量 "chain"
# run 时 interrupt 把真 chain_key 持久进 resultcontinue_volume
await job_repo.set_awaiting(job.id, {"awaiting_chapter": 1, "chain_key": "continue_volume"})
app = _app(project_repo, job_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/chains/runs/{job.id}/resume",
json={"decisions": [{"conflict_index": 0, "verdict": "ignore"}]},
)
assert resp.status_code == 202
assert resp.json()["chain_key"] == "continue_volume"
assert _noop_run_chain_job[0]["kwargs"]["chain_key"] == "continue_volume"
async def test_resume_chain_non_awaiting_returns_409(
_noop_run_chain_job: list[dict[str, Any]],
) -> None:

View File

@@ -0,0 +1,144 @@
"""F3b GET/POST/DELETE /templates 端点(内存替身,无 DB/无网络)。
覆盖:
- POST 201 + 回显 + 端点 commitGET 列出DELETE 204 + commit
- 空 title → 422、空 body → 422schema `min_length=1`FastAPI 422
- 删不存在 → 404repo.delete 返 False → AppError NOT_FOUND
"""
from __future__ import annotations
import os
import uuid
import httpx
import pytest
from cryptography.fernet import Fernet
from fakes_projects import FakeSession
from ww_core.domain.template_repo import TemplateCreate, TemplateView
class _FakeTemplateRepo:
def __init__(self) -> None:
self.rows: dict[uuid.UUID, TemplateView] = {}
async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView:
view = TemplateView(
id=uuid.uuid4(),
title=data.title,
body=data.body,
category=data.category,
tool_key=data.tool_key,
)
self.rows[view.id] = view
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
return list(self.rows.values())
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
if template_id in self.rows:
del self.rows[template_id]
return True
return False
def _make_client() -> tuple[httpx.AsyncClient, _FakeTemplateRepo, FakeSession]:
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_template_repo
from ww_db import get_session
repo = _FakeTemplateRepo()
session = FakeSession()
app = create_app()
app.dependency_overrides[get_template_repo] = lambda: repo
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, repo, session
@pytest.mark.asyncio
async def test_create_template_returns_201_and_commits() -> None:
client, repo, session = _make_client()
async with client:
resp = await client.post(
"/templates",
json={"title": "爽文开局", "body": "主角穿越...", "tool_key": "expand"},
)
assert resp.status_code == 201
body = resp.json()
assert body["title"] == "爽文开局"
assert body["body"] == "主角穿越..."
assert body["tool_key"] == "expand"
assert session.commits == 1
assert len(repo.rows) == 1
@pytest.mark.asyncio
async def test_list_templates_returns_created() -> None:
client, repo, _session = _make_client()
async with client:
await client.post("/templates", json={"title": "a", "body": "b"})
resp = await client.get("/templates")
assert resp.status_code == 200
items = resp.json()
assert len(items) == 1
assert items[0]["title"] == "a"
@pytest.mark.asyncio
async def test_create_template_empty_title_returns_422() -> None:
client, _repo, _session = _make_client()
async with client:
resp = await client.post("/templates", json={"title": "", "body": "b"})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_template_empty_body_returns_422() -> None:
client, _repo, _session = _make_client()
async with client:
resp = await client.post("/templates", json={"title": "a", "body": ""})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_template_whitespace_only_title_returns_422() -> None:
# strip 后为空 → min_length=1 不满足 → 422防纯空白标题审评 #2
client, _repo, _session = _make_client()
async with client:
resp = await client.post("/templates", json={"title": " ", "body": "b"})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_template_whitespace_only_body_returns_422() -> None:
client, _repo, _session = _make_client()
async with client:
resp = await client.post("/templates", json={"title": "a", "body": " "})
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_delete_template_returns_204_and_commits() -> None:
client, repo, session = _make_client()
async with client:
created = await client.post("/templates", json={"title": "a", "body": "b"})
tid = created.json()["id"]
resp = await client.delete(f"/templates/{tid}")
assert resp.status_code == 204
assert session.commits == 2
assert repo.rows == {}
@pytest.mark.asyncio
async def test_delete_missing_template_returns_404() -> None:
client, _repo, session = _make_client()
async with client:
resp = await client.delete(f"/templates/{uuid.uuid4()}")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "NOT_FOUND"
assert session.commits == 0

View File

@@ -31,6 +31,7 @@ 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.rule_repo import RuleWriteView
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
@@ -94,6 +95,17 @@ class _FakeOutlineWriteRepo:
)
class _FakeRuleWriteRepo:
"""拆书入库 rules 用的内存替身:按 (level, content) 收行(只 flush 语义)。"""
def __init__(self) -> None:
self.rows: list[dict[str, Any]] = []
async def create(self, project_id: uuid.UUID, *, level: str, content: str) -> RuleWriteView:
self.rows.append({"project_id": project_id, "level": level, "content": content})
return RuleWriteView(project_id=project_id, level=level, content=content)
class _FakeOutlineReadRepo:
"""续写/按章工具读节拍用的内存替身:缺章 → None端点降级到空节拍"""
@@ -199,6 +211,7 @@ def _make_app(
outline_write_repo: _FakeOutlineWriteRepo | None = None,
chapter_repo: _FakeChapterRepo | None = None,
outline_read_repo: _FakeOutlineReadRepo | None = None,
rule_write_repo: _FakeRuleWriteRepo | None = None,
memory: Any = None,
no_creds: bool = False,
) -> tuple[Any, FakeSession, _FakeWorldWriteRepo, _FakeOutlineWriteRepo]:
@@ -210,6 +223,7 @@ def _make_app(
get_outline_read_repo,
get_outline_write_repo,
get_project_repo,
get_rule_write_repo,
get_tier_gateway_builder,
get_world_entity_write_repo,
)
@@ -220,6 +234,7 @@ def _make_app(
outline_write_repo = outline_write_repo or _FakeOutlineWriteRepo()
chapter_repo = chapter_repo or _FakeChapterRepo()
outline_read_repo = outline_read_repo or _FakeOutlineReadRepo()
rule_write_repo = rule_write_repo or _FakeRuleWriteRepo()
async def _build_ok(_tier: str) -> Any:
return gateway
@@ -236,6 +251,7 @@ def _make_app(
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_rule_write_repo] = lambda: rule_write_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
@@ -285,10 +301,13 @@ async def test_list_toolbox_contains_all_keys() -> None:
"teardown",
}
by_key = {t["key"]: t for t in tools}
# 4 个竞品生成器:非 legacy、纯预览不可入库
for key in ("continue", "expand", "de-ai", "teardown"):
# 续写/扩写/降AI:非 legacy、纯预览不可入库
for key in ("continue", "expand", "de-ai"):
assert by_key[key]["is_legacy"] is False
assert by_key[key]["ingestable"] is False
# F1拆书非 legacy、可入库落 rules
assert by_key["teardown"]["is_legacy"] is False
assert by_key["teardown"]["ingestable"] is True
# legacy 携 legacy_route + is_legacy新工具 ingestable 标记。
assert by_key["worldbuilding"]["is_legacy"] is True
assert by_key["worldbuilding"]["legacy_route"]
@@ -622,3 +641,72 @@ async def test_ingest_outline_upserts_scenes() -> None:
assert outline_repo.rows[0]["beats"] == ["主角觉醒", "初遇反派"]
assert outline_repo.rows[0]["chapter_no"] == 3
assert session.commits == 1
# ---- POST .../ingestF1拆书落库成 rules----
def _teardown_payload() -> dict[str, Any]:
return {
"teardown": {
"themes": ["逆袭", "复仇"],
"archetypes": ["废柴主角", "腹黑反派"],
"structure": "黄金三章立钩→铺垫→爆发",
"hooks": ["开局即巅峰", "扮猪吃虎"],
}
}
@pytest.mark.asyncio
async def test_ingest_teardown_writes_rules_201() -> None:
# F1拆书结论拍平为可读 rules 条目落库(无 continuity 预检,仿 outline 无 409
repo = FakeProjectRepo()
pid = await _seed_project(repo)
rule_repo = _FakeRuleWriteRepo()
gateway = _SchemaRoutingGateway({})
app, session, *_ = _make_app(project_repo=repo, gateway=gateway, rule_write_repo=rule_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/teardown/ingest", json=_teardown_payload()
)
assert resp.status_code == 201
body = resp.json()
assert body["table"] == "rules"
assert body["rejected_tables"] == []
# 真落行themes/archetypes/structure/hooks 各拼成可读规则条目。
assert len(rule_repo.rows) >= 1
contents = "\n".join(r["content"] for r in rule_repo.rows)
assert "逆袭" in contents
assert "废柴主角" in contents
assert "黄金三章立钩" in contents
assert "开局即巅峰" in contents
# 全部落到 project 级 rulesowner stub 单用户)。
for r in rule_repo.rows:
assert r["level"] == "project"
assert r["project_id"] == pid
assert session.commits == 1
@pytest.mark.asyncio
async def test_teardown_generate_preview_does_not_write_rules() -> None:
# 负向预览generate只产结构化产物绝不写 rules不变量 #3入库经 ingest gate
repo = FakeProjectRepo()
pid = await _seed_project(repo)
rule_repo = _FakeRuleWriteRepo()
parsed = BookTeardownResult(
themes=["逆袭"], archetypes=["废柴主角"], structure="立钩", hooks=["开局巅峰"]
)
gateway = _CaptureGateway(parsed)
app, _, *_ = _make_app(project_repo=repo, gateway=gateway, rule_write_repo=rule_repo)
async with _client(app) as client:
resp = await client.post(
f"/projects/{pid}/skills/teardown/generate",
json={"kind": "某爆款", "text": "样本章节正文……"},
)
assert resp.status_code == 200
assert resp.json()["output_kind"] == "BookTeardownResult"
assert rule_repo.rows == [] # 预览不写库

View File

@@ -27,6 +27,7 @@ from ww_api.routers import (
rules,
settings_providers,
style,
templates,
toolbox,
)
from ww_api.security.credentials import _fernet
@@ -115,6 +116,7 @@ def create_app() -> FastAPI:
app.include_router(outline.router)
app.include_router(rules.router)
app.include_router(style.router)
app.include_router(templates.router)
app.include_router(generation.router)
app.include_router(generation.skills_router)
app.include_router(toolbox.router)

View File

@@ -59,8 +59,9 @@ log = get_logger("ww.api.chain")
router = APIRouter(prefix="/projects", tags=["chain"])
# 本期唯一内置链§1.3/§3。未知 key → 404系统边界fail fast
SUPPORTED_CHAINS = frozenset({"draft_volume"})
# 内置链§1.3/§3draft_volume=从 start 章按记忆量产continue_volume=每章以上一章已验收
# 正文末尾作前文引子续写F2。未知 key → 404系统边界fail fast
SUPPORTED_CHAINS = frozenset({"draft_volume", "continue_volume"})
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
@@ -214,7 +215,9 @@ async def resume_chain(
f"job {job_id} 已被并发续跑抢占或非 awaiting_input 态,不可重复续跑",
)
chain_key = claimed.kind if claimed.kind in SUPPORTED_CHAINS else "draft_volume"
# chain_key 真值run 时 interrupt 把图 state 的 chain_key 持久进 job.result_chain_result
# 读回它,而非按 job.kind 推断kind 恒为通用常量 JOB_KIND_CHAIN从不在 SUPPORTED_CHAINS
chain_key = _chain_key_from_result(claimed.result)
accept_op = build_accept_op(
session_factory=session_factory,
@@ -250,6 +253,17 @@ async def resume_chain(
return ChainResumeAccepted(job_id=job_id, chain_key=chain_key)
def _chain_key_from_result(result: dict[str, Any] | None) -> str:
"""从 awaiting job 的 result 取真 chain_keyrun 时由 _chain_result 持久化)。
缺失/非受支持 key旧行/异常)→ 回退 "draft_volume"(保守默认,不崩)。
"""
key = (result or {}).get("chain_key")
if isinstance(key, str) and key in SUPPORTED_CHAINS:
return key
return "draft_volume"
def _chapter_repo_factory(session: AsyncSession) -> object:
"""链节点review 重读草稿)按 session 建 chapter draft repo。"""
from ww_core.domain.chapter_repo import SqlChapterRepo

View File

@@ -0,0 +1,90 @@
"""模板库端点F3 / 契约 §F3
单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。
**不做分享/市场**需多租户。owner_id 全程 stub单用户原型
- GET /templates 列出当前用户的模板。
- POST /templates 新建模板201title/body 空 → 422
- DELETE /templates/:id 删除模板204不存在 → 404
提交边界:`TemplateRepo.create`/`delete` 只 `flush()`,端点写后 `await session.commit()`
(仿 rules/foreshadow 写侧,见 memory/gotchas
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy.ext.asyncio import AsyncSession
from ww_core.domain import TemplateCreate, TemplateRepo
from ww_db import get_session
from ww_shared import AppError, ErrorCode
from ww_api.logging_config import get_logger
from ww_api.schemas.templates import TemplateCreateRequest, TemplateResponse
from ww_api.services.credentials import STUB_OWNER_ID
from ww_api.services.project_deps import get_template_repo
log = get_logger("ww.api.templates")
router = APIRouter(prefix="/templates", tags=["templates"])
TemplateRepoDep = Annotated[TemplateRepo, Depends(get_template_repo)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@router.get("")
async def list_templates(repo: TemplateRepoDep) -> list[TemplateResponse]:
"""列出当前用户stub的模板。"""
views = await repo.list_for_owner(STUB_OWNER_ID)
return [
TemplateResponse(
id=v.id, title=v.title, body=v.body, category=v.category, tool_key=v.tool_key
)
for v in views
]
@router.post("", status_code=201)
async def create_template(
body: TemplateCreateRequest,
request: Request,
repo: TemplateRepoDep,
session: SessionDep,
) -> TemplateResponse:
"""新建模板201。title/body 空 → FastAPI 422schema `min_length=1`)。"""
request_id = getattr(request.state, "request_id", None)
view = await repo.create(
STUB_OWNER_ID,
TemplateCreate(
title=body.title, body=body.body, category=body.category, tool_key=body.tool_key
),
)
await session.commit()
log.info("template_created", template_id=str(view.id), request_id=request_id)
return TemplateResponse(
id=view.id,
title=view.title,
body=view.body,
category=view.category,
tool_key=view.tool_key,
)
@router.delete("/{template_id}", status_code=204)
async def delete_template(
template_id: uuid.UUID,
request: Request,
repo: TemplateRepoDep,
session: SessionDep,
) -> Response:
"""删除模板204。不存在 → 404 NOT_FOUND。"""
request_id = getattr(request.state, "request_id", None)
deleted = await repo.delete(STUB_OWNER_ID, template_id)
if not deleted:
raise AppError(ErrorCode.NOT_FOUND, f"template not found: {template_id}")
await session.commit()
log.info("template_deleted", template_id=str(template_id), request_id=request_id)
return Response(status_code=204)

View File

@@ -24,6 +24,7 @@ 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
from ww_core.domain.rule_repo import RuleWriteRepo
from ww_core.domain.world_entity_repo import WorldEntityWriteRepo
from ww_core.orchestrator import run_generator
from ww_db import get_session
@@ -37,6 +38,7 @@ from ww_api.routers.generation import _project_context, _world_context
from ww_api.schemas.generation import IngestConflictView, WorldEntityCardView
from ww_api.schemas.toolbox import (
OutlineSceneIngestView,
TeardownIngestView,
ToolboxListResponse,
ToolDescriptorView,
ToolGeneratePreviewResponse,
@@ -53,6 +55,7 @@ from ww_api.services.project_deps import (
get_outline_read_repo,
get_outline_write_repo,
get_project_repo,
get_rule_write_repo,
get_tier_gateway_builder,
get_world_entity_write_repo,
)
@@ -79,6 +82,7 @@ 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)]
RuleWriteRepoDep = Annotated[RuleWriteRepo, Depends(get_rule_write_repo)]
GatewayBuilderDep = Annotated[TierGatewayBuilder, Depends(get_tier_gateway_builder)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@@ -316,6 +320,7 @@ async def ingest_with_tool(
memory: MemoryReposDep,
world_write_repo: WorldWriteRepoDep,
outline_write_repo: OutlineWriteRepoDep,
rule_write_repo: RuleWriteRepoDep,
build_gateway: GatewayBuilderDep,
session: SessionDep,
) -> ToolIngestResponse:
@@ -362,6 +367,16 @@ async def ingest_with_tool(
request_id=request_id,
tool_key=tool_key,
)
if table == "rules":
return await _ingest_rules(
spec,
body=body,
project_id=project_id,
rule_write_repo=rule_write_repo,
session=session,
request_id=request_id,
tool_key=tool_key,
)
# 描述符的 IngestSpec.table 已限白名单;其余表本期通用入库未实现。
raise AppError(ErrorCode.VALIDATION, f"入库表 {table} 暂未支持通用入库", {"table": table})
@@ -493,3 +508,72 @@ async def _ingest_outline(
created_count=len(created),
)
return ToolIngestResponse(table="outline", created=created, rejected_tables=rejected)
# 拆书入库的 rules 级别meta 规则非设定卡,落项目级;单用户原型 owner stub
_TEARDOWN_RULE_LEVEL = "project"
def _flatten_teardown_rules(teardown: TeardownIngestView) -> list[tuple[str, str]]:
"""把拆书结论拍平成可读 rules 条目:返回 (label, content) 列表(确定性顺序,跳过空字段)。
主题/原型/钩子各一条(清单拼成可读文本);结构一条。空清单/空串的字段跳过,不落空规则。
"""
entries: list[tuple[str, str]] = []
if teardown.themes:
entries.append(("themes", f"核心主题:{''.join(teardown.themes)}"))
if teardown.archetypes:
entries.append(("archetypes", f"人物原型:{''.join(teardown.archetypes)}"))
if teardown.structure.strip():
entries.append(("structure", f"叙事结构:{teardown.structure.strip()}"))
if teardown.hooks:
entries.append(("hooks", f"钩子套路:{''.join(teardown.hooks)}"))
return entries
async def _ingest_rules(
spec: AgentSpec,
*,
body: ToolIngestRequest,
project_id: uuid.UUID,
rule_write_repo: RuleWriteRepo,
session: AsyncSession,
request_id: str | None,
tool_key: str,
) -> ToolIngestResponse:
"""rules 入库(拆书):白名单 → 把拆书结论拍平为可读 rules 条目写入项目级 rules。
meta 规则非设定卡,无需 continuity 预检(仿 outline 无 409 路径);仍过 partition_writes
白名单teardown spec 声明 writes=["rules"])。无 teardown 产物 → VALIDATION。
"""
if body.teardown is None:
raise AppError(ErrorCode.VALIDATION, "拆书入库需提供 teardown 产物", {"tool_key": tool_key})
entries = _flatten_teardown_rules(body.teardown)
# gate权限白名单teardown spec 声明 writes=["rules"])。
allowed, rejected = partition_writes(spec, {"rules": entries})
if rejected:
log.warning(
"toolbox_ingest_rejected_over_permission",
tool_key=tool_key,
project_id=str(project_id),
request_id=request_id,
rejected_tables=rejected,
)
created: list[str] = []
for label, content in allowed.get("rules", []):
await rule_write_repo.create(project_id, level=_TEARDOWN_RULE_LEVEL, content=content)
created.append(label)
await session.commit()
log.info(
"toolbox_ingest_done",
tool_key=tool_key,
project_id=str(project_id),
request_id=request_id,
table="rules",
created_count=len(created),
)
return ToolIngestResponse(table="rules", created=created, rejected_tables=rejected)

View File

@@ -0,0 +1,35 @@
"""模板库端点的请求/响应 schemaF3 / 契约 §F3
snake_case前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。
`title`/`body` 非空(先 strip 再 `min_length=1`,纯空白 → 422。`category`/`tool_key` 可选。
"""
from __future__ import annotations
import uuid
from typing import Annotated
from pydantic import BaseModel, Field, StringConstraints
# 先去首尾空白再校验长度:纯空白(" "strip 后为空 → min_length=1 不满足 → 422。
NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
class TemplateCreateRequest(BaseModel):
"""POST /templates新建一条提示词模板。"""
title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422")
body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text")
category: str | None = Field(default=None, description="可选分类")
tool_key: str | None = Field(default=None, description="可选关联生成器NULL=通用)")
class TemplateResponse(BaseModel):
"""模板视图(列出/创建后回显snake_case"""
id: uuid.UUID
title: str
body: str
category: str | None = None
tool_key: str | None = None

View File

@@ -105,12 +105,22 @@ class OutlineSceneIngestView(BaseModel):
hook: str = ""
class TeardownIngestView(BaseModel):
"""拆书入库产物(贴 ww_agents.BookTeardownResult落 rules 表。F1。"""
themes: list[str] = Field(default_factory=list, description="核心主题/立意清单")
archetypes: list[str] = Field(default_factory=list, description="人物原型/角色模板清单")
structure: str = Field(default="", description="叙事结构概述")
hooks: list[str] = Field(default_factory=list, description="抓人钩子/爽点套路清单")
class ToolIngestRequest(BaseModel):
"""通用入库请求:待持久化的产物 + 冲突确认。
仅声明了 `ingest` 的工具可用。按入库表取用对应字段:
- world_entities金手指/词条)→ `world_entities`(贴 WorldEntityCardViewtype/name/rules
- outline细纲→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView
- outline细纲→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView
- rules拆书→ `teardown`(贴 TeardownIngestViewthemes/archetypes/structure/hooks
`acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate
"""
@@ -123,6 +133,9 @@ class ToolIngestRequest(BaseModel):
scenes: list[OutlineSceneIngestView] = Field(
default_factory=list, description="outline 入库的场景行(细纲)"
)
teardown: TeardownIngestView | None = Field(
default=None, description="rules 入库的拆书结论F1拍平为可读规则条目"
)
acknowledge_conflicts: bool = Field(
default=False, description="作者已知悉并接受 continuity 冲突 → 放行入库"
)

View File

@@ -29,6 +29,7 @@ from ww_core.domain.repositories import MemoryRepos, OutlineRepo, RulesRepo
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
from ww_core.domain.rule_repo import RuleWriteRepo, SqlRuleWriteRepo
from ww_core.domain.style_repo import SqlStyleFingerprintWriteRepo, StyleFingerprintWriteRepo
from ww_core.domain.template_repo import SqlTemplateRepo, TemplateRepo
from ww_core.domain.world_entity_repo import SqlWorldEntityWriteRepo, WorldEntityWriteRepo
from ww_core.memory.sql_repositories import SqlOutlineRepo, SqlRulesRepo, sql_memory_repos
from ww_db import get_session, get_sessionmaker
@@ -159,6 +160,16 @@ def get_rule_write_repo(
return SqlRuleWriteRepo(session)
async def get_template_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> TemplateRepo:
"""模板库 repoGET/POST/DELETE /templatescreate/delete 只 flush端点提交
测试经 `app.dependency_overrides` 注入内存 fake。
"""
return SqlTemplateRepo(session)
async def get_skill_registry(
session: Annotated[AsyncSession, Depends(get_session)],
) -> SkillRegistry:

View File

@@ -0,0 +1,32 @@
import { AppShell } from "@/components/AppShell";
import { TemplatesManager } from "@/components/templates/TemplatesManager";
import { fetchTemplates } from "@/lib/api/server";
import type { TemplateResponse } from "@/lib/api/types";
// 提示词/模板库F3全局单用户本地版。Server Component 预取列表CRUD 在客户端组件。
export default async function TemplatesPage() {
let initial: TemplateResponse[] = [];
let loadError = false;
try {
initial = await fetchTemplates();
} catch {
loadError = true;
}
return (
<AppShell title="提示词 / 模板库">
<div className="mx-auto max-w-3xl px-8 py-10">
<p className="mb-6 text-xs text-ink-soft">
brief /
</p>
{loadError ? (
<p className="rounded border border-conflict bg-panel p-6 text-conflict">
API
</p>
) : (
<TemplatesManager initial={initial} />
)}
</div>
</AppShell>
);
}

View File

@@ -5,7 +5,7 @@ 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 { chainPhase, chainResultView, type ChainKind } from "@/lib/chain/chain";
import { friendlyError } from "@/lib/errors/messages";
import { useJobPoll } from "@/lib/jobs/useJobPoll";
import type { ProjectResponse } from "@/lib/api/types";
@@ -17,8 +17,6 @@ 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
@@ -43,14 +41,18 @@ export function ChainPage({ project }: ChainPageProps) {
}, [phase, poll]);
const onStart = useCallback(
async (startChapterNo: number, count: number): Promise<void> => {
async (
chainKey: ChainKind,
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 },
path: { project_id: project.id, chain_key: chainKey },
},
body: { start_chapter_no: startChapterNo, count },
},
@@ -89,7 +91,10 @@ export function ChainPage({ project }: ChainPageProps) {
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} />
<ChainStarter
onStart={(k, s, c) => void onStart(k, s, c)}
disabled={busy}
/>
{showProgress && result ? (
<ChainProgress phase={phase} progress={poll.progress} result={result} />

View File

@@ -2,9 +2,11 @@
import { useState } from "react";
import { CHAIN_KINDS, type ChainKind } from "@/lib/chain/chain";
interface ChainStarterProps {
// 发起一条链(起始章 + 章数);父层负责 POST run + 轮询。
onStart: (startChapterNo: number, count: number) => void;
// 发起一条链(链类型 + 起始章 + 章数);父层负责 POST run + 轮询。
onStart: (chainKey: ChainKind, startChapterNo: number, count: number) => void;
// 链正在运行/等待裁决时禁用(避免重复发起)。
disabled: boolean;
}
@@ -12,10 +14,12 @@ interface ChainStarterProps {
const DEFAULT_START = 1;
const DEFAULT_COUNT = 3;
const MAX_COUNT = 50;
const DEFAULT_CHAIN: ChainKind = "draft_volume";
// 链发起表单(净新):选起始章号 + 连续写几章 → 调 onStart。
// count 1..50(对齐后端 ChainRunRequest Field 约束;前端先拦一道,越界后端 422 兜底)。
export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
const [chainKey, setChainKey] = useState<ChainKind>(DEFAULT_CHAIN);
const [start, setStart] = useState(String(DEFAULT_START));
const [count, setCount] = useState(String(DEFAULT_COUNT));
@@ -34,7 +38,7 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
aria-label="发起多章链"
onSubmit={(e) => {
e.preventDefault();
if (valid && !disabled) onStart(startNo, countNo);
if (valid && !disabled) onStart(chainKey, startNo, countNo);
}}
>
<div>
@@ -43,6 +47,33 @@ export function ChainStarter({ onStart, disabled }: ChainStarterProps) {
</p>
</div>
<fieldset className="flex flex-col gap-2">
<legend className="text-sm text-ink-soft"></legend>
<div className="flex flex-wrap gap-4">
{CHAIN_KINDS.map((kind) => (
<label
key={kind.key}
className="flex items-start gap-2 text-sm text-ink"
>
<input
type="radio"
name="chain-kind"
value={kind.key}
checked={chainKey === kind.key}
onChange={() => setChainKey(kind.key)}
className="mt-1"
aria-label={kind.label}
/>
<span>
<span className="block">{kind.label}</span>
<span className="block text-xs text-ink-soft">
{kind.description}
</span>
</span>
</label>
))}
</div>
</fieldset>
<div className="flex flex-wrap gap-4">
<label className="block text-sm text-ink-soft">

View File

@@ -0,0 +1,181 @@
"use client";
import { useCallback, useState } from "react";
import { useToast } from "@/components/Toast";
import { api } from "@/lib/api/client";
import type { TemplateResponse } from "@/lib/api/types";
import {
buildTemplateCreateRequest,
EMPTY_TEMPLATE_DRAFT,
isTemplateDraftValid,
type TemplateDraft,
} from "@/lib/templates/templates";
interface TemplatesManagerProps {
// 初始模板列表Server Component 预取;失败给空数组 + loadError 文案)。
initial: TemplateResponse[];
}
// 提示词/模板库管理F3单用户本地版列出 / 新建 / 删除。
// 列表本地维护乐观更新失败回滚CRUD 走 OpenAPI 客户端。分享/市场不做(需多租户)。
export function TemplatesManager({ initial }: TemplatesManagerProps) {
const toast = useToast();
const [templates, setTemplates] = useState<TemplateResponse[]>(initial);
const [draft, setDraft] = useState<TemplateDraft>(EMPTY_TEMPLATE_DRAFT);
const [creating, setCreating] = useState(false);
const setField = useCallback(
(field: keyof TemplateDraft, value: string): void => {
setDraft((prev) => ({ ...prev, [field]: value }));
},
[],
);
const onCreate = useCallback(async (): Promise<void> => {
if (!isTemplateDraftValid(draft)) {
toast("请填写标题与正文。", "error");
return;
}
setCreating(true);
try {
const { data, error } = await api.POST("/templates", {
body: buildTemplateCreateRequest(draft),
});
if (error || !data) {
toast("新建模板失败,请重试。", "error");
return;
}
setTemplates((prev) => [data, ...prev]);
setDraft(EMPTY_TEMPLATE_DRAFT);
toast("已新建模板。", "success");
} catch {
toast("新建模板请求异常,请检查网络。", "error");
} finally {
setCreating(false);
}
}, [draft, toast]);
const onDelete = useCallback(
async (id: string): Promise<void> => {
const prev = templates;
// 乐观删除:先移除,失败回滚。
setTemplates((cur) => cur.filter((t) => t.id !== id));
try {
const { error } = await api.DELETE("/templates/{template_id}", {
params: { path: { template_id: id } },
});
if (error) {
setTemplates(prev);
toast("删除模板失败,请重试。", "error");
return;
}
toast("已删除模板。", "success");
} catch {
setTemplates(prev);
toast("删除模板请求异常,请检查网络。", "error");
}
},
[templates, toast],
);
return (
<div className="flex flex-col gap-6">
<form
className="flex flex-col gap-3 rounded border border-line bg-panel p-5"
aria-label="新建模板"
onSubmit={(e) => {
e.preventDefault();
void onCreate();
}}
>
<h2 className="font-serif text-lg text-ink"></h2>
<label className="block text-sm text-ink-soft">
*
<input
type="text"
value={draft.title}
onChange={(e) => setField("title", e.target.value)}
className="mt-1 w-full 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">
* brief/
<textarea
value={draft.body}
onChange={(e) => setField("body", e.target.value)}
rows={4}
className="mt-1 w-full resize-y rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="正文"
/>
</label>
<div className="flex flex-wrap gap-4">
<label className="block text-sm text-ink-soft">
<input
type="text"
value={draft.category}
onChange={(e) => setField("category", e.target.value)}
className="mt-1 w-48 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">
key
<input
type="text"
value={draft.toolKey}
onChange={(e) => setField("toolKey", e.target.value)}
className="mt-1 w-48 rounded border border-line bg-bg px-3 py-2 text-sm text-ink"
aria-label="关联生成器 key"
/>
</label>
</div>
<button
type="submit"
disabled={creating || !isTemplateDraftValid(draft)}
className="self-start rounded bg-cinnabar px-4 py-2 text-sm text-white disabled:opacity-50"
>
{creating ? "新建中…" : "新建模板"}
</button>
</form>
<section className="flex flex-col gap-3" aria-label="模板列表">
<h2 className="font-serif text-lg text-ink">{templates.length}</h2>
{templates.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
) : (
<ul className="flex flex-col gap-2">
{templates.map((t) => (
<li
key={t.id}
className="flex items-start justify-between gap-4 rounded border border-line bg-bg p-3 text-sm"
>
<div className="min-w-0 flex-1">
<p className="font-serif text-base text-ink">{t.title}</p>
{t.category || t.tool_key ? (
<p className="text-xs text-ink-soft">
{[t.category, t.tool_key].filter(Boolean).join(" · ")}
</p>
) : null}
<p className="mt-1 whitespace-pre-wrap text-ink-soft">
{t.body}
</p>
</div>
<button
type="button"
onClick={() => void onDelete(t.id)}
className="shrink-0 rounded border border-line px-3 py-1 text-ink-soft hover:text-conflict"
aria-label={`删除模板 ${t.title}`}
>
</button>
</li>
))}
</ul>
)}
</section>
</div>
);
}

View File

@@ -10,11 +10,13 @@ import {
initialFieldValues,
missingRequiredFields,
previewRows,
templateFillTarget,
type FieldValues,
type PreviewItem,
} from "@/lib/toolbox/toolbox";
import { ingestTable } from "@/lib/toolbox/ingest";
import { ingestTable, isSingleObjectIngest } from "@/lib/toolbox/ingest";
import { useGenerator } from "@/lib/toolbox/useGenerator";
import { TemplateFiller } from "./TemplateFiller";
interface GeneratorRunnerProps {
projectId: string;
@@ -42,6 +44,12 @@ export function GeneratorRunner({
setValues((prev) => ({ ...prev, [name]: value }));
}, []);
// 「从模板填入」的目标字段brief / text无则不渲染
const fillTarget = useMemo(
() => templateFillTarget(tool.input_fields),
[tool.input_fields],
);
const onGenerate = useCallback(async (): Promise<void> => {
const missing = missingRequiredFields(tool.input_fields, values);
if (missing.length > 0) {
@@ -58,7 +66,15 @@ export function GeneratorRunner({
);
const table = gen.outputKind ? ingestTable(gen.outputKind) : null;
const canIngest = tool.ingestable && table !== null && rows.length > 0;
// 单对象产物(如拆书):整个 preview 即一条入库项,无逐行勾选;行式产物按勾选入库。
const singleObject = gen.outputKind
? isSingleObjectIngest(gen.outputKind)
: false;
const hasPreview = (gen.preview?.items.length ?? 0) > 0;
const canIngest =
tool.ingestable &&
table !== null &&
(singleObject ? hasPreview : rows.length > 0);
const toggle = useCallback((idx: number): void => {
setSelected((prev) => {
@@ -81,14 +97,17 @@ export function GeneratorRunner({
async (acknowledge: boolean): Promise<void> => {
if (!gen.outputKind) return;
const chapterNo = Number(values["chapter_no"]?.trim() || "") || null;
// 单对象产物(拆书):整个 rawPreview 作唯一入库行;行式产物用勾选行。
const ingestRows =
singleObject && gen.rawPreview ? [gen.rawPreview] : selectedRows;
await gen.ingest(projectId, tool.key, {
outputKind: gen.outputKind,
rows: selectedRows,
rows: ingestRows,
chapterNo,
acknowledgeConflicts: acknowledge,
});
},
[gen, projectId, tool.key, selectedRows, values],
[gen, projectId, tool.key, selectedRows, values, singleObject],
);
const generating = gen.genStatus === "generating";
@@ -120,6 +139,7 @@ export function GeneratorRunner({
void onGenerate();
}}
>
<TemplateFiller targetField={fillTarget} onFill={setField} />
{(tool.input_fields ?? []).map((field) => (
<FormField
key={field.name}
@@ -141,7 +161,11 @@ export function GeneratorRunner({
<div className="flex flex-col gap-3">
<h3 className="font-serif text-sm text-ink">
{gen.preview.items.length}
{canIngest ? "(勾选后可入库)" : null}
{canIngest
? singleObject
? "(可入库)"
: "(勾选后可入库)"
: null}
</h3>
<ul className="flex flex-col gap-2">
{gen.preview.items.map((item, i) => (
@@ -149,7 +173,7 @@ export function GeneratorRunner({
key={i}
item={item}
index={i}
selectable={canIngest}
selectable={canIngest && !singleObject}
checked={selected.has(i)}
onToggle={toggle}
/>
@@ -160,10 +184,14 @@ export function GeneratorRunner({
<button
type="button"
onClick={() => void runIngest(false)}
disabled={ingesting || selected.size === 0}
disabled={ingesting || (!singleObject && selected.size === 0)}
className="self-start rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar disabled:opacity-50"
>
{ingesting ? "入库中…" : `入库选中(${selected.size})至 ${table}`}
{ingesting
? "入库中…"
: singleObject
? `入库为规则至 ${table}`
: `入库选中(${selected.size})至 ${table}`}
</button>
) : null}

View File

@@ -0,0 +1,102 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useToast } from "@/components/Toast";
import { api } from "@/lib/api/client";
import type { TemplateResponse } from "@/lib/api/types";
interface TemplateFillerProps {
// 可填入的目标输入字段名(如 brief / text空 → 不渲染(无可填字段)。
targetField: string | null;
// 选定模板 → 把其 body 填进目标字段(纯前端,不改生成器后端)。
onFill: (field: string, body: string) => void;
}
// 「从模板填入」F3纯前端进场懒加载模板库选一个把 body 填进生成器的 brief/原文输入。
// 与生成器后端解耦——只读 /templates 并回写本地表单值。无模板/无目标字段则不渲染。
export function TemplateFiller({ targetField, onFill }: TemplateFillerProps) {
const toast = useToast();
const [templates, setTemplates] = useState<TemplateResponse[] | null>(null);
const [open, setOpen] = useState(false);
const load = useCallback(async (): Promise<void> => {
try {
const { data, error } = await api.GET("/templates", {});
if (error || !data) {
toast("加载模板失败。", "error");
setTemplates([]);
return;
}
setTemplates(data);
} catch {
toast("加载模板请求异常,请检查网络。", "error");
setTemplates([]);
}
}, [toast]);
useEffect(() => {
if (open && templates === null) void load();
}, [open, templates, load]);
if (!targetField) return null;
const field = targetField;
if (!open) {
return (
<button
type="button"
onClick={() => setOpen(true)}
className="self-start rounded border border-line px-3 py-1 text-sm text-ink-soft hover:text-ink"
>
</button>
);
}
return (
<div
className="flex flex-col gap-2 rounded border border-line bg-bg p-3"
aria-label="从模板填入"
>
<div className="flex items-center justify-between">
<span className="text-sm text-ink-soft">{field}</span>
<button
type="button"
onClick={() => setOpen(false)}
className="text-xs text-ink-soft hover:text-ink"
>
</button>
</div>
{templates === null ? (
<p className="text-sm text-ink-soft"></p>
) : templates.length === 0 ? (
<p className="text-sm text-ink-soft"></p>
) : (
<ul className="flex flex-col gap-1">
{templates.map((t) => (
<li key={t.id}>
<button
type="button"
onClick={() => {
onFill(field, t.body);
setOpen(false);
toast(`已填入模板「${t.title}」。`, "success");
}}
className="w-full rounded border border-line px-3 py-2 text-left text-sm text-ink hover:border-cinnabar"
>
<span className="font-serif">{t.title}</span>
{t.tool_key ? (
<span className="ml-2 text-xs text-ink-soft">
{t.tool_key}
</span>
) : null}
</button>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -377,6 +377,50 @@ export interface paths {
patch?: never;
trace?: never;
};
"/templates": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Templates
* @description 列出当前用户stub的模板。
*/
get: operations["list_templates_templates_get"];
put?: never;
/**
* Create Template
* @description 新建模板201。title/body 空 → FastAPI 422schema `min_length=1`)。
*/
post: operations["create_template_templates_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/templates/{template_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/**
* Delete Template
* @description 删除模板204。不存在 → 404 NOT_FOUND。
*/
delete: operations["delete_template_templates__template_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{project_id}/world/generate": {
parameters: {
query?: never;
@@ -1719,6 +1763,78 @@ export interface components {
*/
job_id: string;
};
/**
* TeardownIngestView
* @description 拆书入库产物(贴 ww_agents.BookTeardownResult落 rules 表。F1。
*/
TeardownIngestView: {
/**
* Themes
* @description 核心主题/立意清单
*/
themes?: string[];
/**
* Archetypes
* @description 人物原型/角色模板清单
*/
archetypes?: string[];
/**
* Structure
* @description 叙事结构概述
* @default
*/
structure: string;
/**
* Hooks
* @description 抓人钩子/爽点套路清单
*/
hooks?: string[];
};
/**
* TemplateCreateRequest
* @description POST /templates新建一条提示词模板。
*/
TemplateCreateRequest: {
/**
* Title
* @description 模板标题(非空,纯空白 → 422
*/
title: string;
/**
* Body
* @description 模板正文(非空,一键填入生成器的 brief/text
*/
body: string;
/**
* Category
* @description 可选分类
*/
category?: string | null;
/**
* Tool Key
* @description 可选关联生成器NULL=通用)
*/
tool_key?: string | null;
};
/**
* TemplateResponse
* @description 模板视图(列出/创建后回显snake_case
*/
TemplateResponse: {
/**
* Id
* Format: uuid
*/
id: string;
/** Title */
title: string;
/** Body */
body: string;
/** Category */
category?: string | null;
/** Tool Key */
tool_key?: string | null;
};
/**
* TestConnectionRequest
* @description POST /test最小探测请求。
@@ -1861,7 +1977,8 @@ export interface components {
*
* 仅声明了 `ingest` 的工具可用。按入库表取用对应字段:
* - world_entities金手指/词条)→ `world_entities`(贴 WorldEntityCardViewtype/name/rules
* - outline细纲→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView
* - outline细纲→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView
* - rules拆书→ `teardown`(贴 TeardownIngestViewthemes/archetypes/structure/hooks
* `acknowledge_conflicts`:作者已查看并接受 continuity 预检冲突时置 true 放行(仿 accept gate
*/
ToolIngestRequest: {
@@ -1880,6 +1997,8 @@ export interface components {
* @description outline 入库的场景行(细纲)
*/
scenes?: components["schemas"]["OutlineSceneIngestView"][];
/** @description rules 入库的拆书结论F1拍平为可读规则条目 */
teardown?: components["schemas"]["TeardownIngestView"] | null;
/**
* Acknowledge Conflicts
* @description 作者已知悉并接受 continuity 冲突 → 放行入库
@@ -2866,6 +2985,88 @@ export interface operations {
};
};
};
list_templates_templates_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TemplateResponse"][];
};
};
};
};
create_template_templates_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TemplateCreateRequest"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TemplateResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_template_templates__template_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
template_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
generate_world_projects__project_id__world_generate_post: {
parameters: {
query?: never;

View File

@@ -13,6 +13,7 @@ import type {
RuleListResponse,
SkillListResponse,
StyleFingerprintResponse,
TemplateResponse,
ToolboxListResponse,
WorldEntityListResponse,
} from "./types";
@@ -149,6 +150,16 @@ export async function fetchOutline(
}
}
// 提示词/模板库列表GET /templates全局只读裸数组
// 任何错误(含后端未上线)降级为空列表,模板页照常渲染(进页不阻塞)。
export async function fetchTemplates(): Promise<TemplateResponse[]> {
try {
return await getJson<TemplateResponse[]>("/templates");
} catch {
return [];
}
}
// 已存草稿GET .../draft含正文供工作台重访重载编辑器
// 404尚无草稿新章→ null由调用方当空编辑器处理不抛错。
export async function fetchDraft(

View File

@@ -85,6 +85,11 @@ export type CharacterListResponse =
export type WorldEntityListResponse =
components["schemas"]["WorldEntityListResponse"];
// F3 提示词/模板库单用户本地版GET/POST/DELETE /templates
export type TemplateResponse = components["schemas"]["TemplateResponse"];
export type TemplateCreateRequest =
components["schemas"]["TemplateCreateRequest"];
// K1 Kimi Code OAuth device-flowC3 扩 K1.3)。
export type OAuthStartResponse = components["schemas"]["OAuthStartResponse"];
export type OAuthDisconnectResponse =
@@ -113,3 +118,6 @@ export type ToolIngestRequest = components["schemas"]["ToolIngestRequest"];
export type ToolIngestResponse = components["schemas"]["ToolIngestResponse"];
export type OutlineSceneIngestView =
components["schemas"]["OutlineSceneIngestView"];
// F1拆书入库产物themes/archetypes/structure/hooks → 项目 rules 条目)。
export type TeardownIngestView =
components["schemas"]["TeardownIngestView"];

View File

@@ -4,6 +4,7 @@ import { emptyDecisions, setNote, setVerdict } from "@/lib/review/decisions";
import type { JobView } from "@/lib/jobs/job";
import {
buildResumeRequest,
CHAIN_KINDS,
chainPhase,
chainResultView,
} from "./chain";
@@ -110,3 +111,19 @@ describe("buildResumeRequest", () => {
expect(buildResumeRequest([])).toEqual({ decisions: [] });
});
});
describe("CHAIN_KINDS", () => {
it("exposes both built-in chain kinds aligned with backend SUPPORTED_CHAINS", () => {
expect(CHAIN_KINDS.map((k) => k.key)).toEqual([
"draft_volume",
"continue_volume",
]);
});
it("gives each kind a non-empty label and description", () => {
for (const kind of CHAIN_KINDS) {
expect(kind.label.length).toBeGreaterThan(0);
expect(kind.description.length).toBeGreaterThan(0);
}
});
});

View File

@@ -6,6 +6,29 @@ import type { ChainResumeRequest } from "@/lib/api/types";
import type { JobView, PollStatus } from "@/lib/jobs/job";
import { buildAcceptRequest, type DecisionDraft } from "@/lib/review/decisions";
// 内置链类型(对齐后端 SUPPORTED_CHAINSdraft_volume=从 start 章按记忆量产;
// continue_volume=续写式(每章以上一章 accepted 正文末尾作前文引子,比纯 digest 更顺)。
export type ChainKind = "draft_volume" | "continue_volume";
export interface ChainKindOption {
key: ChainKind;
label: string;
description: string;
}
export const CHAIN_KINDS: readonly ChainKindOption[] = [
{
key: "draft_volume",
label: "从头写",
description: "按记忆与大纲量产,每章独立写。",
},
{
key: "continue_volume",
label: "续写",
description: "每章以上一章已验收正文末尾作前文引子,承接更顺。",
},
];
// 链 job result 的视图模型(与后端 `_chain_result` 字段对齐chain_key/written/completed/
// awaiting_chapter。token/正文绝不入 result不变量 #5 / chain §5故此处只有进度元数据。
export interface ChainResultView {

View File

@@ -22,6 +22,7 @@ export interface NavItem {
export const GLOBAL_NAV_ITEMS: readonly NavItem[] = [
{ href: "/", label: "作品库" },
{ href: "/templates", label: "模板库" },
{ href: "/settings/providers", label: "设置" },
];

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
buildTemplateCreateRequest,
EMPTY_TEMPLATE_DRAFT,
isTemplateDraftValid,
type TemplateDraft,
} from "./templates";
const draft = (over: Partial<TemplateDraft>): TemplateDraft => ({
...EMPTY_TEMPLATE_DRAFT,
...over,
});
describe("isTemplateDraftValid", () => {
it("returns true when title and body are both non-empty", () => {
expect(isTemplateDraftValid(draft({ title: "标题", body: "正文" }))).toBe(
true,
);
});
it("returns false when title is blank", () => {
expect(isTemplateDraftValid(draft({ title: " ", body: "正文" }))).toBe(
false,
);
});
it("returns false when body is blank", () => {
expect(isTemplateDraftValid(draft({ title: "标题", body: "" }))).toBe(false);
});
it("returns false for the empty draft", () => {
expect(isTemplateDraftValid(EMPTY_TEMPLATE_DRAFT)).toBe(false);
});
});
describe("buildTemplateCreateRequest", () => {
it("trims title and body and includes optional fields", () => {
const body = buildTemplateCreateRequest(
draft({
title: " 脑洞模板 ",
body: " 写一个穿越故事 ",
category: " 脑洞 ",
toolKey: " idea ",
}),
);
expect(body).toEqual({
title: "脑洞模板",
body: "写一个穿越故事",
category: "脑洞",
tool_key: "idea",
});
});
it("nulls out blank category and tool_key", () => {
const body = buildTemplateCreateRequest(
draft({ title: "t", body: "b", category: " ", toolKey: "" }),
);
expect(body.category).toBeNull();
expect(body.tool_key).toBeNull();
});
});

View File

@@ -0,0 +1,39 @@
// 提示词/模板库前端纯逻辑F3新建表单值校验 + 请求体组装。
// 纯逻辑(无 React / 无 fetch便于 node 环境单测。后端 title/body 非空校验为权威422 兜底),
// 前端先拦一道给即时反馈(守边界:不信输入,提交前 trim 判空)。
import type { TemplateCreateRequest } from "@/lib/api/types";
// 新建模板的草稿表单值(受控输入原始值)。
export interface TemplateDraft {
title: string;
body: string;
category: string;
toolKey: string;
}
export const EMPTY_TEMPLATE_DRAFT: TemplateDraft = {
title: "",
body: "",
category: "",
toolKey: "",
};
// 草稿是否可提交title/body 去空白后非空)。
export function isTemplateDraftValid(draft: TemplateDraft): boolean {
return draft.title.trim().length > 0 && draft.body.trim().length > 0;
}
// 草稿 → 创建请求体trim title/body空 category/toolKey 省略为 null后端可空
export function buildTemplateCreateRequest(
draft: TemplateDraft,
): TemplateCreateRequest {
const category = draft.category.trim();
const toolKey = draft.toolKey.trim();
return {
title: draft.title.trim(),
body: draft.body.trim(),
category: category.length > 0 ? category : null,
tool_key: toolKey.length > 0 ? toolKey : null,
};
}

View File

@@ -1,12 +1,17 @@
import { describe, expect, it } from "vitest";
import { buildIngestRequest, ingestTable } from "./ingest";
import {
buildIngestRequest,
ingestTable,
isSingleObjectIngest,
} from "./ingest";
describe("ingestTable", () => {
it("maps ingestable kinds to their target table", () => {
expect(ingestTable("GoldenFingerResult")).toBe("world_entities");
expect(ingestTable("GlossaryResult")).toBe("world_entities");
expect(ingestTable("DetailedOutlineResult")).toBe("outline");
expect(ingestTable("BookTeardownResult")).toBe("rules");
});
it("returns null for non-ingestable kinds", () => {
@@ -15,6 +20,18 @@ describe("ingestTable", () => {
});
});
describe("isSingleObjectIngest", () => {
it("flags teardown as single-object ingest", () => {
expect(isSingleObjectIngest("BookTeardownResult")).toBe(true);
});
it("treats row-based kinds as not single-object", () => {
expect(isSingleObjectIngest("GoldenFingerResult")).toBe(false);
expect(isSingleObjectIngest("DetailedOutlineResult")).toBe(false);
expect(isSingleObjectIngest("unknown")).toBe(false);
});
});
describe("buildIngestRequest", () => {
it("maps golden finger rows into world_entities with merged rules", () => {
const body = buildIngestRequest({
@@ -81,6 +98,36 @@ describe("buildIngestRequest", () => {
expect(body?.acknowledge_conflicts).toBe(false);
});
it("maps teardown single object to a teardown body", () => {
const body = buildIngestRequest({
outputKind: "BookTeardownResult",
rows: [
{
themes: ["逆袭", "成长"],
archetypes: ["废柴主角"],
structure: "三幕式",
hooks: ["开局打脸"],
},
],
acknowledgeConflicts: true,
});
expect(body).toEqual({
teardown: {
themes: ["逆袭", "成长"],
archetypes: ["废柴主角"],
structure: "三幕式",
hooks: ["开局打脸"],
},
acknowledge_conflicts: true,
});
});
it("returns null for teardown with no rows", () => {
expect(
buildIngestRequest({ outputKind: "BookTeardownResult", rows: [] }),
).toBeNull();
});
it("returns null for non-ingestable output kind", () => {
expect(
buildIngestRequest({ outputKind: "IdeaListResult", rows: [] }),

View File

@@ -4,6 +4,7 @@
import type {
OutlineSceneIngestView,
TeardownIngestView,
ToolIngestRequest,
WorldEntityCardView,
} from "@/lib/api/types";
@@ -63,6 +64,17 @@ function rowToScene(
};
}
// 拆书结构化产物 → TeardownIngestView贴后端 schema后端再拍平为可读 rules 条目)。
// 单对象产物:整个 preview 对象即一条入库项(无逐行勾选)。
function rowToTeardown(row: Record<string, unknown>): TeardownIngestView {
return {
themes: asStringArray(row["themes"]),
archetypes: asStringArray(row["archetypes"]),
structure: asString(row["structure"]),
hooks: asStringArray(row["hooks"]),
};
}
export interface IngestBuildInput {
outputKind: string;
// 作者勾选要入库的预览行(结构化产物原始记录)。
@@ -72,6 +84,15 @@ export interface IngestBuildInput {
acknowledgeConflicts?: boolean;
}
// 单对象入库的 output_kind整个 preview 即一条入库项(无逐行勾选)。
// 当前仅拆书(结构化结论拍平为 rules。行式产物金手指/词条/细纲)不在此列。
const SINGLE_OBJECT_INGEST_KINDS = new Set(["BookTeardownResult"]);
// 该产物是否「单对象入库」UI 据此隐藏勾选框、改文案、用整个 rawPreview 作唯一行)。
export function isSingleObjectIngest(outputKind: string): boolean {
return SINGLE_OBJECT_INGEST_KINDS.has(outputKind);
}
// 入库目标表(供 UI 文案 / 判定)。未知 → null不可入库
export function ingestTable(outputKind: string): string | null {
switch (outputKind) {
@@ -80,6 +101,8 @@ export function ingestTable(outputKind: string): string | null {
return "world_entities";
case "DetailedOutlineResult":
return "outline";
case "BookTeardownResult":
return "rules";
default:
return null;
}
@@ -107,6 +130,12 @@ export function buildIngestRequest(
scenes: input.rows.map((row, i) => rowToScene(row, i)),
acknowledge_conflicts,
};
case "BookTeardownResult": {
// 单对象产物:取首行(整个拆书结论)作 teardown无产物 → null。
const row = input.rows[0];
if (!row) return null;
return { teardown: rowToTeardown(row), acknowledge_conflicts };
}
default:
return null;
}

View File

@@ -9,6 +9,7 @@ import {
missingRequiredFields,
previewRows,
resolveLegacyRoute,
templateFillTarget,
} from "./toolbox";
const field = (over: Partial<ToolInputFieldView> = {}): ToolInputFieldView => ({
@@ -210,3 +211,20 @@ describe("isLegacyTool", () => {
).toBe(false);
});
});
describe("templateFillTarget", () => {
it("prefers brief when present", () => {
expect(
templateFillTarget([field({ name: "brief" }), field({ name: "text" })]),
).toBe("brief");
});
it("falls back to text when brief absent", () => {
expect(templateFillTarget([field({ name: "text" })])).toBe("text");
});
it("returns null when no fillable field exists", () => {
expect(templateFillTarget([field({ name: "count" })])).toBeNull();
expect(templateFillTarget(undefined)).toBeNull();
});
});

View File

@@ -292,6 +292,21 @@ function fallbackItem(row: Record<string, unknown>): PreviewItem {
return { heading, fields, body: null };
}
// —— 模板填入目标 ——
// 模板 body 一键填入的目标输入字段:优先 brief多数生成器主输入否则 text原文输入类
// 都没有则 null不渲染「从模板填入」。F3纯前端不改生成器后端。
const TEMPLATE_FILL_FIELDS = ["brief", "text"] as const;
export function templateFillTarget(
fields: readonly ToolInputFieldView[] | undefined,
): string | null {
const names = new Set((fields ?? []).map((f) => f.name));
for (const candidate of TEMPLATE_FILL_FIELDS) {
if (names.has(candidate)) return candidate;
}
return null;
}
// —— legacy 路由解析 ——
// legacy 工具的 legacy_route 含 {id} 占位(如 /projects/{id}/codex?gen=world→ 替换为真实 projectId。
export function resolveLegacyRoute(

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,54 @@
# 设计契约 · 续补三件(拆书入库 / 续写式链 / 模板库)
> 分支 `feat/backlog-followups` · 实施前唯一契约源。Agent 先读本文 + CLAUDE.md 不变量。范围 = 用户选定「全部可建的续补」(排除多租户/市场/push/browse/K1
## Context
Scope B 已合并(链 UI + 4 生成器)。本批补三个**贴现有框架**的续补功能:把竞品对标的剩余可建项做完。守不变量 #1/#2/#3/#9preview→ingest 经验收闸单用户原型owner_id stub
---
## F1 · 拆书落库成 rulesteardown → rules ingest
现状teardown 生成器是 preview-only。目标可把拆书结论入库为项目 `rules`,供写章注入。
- **@llm**packages/agents`teardown_spec.writes=["rules"]`(其余不动;只声明 tier #2)。
- **@backend**packages/skills + apps/api
- `skill_permissions.KNOWN_TABLES``"rules"`(确认未在则加)。
- `toolbox_registry` teardown entry 加 `ingest=IngestSpec(table="rules")`
- `routers/toolbox.py`:在通用 ingest dispatcher`_ingest_world_entities`/`_ingest_outline`:344-365 的 "其余表未实现" 分支)加 `_ingest_rules` handler——把 `BookTeardownResult`(themes/archetypes/structure/hooks) 转成 `rules`scope=project 级,复用既有 `rules` repo/表;规则文本由结构化字段拼成可读条目)。`partition_writes` 白名单已据 spec.writes 放行rules 无需 continuity 预检meta 规则非设定卡)→ ingest 直接落库(仿 `_ingest_outline` 无 409 路径)。
- **@frontend**apps/webteardown 现 output_kind 已可预览;因 registry 加了 ingest`GeneratorRunner` 的入库分支应自动出现「入库为规则」按钮(复用既有 ingest UI。确认渲染 + `pnpm gen:api`
- **@qa**ingest E2Eteardown generate→ingest→`rules` 真落行;负向:预览仍不写库)。
- DoD后端门禁绿teardown 可入 rules不变量 #3(入库经白名单)守住。
## F2 · 续写式链continue_volume chain
现状:链只有 `draft_volume`(从 start 章按记忆写)。目标:新增 `continue_volume`——每章写作以**上一章 accepted 正文末尾**作前文引子(复用 `build_continuation_context`),比纯 digest 续写更顺。
- **@llm**packages/core/orchestrator/chain`write_chapter` 节点支持「续写模式」——据 state 的 `chain_key``continue_volume` 时调 `build_continuation_context(prior_text=上一章 accepted 正文)`(经注入的 reader仿现有 accept_op 注入core 不 import apps/api`draft_volume` 保持原行为。ChainState 加 `chain_key`(已有则复用)。
- **@backend**apps/api`SUPPORTED_CHAINS`chain.py:63`"continue_volume"`run 端点据 chain_key 选模式chain_runner 注入「读上一章 accepted 正文」的 reader 给图。
- **@frontend**apps/web`ChainStarter` 加链类型选择draft_volume=从头写 / continue_volume=续写),传 chain_key。
- **@qa**E2Econtinue_volume 两章:第二章请求上下文含第一章正文末尾;其余同链 E2E 范式mock 网关零 token
- DoD两种链都可跑续写模式前文注入经 E2E 断言;守不变量 #1/#5
## F3 · 提示词/模板库(单用户本地版)
现状:无。目标:作者保存/复用提示词模板,可一键填入生成器的 brief/text。**不做分享/市场**(需多租户)。
- **@db**packages/db新表 `prompt_templates``UuidPk`+`CreatedAt`+`Base` mixin`owner_id`(stub)、`title:str``body:str``category:str|None``tool_key:str|None`(可选关联生成器)+ alembic 迁移。`alembic check` 无漂移。
- **@backend**packages/core/domain + apps/api`TemplateRepo`/`SqlTemplateRepo`list/create/deleteowner stub 过滤)+ `routers/templates.py``GET/POST/DELETE /templates`POST 校验 title/body 非空→422+ `schemas/templates.py` + main 注册。
- **@frontend**apps/web`pnpm gen:api`;模板库页 `app/templates/page.tsx`(列表/新建/删除)+ nav 入口;生成器 Runner 可选「从模板填入」(把模板 body 填进 brief/text 输入)。
- **@qa**E2E创建→列出→删除 真 pgtitle 空→422
- DoD模板 CRUD 可用 + 可填入生成器;后端+前端门禁绿。
---
## Workflow 结构(顺序建造避免共享树写冲突 → 交叉评审 → 全门禁)
- Phase F11-2 agent→ Phase F2@llm+@backend 顺序 + @frontend)→ Phase F3@db@backend@frontend+@qa)→ 各 feature E2E。
- 交叉评审并行只读python / fastapi / typescript / 不变量。
- 全门禁(并行独立复跑):后端 ruff/format/mypy/alembic check/pytest · 前端 lint/tsc/vitest/build。
- 我汇总 reviewCRITICAL/HIGH → 派 fix agent 复绿再合并。
## 验证
- 后端:`uv run ruff check . && uv run mypy packages apps && uv run alembic check && uv run pytest -q`pg 在跑)。
- 前端:`cd apps/web && pnpm gen:api && pnpm lint && pnpm typecheck && pnpm test && pnpm build`
- 实景app 在 localhost:3000/8000需 provider keyteardown 可入 rules链可选 continue_volume模板库可建/填入。
## 风险/取舍
- F1 rules 入库rules 表结构若与拆书结构化字段不匹配,则拍平为可读规则文本(一条 teardown=一/多条 ruleE2E 断真落行即可。
- F2 续写 reader 跨层:端点读 chapter accepted 正文→注入图节点(仿 accept_opcore 不 import apps/api
- F3 模板填入生成器:前端把模板 body 写进现有 brief/text 输入即可,不改生成器后端。
- owner_id 全程 stub单用户原型分享/市场不做。

View File

@@ -1,7 +1,8 @@
"""Scope B 竞品快赢 · 4 个生成器的 spec/schema 契约测试。
校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema
对齐不变量 #2只声明 tier/ #34 生成器一律纯预览 writes=[])。不联网、无 DB
对齐不变量 #2只声明 tier/ #3续写/扩写/降AI 纯预览 writes=[];拆书 F1 落库 rules
不联网、无 DB。
"""
from __future__ import annotations
@@ -19,17 +20,25 @@ from ww_agents import (
teardown_spec,
)
# (spec, name, tier, reads, output_schema)
# (spec, name, tier, reads, writes, 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),
(
continue_spec,
"continue",
"writer",
["projects", "outline", "chapters"],
[],
ContinuationResult,
),
(expand_spec, "expand", "writer", ["projects"], [], PolishResult),
(de_ai_spec, "de-ai", "analyst", ["projects"], [], DeAiResult),
# F1拆书结论可落库为项目 ruleswrites=["rules"];其余三者仍纯预览)。
(teardown_spec, "teardown", "analyst", ["projects"], ["rules"], BookTeardownResult),
]
@pytest.mark.parametrize(
("spec", "name", "tier", "reads", "output_schema"),
("spec", "name", "tier", "reads", "writes", "output_schema"),
_COMPETITOR_SPECS,
)
def test_competitor_spec_declares_expected_contract(
@@ -37,12 +46,13 @@ def test_competitor_spec_declares_expected_contract(
name: str,
tier: str,
reads: list[str],
writes: list[str],
output_schema: type,
) -> None:
assert spec.name == name
assert spec.tier == tier
assert spec.reads == reads
assert spec.writes == [] # 不变量 #34 生成器一律纯预览
assert spec.writes == writes # 不变量 #3续写/扩写/降AI 纯预览;拆书落 rules
assert spec.output_schema is output_schema
assert spec.scope == "builtin"
assert spec.input_schema is None # 注入材料为序列化文本,非结构化入参

View File

@@ -571,8 +571,8 @@ class DeAiResult(BaseModel):
class BookTeardownResult(BaseModel):
"""拆书生成器结构化产出样本作品的结构化拆解Scope B 竞品快赢)。
纯预览产物——不映射任何业务表、不入库(`teardown_spec.writes=[]`,不变量 #3
拆书「落库成 rules」为可选 follow-up需 `KNOWN_TABLES` + `_ingest_rules`),本期不做
F1可经 ingest 端点把结论拍平为项目 `rules` 条目落库(`teardown_spec.writes=["rules"]`
入库仍经白名单 gate不变量 #3预览generate不写库
"""
themes: list[str] = Field(

View File

@@ -799,6 +799,6 @@ teardown_spec = AgentSpec(
input_schema=None, # 注入材料为序列化文本(设定 + 样本 + 需求),非结构化入参
output_schema=BookTeardownResult,
reads=["projects"],
writes=[], # 纯预览,不写库(不变量 #3
writes=["rules"], # F1拆书结论可落库为项目 rules入库仍经 ingest 白名单 gate#3
scope="builtin",
)

View File

@@ -64,13 +64,17 @@ class FakeChainGateway:
self._by_schema = by_schema
self.write_calls = 0
self.review_calls = 0
self.write_inputs: list[str] = []
async def run(self, req: LlmRequest) -> LlmResponse:
schema: type[BaseModel] | None = req.output_schema
if schema is None:
self.write_calls += 1
self.write_inputs.append(req.input if isinstance(req.input, str) else "")
# 每次写章产不同正文(便于断言下一章续写注入了上一章正文)。
draft = f"{self._draft}#{self.write_calls}"
return LlmResponse(
text=self._draft,
text=draft,
parsed=None,
usage=_usage(),
served_by=ServedBy(provider="fake", model="fake"),
@@ -93,10 +97,11 @@ class FakeDraftView:
class FakeChapterRepo:
"""内存章草稿 reposave_draft 存正文get_draft 回读。"""
"""内存章草稿 reposave_draft 存正文get_draft 回读latest_accepted 读已验收正文"""
def __init__(self, store: dict[int, str]) -> None:
def __init__(self, store: dict[int, str], accepted: dict[int, str] | None = None) -> None:
self._store = store
self._accepted = accepted if accepted is not None else {}
async def save_draft(
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
@@ -108,6 +113,10 @@ class FakeChapterRepo:
content = self._store.get(chapter_no)
return FakeDraftView(content) if content is not None else None
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> Any:
content = self._accepted.get(chapter_no)
return FakeDraftView(content) if content is not None else None
class FakeReviewRecord:
def __init__(self, chapter_no: int, conflicts: list[dict[str, Any]]) -> None:
@@ -177,6 +186,7 @@ def _empty_by_schema() -> dict[type, BaseModel]:
def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]:
"""造一套共享内存态 + 工厂闭包 + accept 间谍,供图/节点测试复用。"""
draft_store: dict[int, str] = {}
accepted_store: dict[int, str] = {}
review_store: dict[int, list[FakeReviewRecord]] = {}
accepted: list[dict[str, Any]] = []
@@ -192,7 +202,7 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]:
return object() # assemble 被 fake 替换,不实际用 repos
def chapter_repo_factory(session: Any) -> Any:
return FakeChapterRepo(draft_store)
return FakeChapterRepo(draft_store, accepted_store)
def review_repo_factory(session: Any) -> Any:
return FakeReviewRepo(review_store)
@@ -208,6 +218,9 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]:
decisions: list[Any],
) -> None:
accepted.append({"chapter_no": chapter_no, "decisions": list(decisions)})
# 仿真验收落库:把本章草稿晋升为 accepted供下一章续写读前文
if chapter_no in draft_store:
accepted_store[chapter_no] = draft_store[chapter_no]
return {
"gateway": gateway,
@@ -220,6 +233,7 @@ def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]:
"accept_op": accept_op,
"accepted": accepted,
"draft_store": draft_store,
"accepted_store": accepted_store,
"review_store": review_store,
}
@@ -330,7 +344,7 @@ async def test_write_chapter_saves_collected_draft() -> None:
)
assert out == {} # state 不变(正文在 DB
assert h["draft_store"][1] == "第 N 章正文。"
assert h["draft_store"][1] == "第 N 章正文。#1"
# 收集版write 走非流式 run
assert h["gateway"].write_calls == 1
@@ -378,3 +392,80 @@ async def test_chain_interrupts_on_conflict_then_resumes() -> None:
assert final["written"] == [1]
assert [a["chapter_no"] for a in h["accepted"]] == [1]
assert h["accepted"][0]["decisions"] == decisions # 裁决透传给 accept_op
# ---- F2 续写式链 continue_volume ----
async def test_write_chapter_draft_volume_omits_prior_text() -> None:
"""draft_volume默认写章不注入前文——仍走 assemble 的 volatile无回归"""
h = _make_harness(conflicts=[])
h["accepted_store"][0] = "上一章正文末尾片段。" # 即使有前文也不应注入
state = initial_chain_state(
project_id=PROJECT, user_id=USER, start_chapter_no=1, count=1
) # 默认 chain_key=draft_volume
await write_chapter(
state,
gateway_builder=h["gateway_builder"],
session_factory=h["session_factory"],
memory_repos_factory=h["memory_repos_factory"],
chapter_repo_factory=h["chapter_repo_factory"],
assemble=h["assemble"],
)
write_input = h["gateway"].write_inputs[0]
assert write_input == "## 写作指令\n写第 N 章" # 原 assemble volatile未改写
assert "上一章正文末尾片段" not in write_input
async def test_write_chapter_continue_volume_injects_prior_accepted_text() -> None:
"""continue_volume 写第 2 章时,注入第 1 章 accepted 正文(经 build_continuation_context"""
h = _make_harness(conflicts=[])
h["accepted_store"][1] = "第一章已验收的正文末尾。"
state = initial_chain_state(
project_id=PROJECT,
user_id=USER,
start_chapter_no=2,
count=1,
chain_key="continue_volume",
)
await write_chapter(
state,
gateway_builder=h["gateway_builder"],
session_factory=h["session_factory"],
memory_repos_factory=h["memory_repos_factory"],
chapter_repo_factory=h["chapter_repo_factory"],
assemble=h["assemble"],
)
write_input = h["gateway"].write_inputs[0]
assert "第一章已验收的正文末尾。" in write_input
assert "前文正文" in write_input # build_continuation_context 的小节标题
async def test_continue_volume_chain_second_chapter_sees_first_chapter_text() -> None:
"""全图 E2Econtinue_volume 两章——第 2 章请求上下文含第 1 章正文(不变量 #1/#5"""
h = _make_harness(conflicts=[])
graph = _build(h, MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "chain-continue"}}
initial = initial_chain_state(
project_id=PROJECT,
user_id=USER,
start_chapter_no=1,
count=2,
chain_key="continue_volume",
)
final = await graph.ainvoke(initial, config=config)
assert "__interrupt__" not in final
assert final["written"] == [1, 2]
# 第 1 章写出的正文accept_op 晋升为 accepted应出现在第 2 章的写章请求里。
first_chapter_text = h["accepted_store"][1]
second_write_input = h["gateway"].write_inputs[1]
assert first_chapter_text in second_write_input
# 第 1 章请求无前文(前一章 0 不存在)→ 降级占位,不报错。
first_write_input = h["gateway"].write_inputs[0]
assert "前文正文" in first_write_input

View File

@@ -0,0 +1,104 @@
"""F3b 模板库 repo 单测(契约 §F3
`TemplateRepo` = 单用户提示词模板的 list/create/delete统一按 `owner_id` 过滤
(单用户原型 stub。`create`/`delete` 只 `flush()` 不 `commit()`——提交交端点事务
(与项目其它写侧 repo 一致,见 memory/gotchas。`delete` 返回是否删到行(端点据此 404
纯内存 fake无 DB。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
import pytest
from pydantic import ValidationError
from ww_core.domain.template_repo import TemplateCreate, TemplateRepo, TemplateView
OWNER = uuid.UUID(int=1)
OTHER_OWNER = uuid.UUID(int=2)
@dataclass
class _FakeTemplateRepo:
rows: dict[uuid.UUID, TemplateView] = field(default_factory=dict)
owners: dict[uuid.UUID, uuid.UUID] = field(default_factory=dict)
flushed: int = 0
async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView:
view = TemplateView(
id=uuid.uuid4(),
title=data.title,
body=data.body,
category=data.category,
tool_key=data.tool_key,
)
self.rows[view.id] = view
self.owners[view.id] = owner_id
self.flushed += 1
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
return [v for tid, v in self.rows.items() if self.owners[tid] == owner_id]
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
if self.rows.get(template_id) is None or self.owners.get(template_id) != owner_id:
return False
del self.rows[template_id]
del self.owners[template_id]
self.flushed += 1
return True
@pytest.mark.asyncio
async def test_create_then_list_returns_view() -> None:
repo: TemplateRepo = _FakeTemplateRepo()
view = await repo.create(
OWNER, TemplateCreate(title="爽文开局", body="主角穿越...", category=None, tool_key=None)
)
listed = await repo.list_for_owner(OWNER)
assert view.title == "爽文开局"
assert view.body == "主角穿越..."
assert [v.id for v in listed] == [view.id]
@pytest.mark.asyncio
async def test_list_filters_by_owner() -> None:
repo: TemplateRepo = _FakeTemplateRepo()
await repo.create(OWNER, TemplateCreate(title="a", body="b"))
assert await repo.list_for_owner(OTHER_OWNER) == []
@pytest.mark.asyncio
async def test_delete_existing_returns_true() -> None:
repo: TemplateRepo = _FakeTemplateRepo()
view = await repo.create(OWNER, TemplateCreate(title="a", body="b"))
deleted = await repo.delete(OWNER, view.id)
assert deleted is True
assert await repo.list_for_owner(OWNER) == []
@pytest.mark.asyncio
async def test_delete_missing_returns_false() -> None:
repo: TemplateRepo = _FakeTemplateRepo()
assert await repo.delete(OWNER, uuid.uuid4()) is False
@pytest.mark.asyncio
async def test_delete_other_owner_returns_false() -> None:
repo: TemplateRepo = _FakeTemplateRepo()
view = await repo.create(OWNER, TemplateCreate(title="a", body="b"))
assert await repo.delete(OTHER_OWNER, view.id) is False
def test_template_view_is_frozen() -> None:
view = TemplateView(id=uuid.uuid4(), title="a", body="b", category=None, tool_key=None)
with pytest.raises(ValidationError):
view.title = "x"

View File

@@ -60,6 +60,12 @@ from ww_core.domain.style_repo import (
StyleFingerprintView,
StyleFingerprintWriteRepo,
)
from ww_core.domain.template_repo import (
SqlTemplateRepo,
TemplateCreate,
TemplateRepo,
TemplateView,
)
from ww_core.domain.world_entity_repo import (
SqlWorldEntityWriteRepo,
WorldEntityWriteRepo,
@@ -116,4 +122,8 @@ __all__ = [
"StyleFingerprintWriteRepo",
"StyleFingerprintView",
"SqlStyleFingerprintWriteRepo",
"TemplateCreate",
"TemplateRepo",
"TemplateView",
"SqlTemplateRepo",
]

View File

@@ -0,0 +1,105 @@
"""提示词/模板库 RepositoryF3 / 契约 §F3
单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。**不做分享/市场**
(需多租户)。统一按 `owner_id` 过滤(单用户原型 stub多租户化时由认证主体替换
提交边界:`create`/`delete` 只 `flush()` 不 `commit()`——提交交端点事务(与项目其它写侧
repo 一致,见 memory/gotchas。`delete` 返回是否删到行(端点据此映射 404
视图是与 ORM 解耦的只读 Pydantic 快照frozen——路由不碰 SQLAlchemy 行。
"""
from __future__ import annotations
import uuid
from typing import Protocol
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import PromptTemplate
class TemplateView(BaseModel):
"""模板只读快照snake_casefrozen"""
model_config = {"frozen": True}
id: uuid.UUID
title: str
body: str
category: str | None = None
tool_key: str | None = None
class TemplateCreate(BaseModel):
"""新建模板写入字段owner_id 由服务层补 stub"""
title: str
body: str
category: str | None = None
tool_key: str | None = None
class TemplateRepo(Protocol):
"""模板读写接口(按 owner_id 隔离create/delete 只 flush"""
async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView: ...
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]: ...
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool: ...
def _to_view(row: PromptTemplate) -> TemplateView:
return TemplateView(
id=row.id,
title=row.title,
body=row.body,
category=row.category,
tool_key=row.tool_key,
)
class SqlTemplateRepo:
"""SQLAlchemy 实现:写/读/删 `prompt_templates`,按 owner_id 过滤(只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def create(self, owner_id: uuid.UUID, data: TemplateCreate) -> TemplateView:
row = PromptTemplate(
owner_id=owner_id,
title=data.title,
body=data.body,
category=data.category,
tool_key=data.tool_key,
)
self._s.add(row)
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)
async def list_for_owner(self, owner_id: uuid.UUID) -> list[TemplateView]:
rows = (
await self._s.execute(
select(PromptTemplate)
.where(PromptTemplate.owner_id == owner_id)
.order_by(PromptTemplate.created_at)
)
).scalars()
return [_to_view(r) for r in rows]
async def delete(self, owner_id: uuid.UUID, template_id: uuid.UUID) -> bool:
row = (
await self._s.execute(
select(PromptTemplate).where(
PromptTemplate.owner_id == owner_id,
PromptTemplate.id == template_id,
)
)
).scalar_one_or_none()
if row is None:
return False
await self._s.delete(row)
await self._s.flush()
return True

View File

@@ -20,6 +20,7 @@ from ww_agents import AgentSpec
from .._protocols import GatewayRun
from ..collect import collect_reviews
from ..generation_node import build_continuation_context
from ..review_node import build_review_context, run_review
from ..state import ChapterState
from ..write_node import build_write_request
@@ -27,6 +28,9 @@ from .state import ChainState
log = structlog.get_logger(__name__)
#: 续写式链 key写章以上一章已验收正文末尾作前文引子F2
CHAIN_CONTINUE_VOLUME = "continue_volume"
# ---- 注入缝(图工厂经默认参绑定,单测直接注 fake----
#: `session -> Repo`:节点自建短事务里从 session 造 repo仿端点依赖工厂
@@ -62,7 +66,10 @@ class DraftView(Protocol):
class ChapterDraftRepo(Protocol):
"""章草稿 repo 最小依赖write 落稿 `save_draft` + review 重读 `get_draft`(只 flush"""
"""章草稿 repo 最小依赖write 落稿 `save_draft` + review 重读 `get_draft`(只 flush
续写式链(`continue_volume`)写章另需 `latest_accepted`——读上一章已验收终稿正文作前文引子。
"""
async def save_draft(
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
@@ -70,6 +77,8 @@ class ChapterDraftRepo(Protocol):
async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> DraftView | None: ...
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> DraftView | None: ...
class ReviewRecordRepo(Protocol):
"""review/accept 节点对审稿 repo 的最小依赖collect 留痕 + accept 重读最近审稿。"""
@@ -126,20 +135,30 @@ async def write_chapter(
project_id = state["project_id"]
chapter_no = state["current_chapter_no"]
user_id = state["user_id"]
is_continuation = state.get("chain_key") == CHAIN_CONTINUE_VOLUME
async with session_factory() as session:
gateway = await gateway_builder(session)
repos = memory_repos_factory(session)
context = await assemble(repos, project_id, chapter_no)
chapter_repo = chapter_repo_factory(session)
# 续写模式volatile 改写为「设定 + 上一章已验收正文末尾」(不变量 #9仅改断点后块
# stable_core 仍进缓存前缀)。前文经注入的 chapter_repo 重读core 不 import apps/api
if is_continuation:
prior_text = await _read_prior_accepted(chapter_repo, project_id, chapter_no)
volatile = build_continuation_context(
project_context=context.volatile, prior_text=prior_text
)
else:
volatile = context.volatile
req = build_write_request(
stable_core=context.stable_core,
volatile=context.volatile,
volatile=volatile,
user_id=user_id,
project_id=project_id,
)
# 收集版:非流式,一次 run 拿全文(链批量量产不走 SSE
collect_req = req.model_copy(update={"stream": False})
resp = await gateway.run(collect_req)
chapter_repo = chapter_repo_factory(session)
await chapter_repo.save_draft(project_id, chapter_no, text=resp.text)
await _commit(session)
log.info(
@@ -295,6 +314,19 @@ async def _read_draft(
return view.content if view is not None else ""
async def _read_prior_accepted(
chapter_repo: ChapterDraftRepo, project_id: uuid.UUID, chapter_no: int
) -> str:
"""续写式链读上一章chapter_no-1已验收终稿正文作前文引子不变量 #1/#4
第一章(前一章号 < 1或前一章未验收 → 空串,由 `build_continuation_context` 降级占位。
"""
if chapter_no <= 1:
return ""
view = await chapter_repo.latest_accepted(project_id, chapter_no - 1)
return view.content if view is not None else ""
async def _commit(session: CommitSession) -> None:
"""提交短事务session 满足 `CommitSession`:只需 `commit()`)。"""
await session.commit()
@@ -307,6 +339,7 @@ def has_conflicts(state: ChainState) -> bool:
__all__ = [
"CHAIN_CONTINUE_VOLUME",
"AcceptChapterOp",
"AssembleContext",
"AssembleFn",

View File

@@ -0,0 +1,53 @@
"""prompt_templates table
Revision ID: 59a9198d4604
Revises: d3e4f5a6b7c8
Create Date: 2026-06-23 20:10:37.869872
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "59a9198d4604"
down_revision: str | None = "d3e4f5a6b7c8"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"prompt_templates",
sa.Column("owner_id", sa.Uuid(), nullable=False),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("category", sa.Text(), nullable=True),
sa.Column("tool_key", sa.Text(), nullable=True),
sa.Column("id", sa.Uuid(), server_default=sa.text("gen_random_uuid()"), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["owner_id"],
["users.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_prompt_templates_owner_id"), "prompt_templates", ["owner_id"], unique=False
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f("ix_prompt_templates_owner_id"), table_name="prompt_templates")
op.drop_table("prompt_templates")
# ### end Alembic commands ###

View File

@@ -284,6 +284,20 @@ class Skill(UuidPk, CreatedAt, Base):
examples: Mapped[list[Any]] = mapped_column(JSONB, server_default=text("'[]'"))
class PromptTemplate(UuidPk, CreatedAt, Base):
"""作者保存/复用的提示词模板(单用户本地版;无分享/市场)。
owner_id 为单用户原型 stubtool_key 可选关联某生成器NULL=通用)。
"""
__tablename__ = "prompt_templates"
owner_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
title: Mapped[str] = mapped_column(Text, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
category: Mapped[str | None] = mapped_column(Text)
tool_key: Mapped[str | None] = mapped_column(Text)
class Job(UuidPk, TimestampedMixin, Base):
__tablename__ = "jobs"
__table_args__ = (

View File

@@ -19,13 +19,13 @@ _NEW_KEYS = {
"glossary",
"opening",
"fine-outline",
# 竞品快赢Scope B续写 / 扩写 / 降AI率 / 拆书(全 preview-only
# 竞品快赢Scope B续写/扩写/降AI率 preview-only拆书 F1 可落库 rules
"continue",
"expand",
"de-ai",
"teardown",
}
_INGEST_KEYS = {"golden-finger", "glossary", "fine-outline"}
_INGEST_KEYS = {"golden-finger", "glossary", "fine-outline", "teardown"}
def test_toolbox_has_all_tools() -> None:
@@ -70,6 +70,9 @@ def test_ingest_tools_declare_known_table() -> None:
assert TOOLBOX["glossary"].ingest.table == "world_entities"
assert TOOLBOX["fine-outline"].ingest is not None
assert TOOLBOX["fine-outline"].ingest.table == "outline"
# F1拆书结论落库为项目 rules。
assert TOOLBOX["teardown"].ingest is not None
assert TOOLBOX["teardown"].ingest.table == "rules"
def test_chapter_tools_have_chapter_no_field() -> None:

View File

@@ -197,7 +197,7 @@ TOOLBOX: dict[str, GeneratorTool] = {
input_fields=[_chapter_no_field(), _BRIEF_FIELD],
ingest=IngestSpec(table="outline"),
),
# ---- 竞品快赢Scope B续写 / 扩写 / 降AI率 / 拆书(全 preview-onlywrites=[]----
# ---- 竞品快赢Scope B续写/扩写/降AI率 preview-only拆书 F1 可落库 rules ----
"continue": GeneratorTool(
key="continue",
title="续写生成器",
@@ -252,6 +252,7 @@ TOOLBOX: dict[str, GeneratorTool] = {
_SOURCE_TEXT_FIELD,
_BRIEF_FIELD,
],
ingest=IngestSpec(table="rules"), # F1拆书结论拍平为项目 rules 条目落库
),
}

View File

@@ -0,0 +1,398 @@
"""F2 续写式链 continue_volume 端到端(真 pg + mock 网关零 token
证明续写式链闭环(不变量 #1/#5`continue_volume` 每章写作以**上一章 accepted 正文末尾**
作前文引子(复用 `build_continuation_context`)。两章无冲突全自动:
- 第二章写作请求的上下文须含第一章 accepted 正文(断前文注入);
- DB 真源:两章 accepted + 两 digest 行。
回归守卫:`draft_volume`(非续写)第二章写作请求**不应**含第一章正文(区分两模式)。
镜像 `tests/test_chain_workflow_e2e.py`:真 `Gateway` + 假适配器(据 `req.output_schema` 分支
返 parsed/text绝不联网+ 真 `SqlAlchemyLedgerSink`MemorySaver 检查点。无 pg → skip。
关键差异:写章假适配器**逐章返不同正文**(含章号标记),并**记录每次写章请求的 input**——
故可断言第二章写章请求的 input 含第一章 accepted 正文标记(前文注入),而 draft_volume 不含。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AbstractAsyncContextManager, asynccontextmanager
from typing import Annotated, Any
import httpx
import pytest
from asgi_lifespan import LifespanManager
from fastapi import Depends
from langgraph.checkpoint.memory import MemorySaver
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_agents import (
Conflict,
ContinuityReview,
ForeshadowReview,
PaceReview,
StyleDriftReview,
)
from ww_api.services.digest_extraction import ChapterDigestFacts
from ww_db import get_session, get_sessionmaker
from ww_db.models import (
Chapter,
ChapterDigest,
ChapterReview,
Job,
Project,
UsageLedger,
)
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
_PROVIDER = "deepseek"
_USAGE = ProviderUsage(input_tokens=13, output_tokens=7)
# 每章正文含唯一章号标记,供「前文注入」断言(第二章请求须含第一章标记)。
_CHAPTER_MARK = "【第{n}章正文END】"
_FORESHADOW_REVIEW = ForeshadowReview(planted=[], resolved=[])
_PACE_REVIEW = PaceReview(water=[], hook=True, beat_map=[1, 2, 3])
_STYLE_DRIFT = StyleDriftReview(score=88, segments=[])
class _RecordingChainAdapter:
"""实现 `ProviderAdapter`:写章逐章返带章号标记的正文 + 记录每次写章请求 input。
- `output_schema is None`(写章 `gateway.run`):自增计数返第 N 章正文,记录 req.input。
- 四审 schema → 无冲突 parsed让图全自动跑完
- 其余ChapterDigestFacts→ 终稿 digest 提炼。
"""
provider = _PROVIDER
def __init__(self, *, continuity_conflicts: list[Conflict] | None = None) -> None:
self._write_count = 0
# (request_input, returned_text) 每次写章一条,按写章顺序。
self.write_calls: list[tuple[str, str]] = []
self._continuity = ContinuityReview(conflicts=continuity_conflicts or [])
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True)
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
schema = req.output_schema
if schema is None:
self._write_count += 1
text = f"灵气如潮{_CHAPTER_MARK.format(n=self._write_count)}"
self.write_calls.append((str(req.input), text))
return ProviderResult(text=text, parsed=None, usage=_USAGE)
if schema is ContinuityReview:
return ProviderResult(
text=self._continuity.model_dump_json(), parsed=self._continuity, usage=_USAGE
)
if schema is ForeshadowReview:
return ProviderResult(
text=_FORESHADOW_REVIEW.model_dump_json(), parsed=_FORESHADOW_REVIEW, usage=_USAGE
)
if schema is PaceReview:
return ProviderResult(
text=_PACE_REVIEW.model_dump_json(), parsed=_PACE_REVIEW, usage=_USAGE
)
if schema is StyleDriftReview:
return ProviderResult(
text=_STYLE_DRIFT.model_dump_json(), parsed=_STYLE_DRIFT, usage=_USAGE
)
facts = ChapterDigestFacts(summary="终稿摘要", events=["少年出山"], locations=["山门"])
return ProviderResult(text=facts.model_dump_json(), parsed=facts, usage=_USAGE)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
yield StreamChunk(usage=_USAGE)
@pytest.fixture
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
"""真 DB session 工厂;无 pg 时跳过。"""
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 _gateway_for(adapter: _RecordingChainAdapter, session: AsyncSession) -> Gateway:
return Gateway(
adapters={_PROVIDER: adapter},
ledger=SqlAlchemyLedgerSink(session),
resolver=resolve_route,
)
def _chain_gateway_override(
adapter: _RecordingChainAdapter,
) -> Callable[[AsyncSession], Gateway]:
def _override(session: Annotated[AsyncSession, Depends(get_session)]) -> Gateway:
return _gateway_for(adapter, session)
return _override
def _builder_override(
adapter: _RecordingChainAdapter,
) -> Callable[[], Callable[[AsyncSession], Awaitable[Gateway]]]:
def _get_builder() -> Callable[[AsyncSession], Awaitable[Gateway]]:
async def _build(session: AsyncSession) -> Gateway:
return _gateway_for(adapter, session)
return _build
return _get_builder
def _memsaver_override(
saver: MemorySaver,
) -> Callable[[], Callable[[], AbstractAsyncContextManager[MemorySaver]]]:
@asynccontextmanager
async def _ctx() -> AsyncIterator[MemorySaver]:
yield saver
return lambda: lambda: _ctx()
async def _cleanup(e2e_sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
async with e2e_sm() as cleanup:
await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
await cleanup.execute(delete(Job).where(Job.project_id == project_uuid))
await cleanup.execute(delete(ChapterDigest).where(ChapterDigest.project_id == project_uuid))
await cleanup.execute(delete(ChapterReview).where(ChapterReview.project_id == project_uuid))
await cleanup.execute(delete(Chapter).where(Chapter.project_id == project_uuid))
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
await cleanup.commit()
def _build_app(adapter: _RecordingChainAdapter, saver: MemorySaver, e2e_sm: Any) -> Any:
from ww_api.main import create_app
from ww_api.services.chain_deps import get_checkpointer_factory
from ww_api.services.project_deps import (
get_chain_gateway,
get_chain_gateway_builder,
get_digest_gateway_builder,
get_session_factory,
)
app = create_app()
app.dependency_overrides[get_chain_gateway] = _chain_gateway_override(adapter)
app.dependency_overrides[get_chain_gateway_builder] = _builder_override(adapter)
app.dependency_overrides[get_session_factory] = lambda: e2e_sm
app.dependency_overrides[get_checkpointer_factory] = _memsaver_override(saver)
app.dependency_overrides[get_digest_gateway_builder] = _builder_override(adapter)
return app
async def _run_two_chapter_chain(
adapter: _RecordingChainAdapter,
e2e_sm: async_sessionmaker[AsyncSession],
chain_key: str,
) -> tuple[uuid.UUID, dict[str, Any]]:
"""跑两章链(无冲突全自动)→ 返 (project_uuid, job result)。调用方负责 cleanup。"""
saver = MemorySaver()
app = _build_app(adapter, saver, e2e_sm)
transport = httpx.ASGITransport(app=app)
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
create_resp = await client.post("/projects", json={"title": f"F2 {chain_key} 作品"})
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
run_resp = await client.post(
f"/projects/{project_id}/chains/{chain_key}/run",
json={"start_chapter_no": 1, "count": 2},
)
assert run_resp.status_code == 202, run_resp.text
assert run_resp.json()["chain_key"] == chain_key
job_id = run_resp.json()["job_id"]
job = (await client.get(f"/jobs/{job_id}")).json()
assert job["status"] == "done", job
return project_uuid, job["result"]
async def test_continue_volume_injects_prior_chapter_into_second_write(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 1continue_volume 两章 → 第二章写章请求含第一章 accepted 正文(前文注入)。"""
adapter = _RecordingChainAdapter()
project_uuid: uuid.UUID | None = None
try:
project_uuid, result = await _run_two_chapter_chain(adapter, e2e_sm, "continue_volume")
assert result["written"] == [1, 2]
assert result["completed"] is True
# 两次写章请求被记录(按章序)。
assert len(adapter.write_calls) == 2
first_input, first_text = adapter.write_calls[0]
second_input, _second_text = adapter.write_calls[1]
# 第一章正文标记accepted 后即此文本)。
chapter1_mark = _CHAPTER_MARK.format(n=1)
# 核心断言:第二章写章请求上下文含第一章 accepted 正文(前文引子注入,不变量 #1/#5
assert chapter1_mark in second_input, second_input
# 续写上下文结构标记build_continuation_context也应在第二章请求中。
assert "前文正文" in second_input
# 第一章无前文 → 其请求不含「第1章标记」首章占位降级证明非凭空注入
assert chapter1_mark not in first_input
# DB 真源:两章 accepted + 两 digest 行。
async with e2e_sm() as verify:
accepted = (
(
await verify.execute(
select(Chapter).where(
Chapter.project_id == project_uuid, Chapter.status == "accepted"
)
)
)
.scalars()
.all()
)
assert {c.chapter_no for c in accepted} == {1, 2}
digests = (
(
await verify.execute(
select(ChapterDigest).where(ChapterDigest.project_id == project_uuid)
)
)
.scalars()
.all()
)
assert {d.chapter_no for d in digests} == {1, 2}
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)
async def test_draft_volume_does_not_inject_prior_chapter_into_second_write(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 2回归守卫draft_volume 第二章写章请求**不含**第一章正文(区分两模式)。"""
adapter = _RecordingChainAdapter()
project_uuid: uuid.UUID | None = None
try:
project_uuid, result = await _run_two_chapter_chain(adapter, e2e_sm, "draft_volume")
assert result["written"] == [1, 2]
assert len(adapter.write_calls) == 2
_first_input, _first_text = adapter.write_calls[0]
second_input, _ = adapter.write_calls[1]
chapter1_mark = _CHAPTER_MARK.format(n=1)
# draft_volume 非续写:第二章写章请求不应含第一章正文(仅按记忆/digest 量产)。
assert chapter1_mark not in second_input
# 亦无续写上下文结构标记。
assert "前文正文(续写须无缝承接)" not in second_input
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)
async def test_continue_volume_conflict_interrupts_then_resume_reports_chain_key(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 3resume 路径 + 回归守卫 #1continue_volume 第 1 章冲突 → interrupt → awaiting →
resume 带裁决 → 续跑 accept → done且 resume 受理回执 chain_key="continue_volume"
修复前 bugresume 端点按 job.kind 推断 chain_key恒为通用常量 "chain",从不在
SUPPORTED_CHAINS→ 总回退 "draft_volume"——continue_volume 的 resume 回执报错类型。
本测试断言 resume 回执 / job result 均反映真值 "continue_volume"
interrupt+resume 横跨两次端点调用,靠单进程单 MemorySaver同一 thread_id 检查点)。
"""
conflict = Conflict(type="性格漂移", where="第2段", suggestion="统一主角姓名为「萧寒」")
adapter = _RecordingChainAdapter(continuity_conflicts=[conflict])
saver = MemorySaver() # 单实例横跨 run + resume 两次端点调用
app = _build_app(adapter, saver, e2e_sm)
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:
create_resp = await client.post(
"/projects", json={"title": "F2 continue_volume resume 作品"}
)
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
# 1) 发起 continue_volume第 1 章四审报冲突 → interrupt → job awaiting_input。
run_resp = await client.post(
f"/projects/{project_id}/chains/continue_volume/run",
json={"start_chapter_no": 1, "count": 1},
)
assert run_resp.status_code == 202, run_resp.text
assert run_resp.json()["chain_key"] == "continue_volume"
job_id = run_resp.json()["job_id"]
job = (await client.get(f"/jobs/{job_id}")).json()
assert job["status"] == "awaiting_input", job
assert job["result"]["awaiting_chapter"] == 1
assert job["result"]["completed"] is False
# job result 已持久真 chain_keyresume 端点据此读回,回归守卫 #1
assert job["result"]["chain_key"] == "continue_volume"
# 2) resume作者裁决冲突 → 续跑 accept → done。回执须报真 chain_key。
resume_resp = await client.post(
f"/projects/{project_id}/chains/runs/{job_id}/resume",
json={
"decisions": [
{"conflict_index": 0, "verdict": "ignore", "note": "笔误,忽略"}
]
},
)
assert resume_resp.status_code == 202, resume_resp.text
# 回归守卫 #1resume 回执报真 chain_key而非旧 bug 的 "draft_volume"。
assert resume_resp.json()["chain_key"] == "continue_volume"
job = (await client.get(f"/jobs/{job_id}")).json()
assert job["status"] == "done", job
assert job["result"]["written"] == [1]
assert job["result"]["completed"] is True
assert job["result"]["chain_key"] == "continue_volume"
# DB 真源resume 后第 1 章 accepted + digest 一行。
assert project_uuid is not None
async with e2e_sm() as verify:
accepted = (
(
await verify.execute(
select(Chapter).where(
Chapter.project_id == project_uuid, Chapter.status == "accepted"
)
)
)
.scalars()
.all()
)
assert {c.chapter_no for c in accepted} == {1}
digests = (
(
await verify.execute(
select(ChapterDigest).where(ChapterDigest.project_id == project_uuid)
)
)
.scalars()
.all()
)
assert [d.chapter_no for d in digests] == [1]
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)

View File

@@ -0,0 +1,216 @@
"""F1 拆书 teardown 落库成 rules 端到端(真 pg + mock 网关零 token
证明拆书入库闭环(不变量 #3入库经白名单 gate预览不写库
1. teardown `generate` → 200 预览BookTeardownResult断言 `rules` 表零新增(预览不写库)。
2. teardown `ingest`teardown 产物)→ 201`rules` 表真落行(拆书结论拍平成可读规则条目)。
镜像 `tests/test_t6_toolbox_e2e.py`:真 `Gateway` + 据 `req.output_schema` 分支返 parsed 的
假适配器(绝不联网)+ 真 `SqlAlchemyLedgerSink`。注入缝 `get_tier_gateway_builder`generate
调 `build_gateway(spec.tier)`rules ingest 无 continuity 预检故不调网关)。无 pg → skip。
"""
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 ww_agents import BookTeardownResult
from ww_db import get_session, get_sessionmaker
from ww_db.models import Project, Rule, UsageLedger
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
_PROVIDER = "deepseek"
_USAGE = ProviderUsage(input_tokens=29, output_tokens=13)
# teardown 假产物themes/archetypes/structure/hooks 全非空(拍平成 4 条 rules
_TEARDOWN = BookTeardownResult(
themes=["逆袭打脸", "守护苍生"],
archetypes=["扮猪吃虎的主角", "口嫌体正的女主"],
structure="开篇废柴受辱 → 奇遇得宝 → 步步攀升 → 终战封神",
hooks=["每章末留悬念", "三章一小高潮"],
)
class _FakeTeardownAdapter:
"""实现 `ProviderAdapter`:仅 BookTeardownResult 一路(拆书 generate绝不联网。"""
provider = _PROVIDER
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True)
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
if req.output_schema is BookTeardownResult:
return ProviderResult(text=_TEARDOWN.model_dump_json(), parsed=_TEARDOWN, usage=_USAGE)
raise AssertionError(f"unexpected output_schema in F1 fake adapter: {req.output_schema!r}")
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
yield StreamChunk(usage=_USAGE)
raise AssertionError("teardown generation must not stream")
@pytest.fixture
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
"""真 DB session 工厂;无 pg 时跳过。"""
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: _FakeTeardownAdapter,
) -> Callable[[AsyncSession], Callable[[Tier], Awaitable[Gateway]]]:
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 _rule_count(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> int:
async with sm() as s:
return int(
(
await s.execute(
select(func.count()).select_from(Rule).where(Rule.project_id == project_uuid)
)
).scalar_one()
)
async def _cleanup(sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
async with sm() as cleanup:
await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
await cleanup.execute(delete(Rule).where(Rule.project_id == project_uuid))
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
await cleanup.commit()
async def test_teardown_generate_preview_does_not_write_rules(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 1负向teardown generate → 200 预览rules 表零新增(不变量 #3 预览不写库)。"""
from ww_api.main import create_app
from ww_api.services.project_deps import get_tier_gateway_builder
app = create_app()
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
_FakeTeardownAdapter()
)
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:
create_resp = await client.post("/projects", json={"title": "F1 拆书预览作品"})
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
gen_resp = await client.post(
f"/projects/{project_id}/skills/teardown/generate",
json={"text": "第一章……(待拆解的样章正文)", "kind": "某爆款玄幻"},
)
assert gen_resp.status_code == 200, gen_resp.text
body = gen_resp.json()
assert body["tool_key"] == "teardown"
assert body["output_kind"] == "BookTeardownResult"
assert body["preview"]["themes"] == ["逆袭打脸", "守护苍生"]
# DB 真源:预览不写 rules 业务表(守不变量 #3
assert project_uuid is not None
assert await _rule_count(e2e_sm, project_uuid) == 0
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)
async def test_teardown_ingest_persists_rules(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 2teardown ingest → 201rules 表真落行(拆书结论拍平成可读规则条目)。"""
from ww_api.main import create_app
from ww_api.services.project_deps import get_tier_gateway_builder
app = create_app()
# ingestrules 无 continuity 预检不触达网关override 防真凭据探测路径。
app.dependency_overrides[get_tier_gateway_builder] = _tier_builder_override(
_FakeTeardownAdapter()
)
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:
create_resp = await client.post("/projects", json={"title": "F1 拆书入库作品"})
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
assert await _rule_count(e2e_sm, project_uuid) == 0
ingest_resp = await client.post(
f"/projects/{project_id}/skills/teardown/ingest",
json={
"teardown": {
"themes": _TEARDOWN.themes,
"archetypes": _TEARDOWN.archetypes,
"structure": _TEARDOWN.structure,
"hooks": _TEARDOWN.hooks,
}
},
)
assert ingest_resp.status_code == 201, ingest_resp.text
ok = ingest_resp.json()
assert ok["table"] == "rules"
# 四个非空字段 → 四条 rules确定性顺序
assert ok["created"] == ["themes", "archetypes", "structure", "hooks"]
assert ok["rejected_tables"] == []
# DB 真源rules 行真落 pg拍平成可读条目内容含拆书结论
assert project_uuid is not None
assert await _rule_count(e2e_sm, project_uuid) == 4
async with e2e_sm() as verify:
rows = (
(await verify.execute(select(Rule).where(Rule.project_id == project_uuid)))
.scalars()
.all()
)
blob = "\n".join(r.content for r in rows)
# 拍平内容须含拆书结论(断真落行 + 形变保留)。
assert "逆袭打脸" in blob
assert "扮猪吃虎的主角" in blob
assert "开篇废柴受辱" in blob
assert "每章末留悬念" in blob
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)

137
tests/test_templates_e2e.py Normal file
View File

@@ -0,0 +1,137 @@
"""F3 提示词/模板库 端到端(真 pg无网关——模板库不调 LLM
证明单用户本地模板库 CRUD 闭环:创建→列出→删除真 pg 回环 + 输入校验title/body 空 → 422
owner_id 全程 stub单用户原型。无 pg → skip。
镜像现有 `tests/test_*_e2e.py` 范式:真 `get_sessionmaker`(无 pg skip+ `LifespanManager` +
`ASGITransport`DB 真源逐项断言。模板库不触达 LLM 网关,故无需 mock 适配器。
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
import httpx
import pytest
from asgi_lifespan import LifespanManager
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_db import get_sessionmaker
from ww_db.models import PromptTemplate
@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()
async def _cleanup(sm: async_sessionmaker[AsyncSession], ids: list[uuid.UUID]) -> None:
if not ids:
return
async with sm() as cleanup:
await cleanup.execute(delete(PromptTemplate).where(PromptTemplate.id.in_(ids)))
await cleanup.commit()
async def _template_count(sm: async_sessionmaker[AsyncSession], template_id: uuid.UUID) -> int:
async with sm() as s:
return len(
(await s.execute(select(PromptTemplate).where(PromptTemplate.id == template_id)))
.scalars()
.all()
)
async def test_templates_create_list_delete_roundtrip(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 1POST → GET → DELETE 真 pg 回环(行真落 pg删除后表无行"""
from ww_api.main import create_app
app = create_app()
transport = httpx.ASGITransport(app=app)
created_id: uuid.UUID | None = None
try:
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
# 1) POST → 201模板真落 pg。
create_resp = await client.post(
"/templates",
json={
"title": "玄幻开篇模板",
"body": "请按照黄金三章节奏,开篇即上钩……",
"category": "开篇",
"tool_key": "opening",
},
)
assert create_resp.status_code == 201, create_resp.text
created = create_resp.json()
assert created["title"] == "玄幻开篇模板"
assert created["body"] == "请按照黄金三章节奏,开篇即上钩……"
assert created["category"] == "开篇"
assert created["tool_key"] == "opening"
created_id = uuid.UUID(created["id"])
# DB 真源:模板行真落 pgowner stub
assert await _template_count(e2e_sm, created_id) == 1
# 2) GET → 列表含刚建模板。
list_resp = await client.get("/templates")
assert list_resp.status_code == 200
listed = list_resp.json()
assert created["id"] in [t["id"] for t in listed]
mine = next(t for t in listed if t["id"] == created["id"])
assert mine["title"] == "玄幻开篇模板"
assert mine["body"] == "请按照黄金三章节奏,开篇即上钩……"
# 3) DELETE → 204DB 真源行被删。
del_resp = await client.delete(f"/templates/{created['id']}")
assert del_resp.status_code == 204
assert await _template_count(e2e_sm, created_id) == 0
# 删除后再列出不含该模板。
after = (await client.get("/templates")).json()
assert created["id"] not in [t["id"] for t in after]
# 4) 删除不存在 → 404 NOT_FOUND envelope。
missing = await client.delete(f"/templates/{uuid.uuid4()}")
assert missing.status_code == 404
assert missing.json()["error"]["code"] == "NOT_FOUND"
finally:
if created_id is not None:
await _cleanup(e2e_sm, [created_id])
async def test_templates_create_blank_title_returns_422(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 2title 空 → 422schema min_length=1DB 无行落库。"""
from ww_api.main import create_app
app = create_app()
transport = httpx.ASGITransport(app=app)
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
before = len((await client.get("/templates")).json())
resp = await client.post("/templates", json={"title": "", "body": "非空正文"})
assert resp.status_code == 422, resp.text
# body 空亦 422守输入校验纪律
resp_body = await client.post("/templates", json={"title": "有标题", "body": ""})
assert resp_body.status_code == 422
# 两次 422 均未落库(列表数量不变)。
after = len((await client.get("/templates")).json())
assert after == before