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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user