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:
Yaojia Wang
2026-06-23 20:45:06 +02:00
parent 1a402f5ccc
commit 1921b037a0
7 changed files with 166 additions and 11 deletions

View File

@@ -28,6 +28,7 @@ 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,
@@ -73,10 +74,11 @@ class _RecordingChainAdapter:
provider = _PROVIDER
def __init__(self) -> None:
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)
@@ -89,8 +91,9 @@ class _RecordingChainAdapter:
self.write_calls.append((str(req.input), text))
return ProviderResult(text=text, parsed=None, usage=_USAGE)
if schema is ContinuityReview:
review = ContinuityReview(conflicts=[])
return ProviderResult(text=review.model_dump_json(), parsed=review, usage=_USAGE)
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
@@ -300,3 +303,96 @@ async def test_draft_volume_does_not_inject_prior_chapter_into_second_write(
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)
async def test_continue_volume_conflict_interrupts_then_resume_reports_chain_key(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""用例 3resume 路径 + 回归守卫 #1continue_volume 第 1 章冲突 → interrupt → awaiting →
resume 带裁决 → 续跑 accept → done且 resume 受理回执 chain_key="continue_volume"
修复前 bugresume 端点按 job.kind 推断 chain_key恒为通用常量 "chain",从不在
SUPPORTED_CHAINS→ 总回退 "draft_volume"——continue_volume 的 resume 回执报错类型。
本测试断言 resume 回执 / job result 均反映真值 "continue_volume"
interrupt+resume 横跨两次端点调用,靠单进程单 MemorySaver同一 thread_id 检查点)。
"""
conflict = Conflict(type="性格漂移", where="第2段", suggestion="统一主角姓名为「萧寒」")
adapter = _RecordingChainAdapter(continuity_conflicts=[conflict])
saver = MemorySaver() # 单实例横跨 run + resume 两次端点调用
app = _build_app(adapter, saver, e2e_sm)
transport = httpx.ASGITransport(app=app)
project_uuid: uuid.UUID | None = None
try:
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
create_resp = await client.post(
"/projects", json={"title": "F2 continue_volume resume 作品"}
)
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
# 1) 发起 continue_volume第 1 章四审报冲突 → interrupt → job awaiting_input。
run_resp = await client.post(
f"/projects/{project_id}/chains/continue_volume/run",
json={"start_chapter_no": 1, "count": 1},
)
assert run_resp.status_code == 202, run_resp.text
assert run_resp.json()["chain_key"] == "continue_volume"
job_id = run_resp.json()["job_id"]
job = (await client.get(f"/jobs/{job_id}")).json()
assert job["status"] == "awaiting_input", job
assert job["result"]["awaiting_chapter"] == 1
assert job["result"]["completed"] is False
# job result 已持久真 chain_keyresume 端点据此读回,回归守卫 #1
assert job["result"]["chain_key"] == "continue_volume"
# 2) resume作者裁决冲突 → 续跑 accept → done。回执须报真 chain_key。
resume_resp = await client.post(
f"/projects/{project_id}/chains/runs/{job_id}/resume",
json={
"decisions": [
{"conflict_index": 0, "verdict": "ignore", "note": "笔误,忽略"}
]
},
)
assert resume_resp.status_code == 202, resume_resp.text
# 回归守卫 #1resume 回执报真 chain_key而非旧 bug 的 "draft_volume"。
assert resume_resp.json()["chain_key"] == "continue_volume"
job = (await client.get(f"/jobs/{job_id}")).json()
assert job["status"] == "done", job
assert job["result"]["written"] == [1]
assert job["result"]["completed"] is True
assert job["result"]["chain_key"] == "continue_volume"
# DB 真源resume 后第 1 章 accepted + digest 一行。
assert project_uuid is not None
async with e2e_sm() as verify:
accepted = (
(
await verify.execute(
select(Chapter).where(
Chapter.project_id == project_uuid, Chapter.status == "accepted"
)
)
)
.scalars()
.all()
)
assert {c.chapter_no for c in accepted} == {1}
digests = (
(
await verify.execute(
select(ChapterDigest).where(ChapterDigest.project_id == project_uuid)
)
)
.scalars()
.all()
)
assert [d.chapter_no for d in digests] == [1]
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)