merge: 续补三件 — 拆书入库rules / 续写式链 continue_volume / 模板库
多 agent 逐学科建造 + 4 路对抗交叉评审 + 修 3 HIGH。 后端 mypy 202 / alembic 无漂移 / pytest 670;前端 vitest 309/build 绿。
This commit is contained in:
@@ -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:
|
||||
"""F2:continue_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:
|
||||
"""回归守卫 #1:resume continue_volume run → 回执 chain_key="continue_volume"(读自
|
||||
job.result,run 时由 _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 持久进 result(continue_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:
|
||||
|
||||
144
apps/api/tests/test_templates.py
Normal file
144
apps/api/tests/test_templates.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""F3b GET/POST/DELETE /templates 端点(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- POST 201 + 回显 + 端点 commit;GET 列出;DELETE 204 + commit;
|
||||
- 空 title → 422、空 body → 422(schema `min_length=1`,FastAPI 422);
|
||||
- 删不存在 → 404(repo.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
|
||||
@@ -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 .../ingest(F1:拆书落库成 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 级 rules(owner 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 == [] # 预览不写库
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/§3):draft_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_key(run 时由 _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
|
||||
|
||||
90
apps/api/ww_api/routers/templates.py
Normal file
90
apps/api/ww_api/routers/templates.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""模板库端点(F3 / 契约 §F3)。
|
||||
|
||||
单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。
|
||||
**不做分享/市场**(需多租户)。owner_id 全程 stub(单用户原型)。
|
||||
|
||||
- GET /templates 列出当前用户的模板。
|
||||
- POST /templates 新建模板(201;title/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 422(schema `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)
|
||||
@@ -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)
|
||||
|
||||
35
apps/api/ww_api/schemas/templates.py
Normal file
35
apps/api/ww_api/schemas/templates.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""模板库端点的请求/响应 schema(F3 / 契约 §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
|
||||
@@ -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`(贴 WorldEntityCardView:type/name/rules);
|
||||
- outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView)。
|
||||
- outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView);
|
||||
- rules(拆书)→ `teardown`(贴 TeardownIngestView:themes/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 冲突 → 放行入库"
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""模板库 repo(GET/POST/DELETE /templates;create/delete 只 flush,端点提交)。
|
||||
|
||||
测试经 `app.dependency_overrides` 注入内存 fake。
|
||||
"""
|
||||
return SqlTemplateRepo(session)
|
||||
|
||||
|
||||
async def get_skill_registry(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> SkillRegistry:
|
||||
|
||||
32
apps/web/app/templates/page.tsx
Normal file
32
apps/web/app/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
起始章号
|
||||
|
||||
181
apps/web/components/templates/TemplatesManager.tsx
Normal file
181
apps/web/components/templates/TemplatesManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
|
||||
102
apps/web/components/toolbox/TemplateFiller.tsx
Normal file
102
apps/web/components/toolbox/TemplateFiller.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
203
apps/web/lib/api/schema.d.ts
vendored
203
apps/web/lib/api/schema.d.ts
vendored
@@ -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 422(schema `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`(贴 WorldEntityCardView:type/name/rules);
|
||||
* - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView)。
|
||||
* - outline(细纲)→ `chapter_no` + `scenes`(贴 OutlineSceneIngestView);
|
||||
* - rules(拆书)→ `teardown`(贴 TeardownIngestView:themes/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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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-flow(C3 扩 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"];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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_CHAINS):draft_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 {
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface NavItem {
|
||||
|
||||
export const GLOBAL_NAV_ITEMS: readonly NavItem[] = [
|
||||
{ href: "/", label: "作品库" },
|
||||
{ href: "/templates", label: "模板库" },
|
||||
{ href: "/settings/providers", label: "设置" },
|
||||
];
|
||||
|
||||
|
||||
62
apps/web/lib/templates/templates.test.ts
Normal file
62
apps/web/lib/templates/templates.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
39
apps/web/lib/templates/templates.ts
Normal file
39
apps/web/lib/templates/templates.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
54
docs/design/backlog-followups.md
Normal file
54
docs/design/backlog-followups.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 设计契约 · 续补三件(拆书入库 / 续写式链 / 模板库)
|
||||
|
||||
> 分支 `feat/backlog-followups` · 实施前唯一契约源。Agent 先读本文 + CLAUDE.md 不变量。范围 = 用户选定「全部可建的续补」(排除多租户/市场/push/browse/K1)。
|
||||
|
||||
## Context
|
||||
Scope B 已合并(链 UI + 4 生成器)。本批补三个**贴现有框架**的续补功能:把竞品对标的剩余可建项做完。守不变量 #1/#2/#3/#9;preview→ingest 经验收闸;单用户原型(owner_id stub)。
|
||||
|
||||
---
|
||||
|
||||
## F1 · 拆书落库成 rules(teardown → 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/web):teardown 现 output_kind 已可预览;因 registry 加了 ingest,`GeneratorRunner` 的入库分支应自动出现「入库为规则」按钮(复用既有 ingest UI)。确认渲染 + `pnpm gen:api`。
|
||||
- **@qa**:ingest E2E(teardown 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**:E2E(continue_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/delete,owner 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(创建→列出→删除 真 pg;title 空→422)。
|
||||
- DoD:模板 CRUD 可用 + 可填入生成器;后端+前端门禁绿。
|
||||
|
||||
---
|
||||
|
||||
## Workflow 结构(顺序建造避免共享树写冲突 → 交叉评审 → 全门禁)
|
||||
- Phase F1(1-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。
|
||||
- 我汇总 review;CRITICAL/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 key):teardown 可入 rules;链可选 continue_volume;模板库可建/填入。
|
||||
|
||||
## 风险/取舍
|
||||
- F1 rules 入库:rules 表结构若与拆书结构化字段不匹配,则拍平为可读规则文本(一条 teardown=一/多条 rule),E2E 断真落行即可。
|
||||
- F2 续写 reader 跨层:端点读 chapter accepted 正文→注入图节点(仿 accept_op,core 不 import apps/api)。
|
||||
- F3 模板填入生成器:前端把模板 body 写进现有 brief/text 输入即可,不改生成器后端。
|
||||
- owner_id 全程 stub(单用户原型);分享/市场不做。
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Scope B 竞品快赢 · 4 个生成器的 spec/schema 契约测试。
|
||||
|
||||
校验每个 spec 的 name/tier/reads/writes/output_schema/scope/input_schema,
|
||||
对齐不变量 #2(只声明 tier)/ #3(4 生成器一律纯预览 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:拆书结论可落库为项目 rules(writes=["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 == [] # 不变量 #3:4 生成器一律纯预览
|
||||
assert spec.writes == writes # 不变量 #3:续写/扩写/降AI 纯预览;拆书落 rules
|
||||
assert spec.output_schema is output_schema
|
||||
assert spec.scope == "builtin"
|
||||
assert spec.input_schema is None # 注入材料为序列化文本,非结构化入参
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""内存章草稿 repo:save_draft 存正文,get_draft 回读。"""
|
||||
"""内存章草稿 repo:save_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:
|
||||
"""全图 E2E:continue_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
|
||||
|
||||
104
packages/core/tests/test_template_repo.py
Normal file
104
packages/core/tests/test_template_repo.py
Normal 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"
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
105
packages/core/ww_core/domain/template_repo.py
Normal file
105
packages/core/ww_core/domain/template_repo.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""提示词/模板库 Repository(F3 / 契约 §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_case,frozen)。"""
|
||||
|
||||
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
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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 为单用户原型 stub;tool_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__ = (
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -197,7 +197,7 @@ TOOLBOX: dict[str, GeneratorTool] = {
|
||||
input_fields=[_chapter_no_field(), _BRIEF_FIELD],
|
||||
ingest=IngestSpec(table="outline"),
|
||||
),
|
||||
# ---- 竞品快赢(Scope B):续写 / 扩写 / 降AI率 / 拆书(全 preview-only,writes=[])----
|
||||
# ---- 竞品快赢(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 条目落库
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
398
tests/test_chain_continue_volume_e2e.py
Normal file
398
tests/test_chain_continue_volume_e2e.py
Normal 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:
|
||||
"""用例 1:continue_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:
|
||||
"""用例 3(resume 路径 + 回归守卫 #1):continue_volume 第 1 章冲突 → interrupt → awaiting →
|
||||
resume 带裁决 → 续跑 accept → done,且 resume 受理回执 chain_key="continue_volume"。
|
||||
|
||||
修复前 bug:resume 端点按 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_key(resume 端点据此读回,回归守卫 #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
|
||||
# 回归守卫 #1:resume 回执报真 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)
|
||||
216
tests/test_teardown_ingest_e2e.py
Normal file
216
tests/test_teardown_ingest_e2e.py
Normal 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:
|
||||
"""用例 2:teardown ingest → 201;rules 表真落行(拆书结论拍平成可读规则条目)。"""
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_tier_gateway_builder
|
||||
|
||||
app = create_app()
|
||||
# ingest(rules 无 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
137
tests/test_templates_e2e.py
Normal 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:
|
||||
"""用例 1:POST → 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 真源:模板行真落 pg(owner 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 → 204;DB 真源行被删。
|
||||
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:
|
||||
"""用例 2:title 空 → 422(schema min_length=1);DB 无行落库。"""
|
||||
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
|
||||
Reference in New Issue
Block a user