feat(chain): F2 续写式链 continue_volume——上一章 accepted 正文作前文引子

- core write_chapter 支持续写模式:chain_key==continue_volume 时经注入的
  chapter_repo.latest_accepted 读上一章已验收终稿,build_continuation_context
  改写 volatile(仅断点后块,守不变量 #9;core 不 import apps/api)。
- ChapterDraftRepo 协议加 latest_accepted;draft_volume 行为不变(无回归)。
- apps/api SUPPORTED_CHAINS 加 continue_volume;run 端点透传 chain_key 选模式
  (chain_runner 经 initial_chain_state 把 chain_key 落入 ChainState)。
- 单测(mock 网关+MemorySaver+fake session):续写第二章请求含第一章正文;
  draft_volume 不注入前文;端点 202 透传 chain_key。守不变量 #1/#5。
This commit is contained in:
Yaojia Wang
2026-06-23 20:09:08 +02:00
parent 61398a1452
commit 18aa87d751
4 changed files with 157 additions and 10 deletions

View File

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