fix(backlog): 修评审 3 HIGH — resume chain_key 真值 / 模板空白校验 / 续写链 resume E2E
1. resume_chain 回报真 chain_key:从 awaiting job.result 读回(run 时 _chain_result 持久化),不再按 job.kind 推断(kind 恒为通用常量 "chain",从不在 SUPPORTED_CHAINS → 总错回退 draft_volume)。continue_volume 的 resume 回执现报真值。 2. 模板 title/body 拒纯空白:StringConstraints(strip_whitespace, min_length=1), " " strip 后为空 → 422。 3. 续写链 resume 路径 E2E:continue_volume 第 1 章冲突 → interrupt → awaiting → resume 带裁决 → done,断言 resume 回执 / job result chain_key="continue_volume" (回归守卫 #1)。 测试:+resume continue_volume 单测(chain_key 真值)+2 模板空白 422 单测 +1 续写链 resume E2E。重生成 TS 客户端(仅 description 文案变化)。
This commit is contained in:
@@ -365,6 +365,31 @@ async def test_resume_chain_awaiting_returns_202(
|
|||||||
assert len(_noop_run_chain_job) == 1
|
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(
|
async def test_resume_chain_non_awaiting_returns_409(
|
||||||
_noop_run_chain_job: list[dict[str, Any]],
|
_noop_run_chain_job: list[dict[str, Any]],
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -105,6 +105,23 @@ async def test_create_template_empty_body_returns_422() -> None:
|
|||||||
assert resp.status_code == 422
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_delete_template_returns_204_and_commits() -> None:
|
async def test_delete_template_returns_204_and_commits() -> None:
|
||||||
client, repo, session = _make_client()
|
client, repo, session = _make_client()
|
||||||
|
|||||||
@@ -215,7 +215,9 @@ async def resume_chain(
|
|||||||
f"job {job_id} 已被并发续跑抢占或非 awaiting_input 态,不可重复续跑",
|
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(
|
accept_op = build_accept_op(
|
||||||
session_factory=session_factory,
|
session_factory=session_factory,
|
||||||
@@ -251,6 +253,17 @@ async def resume_chain(
|
|||||||
return ChainResumeAccepted(job_id=job_id, chain_key=chain_key)
|
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:
|
def _chapter_repo_factory(session: AsyncSession) -> object:
|
||||||
"""链节点(review 重读草稿)按 session 建 chapter draft repo。"""
|
"""链节点(review 重读草稿)按 session 建 chapter draft repo。"""
|
||||||
from ww_core.domain.chapter_repo import SqlChapterRepo
|
from ww_core.domain.chapter_repo import SqlChapterRepo
|
||||||
|
|||||||
@@ -2,21 +2,25 @@
|
|||||||
|
|
||||||
snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
|
snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
|
||||||
单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。
|
单用户本地版:作者保存/复用提示词模板,可一键填入生成器的 brief/text。
|
||||||
`title`/`body` 非空(`min_length=1`,空 → FastAPI 422)。`category`/`tool_key` 可选。
|
`title`/`body` 非空(先 strip 再 `min_length=1`,纯空白 → 422)。`category`/`tool_key` 可选。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field, StringConstraints
|
||||||
|
|
||||||
|
# 先去首尾空白再校验长度:纯空白(" ")strip 后为空 → min_length=1 不满足 → 422。
|
||||||
|
NonBlankStr = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]
|
||||||
|
|
||||||
|
|
||||||
class TemplateCreateRequest(BaseModel):
|
class TemplateCreateRequest(BaseModel):
|
||||||
"""POST /templates:新建一条提示词模板。"""
|
"""POST /templates:新建一条提示词模板。"""
|
||||||
|
|
||||||
title: str = Field(min_length=1, description="模板标题")
|
title: NonBlankStr = Field(description="模板标题(非空,纯空白 → 422)")
|
||||||
body: str = Field(min_length=1, description="模板正文(一键填入生成器的 brief/text)")
|
body: NonBlankStr = Field(description="模板正文(非空,一键填入生成器的 brief/text)")
|
||||||
category: str | None = Field(default=None, description="可选分类")
|
category: str | None = Field(default=None, description="可选分类")
|
||||||
tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)")
|
tool_key: str | None = Field(default=None, description="可选关联生成器(NULL=通用)")
|
||||||
|
|
||||||
|
|||||||
4
apps/web/lib/api/schema.d.ts
vendored
4
apps/web/lib/api/schema.d.ts
vendored
@@ -1797,12 +1797,12 @@ export interface components {
|
|||||||
TemplateCreateRequest: {
|
TemplateCreateRequest: {
|
||||||
/**
|
/**
|
||||||
* Title
|
* Title
|
||||||
* @description 模板标题
|
* @description 模板标题(非空,纯空白 → 422)
|
||||||
*/
|
*/
|
||||||
title: string;
|
title: string;
|
||||||
/**
|
/**
|
||||||
* Body
|
* Body
|
||||||
* @description 模板正文(一键填入生成器的 brief/text)
|
* @description 模板正文(非空,一键填入生成器的 brief/text)
|
||||||
*/
|
*/
|
||||||
body: string;
|
body: string;
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@ from langgraph.checkpoint.memory import MemorySaver
|
|||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
from ww_agents import (
|
from ww_agents import (
|
||||||
|
Conflict,
|
||||||
ContinuityReview,
|
ContinuityReview,
|
||||||
ForeshadowReview,
|
ForeshadowReview,
|
||||||
PaceReview,
|
PaceReview,
|
||||||
@@ -73,10 +74,11 @@ class _RecordingChainAdapter:
|
|||||||
|
|
||||||
provider = _PROVIDER
|
provider = _PROVIDER
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, *, continuity_conflicts: list[Conflict] | None = None) -> None:
|
||||||
self._write_count = 0
|
self._write_count = 0
|
||||||
# (request_input, returned_text) 每次写章一条,按写章顺序。
|
# (request_input, returned_text) 每次写章一条,按写章顺序。
|
||||||
self.write_calls: list[tuple[str, str]] = []
|
self.write_calls: list[tuple[str, str]] = []
|
||||||
|
self._continuity = ContinuityReview(conflicts=continuity_conflicts or [])
|
||||||
|
|
||||||
def capabilities(self) -> Capabilities:
|
def capabilities(self) -> Capabilities:
|
||||||
return Capabilities(structured_output=True)
|
return Capabilities(structured_output=True)
|
||||||
@@ -89,8 +91,9 @@ class _RecordingChainAdapter:
|
|||||||
self.write_calls.append((str(req.input), text))
|
self.write_calls.append((str(req.input), text))
|
||||||
return ProviderResult(text=text, parsed=None, usage=_USAGE)
|
return ProviderResult(text=text, parsed=None, usage=_USAGE)
|
||||||
if schema is ContinuityReview:
|
if schema is ContinuityReview:
|
||||||
review = ContinuityReview(conflicts=[])
|
return ProviderResult(
|
||||||
return ProviderResult(text=review.model_dump_json(), parsed=review, usage=_USAGE)
|
text=self._continuity.model_dump_json(), parsed=self._continuity, usage=_USAGE
|
||||||
|
)
|
||||||
if schema is ForeshadowReview:
|
if schema is ForeshadowReview:
|
||||||
return ProviderResult(
|
return ProviderResult(
|
||||||
text=_FORESHADOW_REVIEW.model_dump_json(), parsed=_FORESHADOW_REVIEW, usage=_USAGE
|
text=_FORESHADOW_REVIEW.model_dump_json(), parsed=_FORESHADOW_REVIEW, usage=_USAGE
|
||||||
@@ -300,3 +303,96 @@ async def test_draft_volume_does_not_inject_prior_chapter_into_second_write(
|
|||||||
finally:
|
finally:
|
||||||
if project_uuid is not None:
|
if project_uuid is not None:
|
||||||
await _cleanup(e2e_sm, project_uuid)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user