feat(orchestrator): C1 多章工作流链图 — write→review→decide→accept 有环图
实现 chain-workflow 设计 §3 的 LangGraph 链图,激活休眠资产
(checkpointer + interrupt-on-conflict)。
- packages/core/ww_core/orchestrator/chain/{state,nodes,graph}.py:
- ChainState(§3.1):只放控制流(章号/计数/written/has_conflicts),
正文/审稿/冲突明文落领域表,resume 重读(不变量 #1/#3/#5)。
- 四节点 write_chapter/review_chapter/decide/accept_chapter(§3.2):
各自短事务、独立 session;decide 纯逻辑读最近审稿留痕定 has_conflicts;
accept 节点起始处遇冲突 interrupt() 暂停交人,恢复值=裁决清单。
- build_chain_graph(§3.3 签名):节点 DB 依赖经 session_factory +
repo 工厂 + assemble/accept_op 闭包默认参绑定(langgraph 不收 partial);
条件边实现循环(current<=last 回 write)+ interrupt。
- 验收事务(apps/api)经 accept_op 闭包注入,守目录所有权 + 不变量 #3/#4。
- 收集版写章:复用 build_write_request 同一组装路径(stream=False → run)。
- 删除无人调用的 build_write_graph 工厂(write_node 本体保留,被链复用);
清理其 __init__ 导出 + 旧单节点图单测(YAGNI,解决审计死代码)。
- 单测(mock gateway + fake session_factory + fake repos + MemorySaver):
无冲突全跑到 END 且 written 满 / 有冲突→__interrupt__ / resume(Command)→
accept→END / decide 纯逻辑 / accept 推进章号;零 token 零联网。
门禁:packages ruff/format/mypy 干净;pytest 314 passed。
This commit is contained in:
375
packages/core/tests/test_chain_graph.py
Normal file
375
packages/core/tests/test_chain_graph.py
Normal file
@@ -0,0 +1,375 @@
|
||||
"""多章链图单测(chain-workflow 设计 §3 / §10)——节点纯逻辑 + 全图 MemorySaver 流程。
|
||||
|
||||
全部注入 mock 网关 + fake session_factory + fake repos + MemorySaver,无真 LLM、无真
|
||||
Postgres。验:① 无冲突全跑到 END 且 written 满;② 有冲突 → `__interrupt__`;③ resume
|
||||
(`Command(resume=decisions)`)→ accept → END;④ `decide` 纯逻辑;⑤ accept 推进章号。
|
||||
asyncio_mode=auto。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from langgraph.types import Command
|
||||
from pydantic import BaseModel
|
||||
from ww_agents import Conflict, ContinuityReview
|
||||
from ww_core.orchestrator.chain import (
|
||||
accept_chapter,
|
||||
build_chain_graph,
|
||||
decide,
|
||||
has_conflicts,
|
||||
initial_chain_state,
|
||||
write_chapter,
|
||||
)
|
||||
from ww_llm_gateway.types import LlmRequest, LlmResponse, ServedBy, Usage
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
|
||||
# ---- 测试替身 ----
|
||||
|
||||
|
||||
def _usage() -> Usage:
|
||||
return Usage(
|
||||
provider="fake",
|
||||
model="fake",
|
||||
input_tokens=1,
|
||||
output_tokens=1,
|
||||
cost_minor=0,
|
||||
currency="USD",
|
||||
)
|
||||
|
||||
|
||||
class FakeChainGateway:
|
||||
"""链网关 mock:write(无 output_schema)回固定正文;审稿按 `output_schema` 回 parsed。
|
||||
|
||||
`conflicts`:注给 continuity 审的冲突清单(空 = 无冲突章)。其余审 schema 回各自空实例。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
draft_text: str,
|
||||
conflicts: list[Conflict],
|
||||
by_schema: dict[type, BaseModel],
|
||||
) -> None:
|
||||
self._draft = draft_text
|
||||
self._continuity = ContinuityReview(conflicts=conflicts)
|
||||
self._by_schema = by_schema
|
||||
self.write_calls = 0
|
||||
self.review_calls = 0
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse:
|
||||
schema: type[BaseModel] | None = req.output_schema
|
||||
if schema is None:
|
||||
self.write_calls += 1
|
||||
return LlmResponse(
|
||||
text=self._draft,
|
||||
parsed=None,
|
||||
usage=_usage(),
|
||||
served_by=ServedBy(provider="fake", model="fake"),
|
||||
)
|
||||
self.review_calls += 1
|
||||
parsed: BaseModel = (
|
||||
self._continuity if schema is ContinuityReview else self._by_schema[schema]
|
||||
)
|
||||
return LlmResponse(
|
||||
text=parsed.model_dump_json(),
|
||||
parsed=parsed,
|
||||
usage=_usage(),
|
||||
served_by=ServedBy(provider="fake", model="fake"),
|
||||
)
|
||||
|
||||
|
||||
class FakeDraftView:
|
||||
def __init__(self, content: str) -> None:
|
||||
self.content = content
|
||||
|
||||
|
||||
class FakeChapterRepo:
|
||||
"""内存章草稿 repo:save_draft 存正文,get_draft 回读。"""
|
||||
|
||||
def __init__(self, store: dict[int, str]) -> None:
|
||||
self._store = store
|
||||
|
||||
async def save_draft(
|
||||
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
|
||||
) -> Any:
|
||||
self._store[chapter_no] = text
|
||||
return FakeDraftView(text)
|
||||
|
||||
async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> Any:
|
||||
content = self._store.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:
|
||||
self.id = uuid.uuid4()
|
||||
self.chapter_no = chapter_no
|
||||
self.conflicts = conflicts
|
||||
|
||||
|
||||
class FakeReviewRepo:
|
||||
"""内存审稿 repo:record 追加留痕,list_for_chapter 回新→旧。"""
|
||||
|
||||
def __init__(self, records: dict[int, list[FakeReviewRecord]]) -> None:
|
||||
self._records = records
|
||||
|
||||
async def record(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
*,
|
||||
chapter_version: int | None = None,
|
||||
conflicts: list[dict[str, Any]],
|
||||
foreshadow_sug: list[dict[str, Any]] | None = None,
|
||||
style: dict[str, Any] | None = None,
|
||||
pace: dict[str, Any] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> Any:
|
||||
rec = FakeReviewRecord(chapter_no, list(conflicts))
|
||||
self._records.setdefault(chapter_no, []).insert(0, rec)
|
||||
return rec
|
||||
|
||||
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[Any]:
|
||||
return list(self._records.get(chapter_no, []))
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
|
||||
class FakeContext:
|
||||
def __init__(self) -> None:
|
||||
self.stable_core = "## 世界观硬规则\n灵气可凝丹"
|
||||
self.volatile = "## 写作指令\n写第 N 章"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _session_factory() -> AsyncIterator[FakeSession]:
|
||||
"""fake 短事务 session(每次新建;满足 SessionFactory)。"""
|
||||
yield FakeSession()
|
||||
|
||||
|
||||
def _empty_by_schema() -> dict[type, BaseModel]:
|
||||
"""非 continuity 审(pace/style/foreshadow)的空 parsed——按各 spec 的 output_schema。"""
|
||||
from ww_agents import foreshadow_spec, pace_spec, style_drift_spec
|
||||
|
||||
out: dict[type, BaseModel] = {}
|
||||
for spec in (foreshadow_spec, pace_spec, style_drift_spec):
|
||||
schema = spec.output_schema
|
||||
assert schema is not None
|
||||
out[schema] = schema()
|
||||
return out
|
||||
|
||||
|
||||
def _make_harness(*, conflicts: list[Conflict]) -> dict[str, Any]:
|
||||
"""造一套共享内存态 + 工厂闭包 + accept 间谍,供图/节点测试复用。"""
|
||||
draft_store: dict[int, str] = {}
|
||||
review_store: dict[int, list[FakeReviewRecord]] = {}
|
||||
accepted: list[dict[str, Any]] = []
|
||||
|
||||
gateway = FakeChainGateway(
|
||||
draft_text="第 N 章正文。", conflicts=conflicts, by_schema=_empty_by_schema()
|
||||
)
|
||||
|
||||
def memory_repos_factory(session: Any) -> Any:
|
||||
return object() # assemble 被 fake 替换,不实际用 repos
|
||||
|
||||
def chapter_repo_factory(session: Any) -> Any:
|
||||
return FakeChapterRepo(draft_store)
|
||||
|
||||
def review_repo_factory(session: Any) -> Any:
|
||||
return FakeReviewRepo(review_store)
|
||||
|
||||
async def assemble(repos: Any, project_id: uuid.UUID, chapter_no: int) -> Any:
|
||||
return FakeContext()
|
||||
|
||||
async def accept_op(
|
||||
*,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
user_id: uuid.UUID,
|
||||
decisions: list[Any],
|
||||
) -> None:
|
||||
accepted.append({"chapter_no": chapter_no, "decisions": list(decisions)})
|
||||
|
||||
return {
|
||||
"gateway": gateway,
|
||||
"session_factory": _session_factory,
|
||||
"memory_repos_factory": memory_repos_factory,
|
||||
"chapter_repo_factory": chapter_repo_factory,
|
||||
"review_repo_factory": review_repo_factory,
|
||||
"assemble": assemble,
|
||||
"accept_op": accept_op,
|
||||
"accepted": accepted,
|
||||
"draft_store": draft_store,
|
||||
"review_store": review_store,
|
||||
}
|
||||
|
||||
|
||||
def _build(h: dict[str, Any], checkpointer: Any) -> Any:
|
||||
return build_chain_graph(
|
||||
h["gateway"],
|
||||
session_factory=h["session_factory"],
|
||||
memory_repos_factory=h["memory_repos_factory"],
|
||||
chapter_repo_factory=h["chapter_repo_factory"],
|
||||
review_repo_factory=h["review_repo_factory"],
|
||||
assemble=h["assemble"],
|
||||
accept_op=h["accept_op"],
|
||||
checkpointer=checkpointer,
|
||||
)
|
||||
|
||||
|
||||
# ---- decide 纯逻辑 ----
|
||||
|
||||
|
||||
async def test_decide_no_conflicts_sets_false() -> None:
|
||||
review_store: dict[int, list[FakeReviewRecord]] = {1: [FakeReviewRecord(1, [])]}
|
||||
|
||||
def review_repo_factory(session: Any) -> Any:
|
||||
return FakeReviewRepo(review_store)
|
||||
|
||||
out = await decide(
|
||||
{"project_id": PROJECT, "user_id": USER, "current_chapter_no": 1},
|
||||
session_factory=_session_factory,
|
||||
review_repo_factory=review_repo_factory,
|
||||
)
|
||||
|
||||
assert out == {"has_conflicts": False}
|
||||
|
||||
|
||||
async def test_decide_with_conflicts_sets_true() -> None:
|
||||
review_store: dict[int, list[FakeReviewRecord]] = {
|
||||
1: [FakeReviewRecord(1, [{"kind": "x", "detail": "矛盾"}])]
|
||||
}
|
||||
|
||||
def review_repo_factory(session: Any) -> Any:
|
||||
return FakeReviewRepo(review_store)
|
||||
|
||||
out = await decide(
|
||||
{"project_id": PROJECT, "user_id": USER, "current_chapter_no": 1},
|
||||
session_factory=_session_factory,
|
||||
review_repo_factory=review_repo_factory,
|
||||
)
|
||||
|
||||
assert out == {"has_conflicts": True}
|
||||
|
||||
|
||||
def test_has_conflicts_reads_flag() -> None:
|
||||
assert has_conflicts({"has_conflicts": True}) is True
|
||||
assert has_conflicts({"has_conflicts": False}) is False
|
||||
assert has_conflicts({}) is False
|
||||
|
||||
|
||||
# ---- accept 节点:推进章号 + 追加 written + 透传裁决 ----
|
||||
|
||||
|
||||
async def test_accept_chapter_advances_and_records_decisions() -> None:
|
||||
accepted: list[dict[str, Any]] = []
|
||||
|
||||
async def accept_op(
|
||||
*,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
user_id: uuid.UUID,
|
||||
decisions: list[Any],
|
||||
) -> None:
|
||||
accepted.append({"chapter_no": chapter_no, "decisions": list(decisions)})
|
||||
|
||||
out = await accept_chapter(
|
||||
{
|
||||
"project_id": PROJECT,
|
||||
"user_id": USER,
|
||||
"current_chapter_no": 3,
|
||||
"written": [1, 2],
|
||||
"last_chapter_no": 5,
|
||||
},
|
||||
decisions=[{"conflict_index": 0, "verdict": "ignore"}],
|
||||
accept_op=accept_op,
|
||||
)
|
||||
|
||||
assert out["current_chapter_no"] == 4
|
||||
assert out["written"] == [1, 2, 3]
|
||||
assert out["has_conflicts"] is False
|
||||
assert accepted == [
|
||||
{"chapter_no": 3, "decisions": [{"conflict_index": 0, "verdict": "ignore"}]}
|
||||
]
|
||||
|
||||
|
||||
# ---- write 节点:收集版(非流式)落稿 ----
|
||||
|
||||
|
||||
async def test_write_chapter_saves_collected_draft() -> None:
|
||||
h = _make_harness(conflicts=[])
|
||||
state = initial_chain_state(project_id=PROJECT, user_id=USER, start_chapter_no=1, count=1)
|
||||
|
||||
out = await write_chapter(
|
||||
state,
|
||||
gateway=h["gateway"],
|
||||
session_factory=h["session_factory"],
|
||||
memory_repos_factory=h["memory_repos_factory"],
|
||||
chapter_repo_factory=h["chapter_repo_factory"],
|
||||
assemble=h["assemble"],
|
||||
)
|
||||
|
||||
assert out == {} # state 不变(正文在 DB)
|
||||
assert h["draft_store"][1] == "第 N 章正文。"
|
||||
# 收集版:write 走非流式 run
|
||||
assert h["gateway"].write_calls == 1
|
||||
|
||||
|
||||
# ---- 全图(MemorySaver):无冲突全自动跑满 ----
|
||||
|
||||
|
||||
async def test_chain_runs_all_chapters_when_no_conflicts() -> None:
|
||||
h = _make_harness(conflicts=[])
|
||||
graph = _build(h, MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "chain-noconflict"}}
|
||||
initial = initial_chain_state(project_id=PROJECT, user_id=USER, start_chapter_no=1, count=2)
|
||||
|
||||
final = await graph.ainvoke(initial, config=config)
|
||||
|
||||
assert "__interrupt__" not in final
|
||||
assert final["written"] == [1, 2]
|
||||
assert final["current_chapter_no"] == 3 # last(2) + 1,越界 → END
|
||||
assert [a["chapter_no"] for a in h["accepted"]] == [1, 2]
|
||||
assert h["accepted"][0]["decisions"] == [] # 无冲突章 decisions=[]
|
||||
|
||||
|
||||
# ---- 全图:有冲突 → interrupt → resume → accept → END ----
|
||||
|
||||
|
||||
async def test_chain_interrupts_on_conflict_then_resumes() -> None:
|
||||
conflict = Conflict(type="性格漂移", where="第3段", suggestion="统一主角姓名为张三")
|
||||
h = _make_harness(conflicts=[conflict])
|
||||
saver = MemorySaver() # 同进程同 saver 跨两次 invoke → interrupt 可恢复
|
||||
graph = _build(h, saver)
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "chain-conflict"}}
|
||||
initial = initial_chain_state(project_id=PROJECT, user_id=USER, start_chapter_no=1, count=1)
|
||||
|
||||
# 初始 run:第 1 章四审报冲突 → interrupt 暂停。
|
||||
interrupted = await graph.ainvoke(initial, config=config)
|
||||
|
||||
assert "__interrupt__" in interrupted
|
||||
assert h["accepted"] == [] # 尚未验收
|
||||
|
||||
# resume:作者裁决该冲突 → 续跑 accept → END。
|
||||
decisions = [{"conflict_index": 0, "verdict": "ignore", "note": "笔误,后续统一"}]
|
||||
final = await graph.ainvoke(Command(resume=decisions), config=config)
|
||||
|
||||
assert "__interrupt__" not in final
|
||||
assert final["written"] == [1]
|
||||
assert [a["chapter_no"] for a in h["accepted"]] == [1]
|
||||
assert h["accepted"][0]["decisions"] == decisions # 裁决透传给 accept_op
|
||||
@@ -8,15 +8,11 @@ from __future__ import annotations
|
||||
import uuid
|
||||
|
||||
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from ww_core.orchestrator import (
|
||||
EVENT_DONE,
|
||||
EVENT_ERROR,
|
||||
EVENT_TOKEN,
|
||||
WRITE_NODE,
|
||||
ChapterState,
|
||||
build_write_graph,
|
||||
build_write_request,
|
||||
normalize_deltas,
|
||||
stream_chapter_draft,
|
||||
@@ -135,31 +131,5 @@ async def test_normalize_deltas_maps_unexpected_error_to_internal() -> None:
|
||||
assert events[0].data["code"] == ErrorCode.INTERNAL
|
||||
|
||||
|
||||
# ---- 图编译:装节点 + checkpointer(MemorySaver,不连真 Postgres) ----
|
||||
|
||||
|
||||
def test_build_write_graph_compiles_with_checkpointer() -> None:
|
||||
gateway = FakeStreamGateway(["x"])
|
||||
|
||||
graph = build_write_graph(gateway, checkpointer=MemorySaver())
|
||||
|
||||
assert WRITE_NODE in graph.get_graph().nodes
|
||||
assert graph.checkpointer is not None
|
||||
|
||||
|
||||
async def test_compiled_graph_runs_write_node_end_to_end() -> None:
|
||||
gateway = FakeStreamGateway(["云", "深"])
|
||||
graph = build_write_graph(gateway, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "t-1"}}
|
||||
initial: ChapterState = {
|
||||
"project_id": PROJECT,
|
||||
"chapter_no": 1,
|
||||
"user_id": USER,
|
||||
"stable_core": STABLE,
|
||||
"volatile": VOLATILE,
|
||||
"draft": "",
|
||||
}
|
||||
|
||||
final = await graph.ainvoke(initial, config=config)
|
||||
|
||||
assert final["draft"] == "云深"
|
||||
# 单 `write` 节点图 `build_write_graph` 已删(链图取代,见 chain-workflow 设计 §3.3);
|
||||
# write 节点本体 `write_node` 仍由上面的 stream/run 用例直接覆盖(注入 mock 网关,无需图运行时)。
|
||||
|
||||
Reference in New Issue
Block a user