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
|
import uuid
|
||||||
|
|
||||||
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
|
from fakes_orchestrator import FakeStreamGateway, RaisingGateway
|
||||||
from langchain_core.runnables import RunnableConfig
|
|
||||||
from langgraph.checkpoint.memory import MemorySaver
|
|
||||||
from ww_core.orchestrator import (
|
from ww_core.orchestrator import (
|
||||||
EVENT_DONE,
|
EVENT_DONE,
|
||||||
EVENT_ERROR,
|
EVENT_ERROR,
|
||||||
EVENT_TOKEN,
|
EVENT_TOKEN,
|
||||||
WRITE_NODE,
|
|
||||||
ChapterState,
|
ChapterState,
|
||||||
build_write_graph,
|
|
||||||
build_write_request,
|
build_write_request,
|
||||||
normalize_deltas,
|
normalize_deltas,
|
||||||
stream_chapter_draft,
|
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
|
assert events[0].data["code"] == ErrorCode.INTERNAL
|
||||||
|
|
||||||
|
|
||||||
# ---- 图编译:装节点 + checkpointer(MemorySaver,不连真 Postgres) ----
|
# 单 `write` 节点图 `build_write_graph` 已删(链图取代,见 chain-workflow 设计 §3.3);
|
||||||
|
# write 节点本体 `write_node` 仍由上面的 stream/run 用例直接覆盖(注入 mock 网关,无需图运行时)。
|
||||||
|
|
||||||
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"] == "云深"
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""编排器(C4 / ARCH §5.2)——写章图 + 审稿子图 + SSE 归一 + Postgres checkpointer。
|
"""编排器(C4 / ARCH §5.2)——写章图 + 审稿子图 + SSE 归一 + Postgres checkpointer。
|
||||||
|
|
||||||
- M1:单 `write` 节点(`build_write_graph`)。
|
- write 节点本体 `write_node`(被多章链 `chain` 复用;M1 单节点图 `build_write_graph` 已删)。
|
||||||
- M2:并行审子图(`build_review_graph`:START→各审并行→collect 落 `chapter_reviews`)+
|
- M2:并行审子图(`build_review_graph`:START→各审并行→collect 落 `chapter_reviews`)+
|
||||||
审稿 SSE(`section`/`conflict` 事件 + `normalize_review`)。
|
审稿 SSE(`section`/`conflict` 事件 + `normalize_review`)。
|
||||||
- accept(验收事务/`interrupt_before`)不在本图——属 T2.4(确定性代码读领域表,R3)。
|
- accept(验收事务/`interrupt_before`)不在本图——属 T2.4(确定性代码读领域表,R3)。
|
||||||
@@ -37,7 +37,6 @@ from .graph import (
|
|||||||
REVIEW_SPECS,
|
REVIEW_SPECS,
|
||||||
WRITE_NODE,
|
WRITE_NODE,
|
||||||
build_review_graph,
|
build_review_graph,
|
||||||
build_write_graph,
|
|
||||||
setup_checkpointer,
|
setup_checkpointer,
|
||||||
)
|
)
|
||||||
from .outline_node import build_outline_request, run_outline
|
from .outline_node import build_outline_request, run_outline
|
||||||
@@ -115,7 +114,6 @@ __all__ = [
|
|||||||
"build_review_request",
|
"build_review_request",
|
||||||
"build_style_extract_request",
|
"build_style_extract_request",
|
||||||
"build_worldbuilder_context",
|
"build_worldbuilder_context",
|
||||||
"build_write_graph",
|
|
||||||
"build_write_request",
|
"build_write_request",
|
||||||
"collect_reviews",
|
"collect_reviews",
|
||||||
"conflict_event",
|
"conflict_event",
|
||||||
|
|||||||
54
packages/core/ww_core/orchestrator/chain/__init__.py
Normal file
54
packages/core/ww_core/orchestrator/chain/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""多章工作流链(chain-workflow 设计)——`write → review → decide → accept` 有环图。
|
||||||
|
|
||||||
|
第一个真正需要 LangGraph 的场景(有环 + 可持久 + 可中断):批量量产 K 章,每章过
|
||||||
|
`assemble → write → 四审 → digest` 闭环,遇冲突 `interrupt()` 暂停交人裁决,resume 续跑。
|
||||||
|
激活休眠资产(checkpointer + interrupt)。详见 docs/design/chain-workflow.md。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .graph import (
|
||||||
|
ACCEPT_CHAPTER,
|
||||||
|
DECIDE,
|
||||||
|
REVIEW_CHAPTER,
|
||||||
|
WRITE_CHAPTER,
|
||||||
|
build_chain_graph,
|
||||||
|
initial_chain_state,
|
||||||
|
)
|
||||||
|
from .nodes import (
|
||||||
|
AcceptChapterOp,
|
||||||
|
AssembleContext,
|
||||||
|
AssembleFn,
|
||||||
|
ChapterDraftRepo,
|
||||||
|
MemoryReposFactory,
|
||||||
|
ReviewRecordRepo,
|
||||||
|
SessionFactory,
|
||||||
|
accept_chapter,
|
||||||
|
decide,
|
||||||
|
has_conflicts,
|
||||||
|
review_chapter,
|
||||||
|
write_chapter,
|
||||||
|
)
|
||||||
|
from .state import ChainState
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACCEPT_CHAPTER",
|
||||||
|
"DECIDE",
|
||||||
|
"REVIEW_CHAPTER",
|
||||||
|
"WRITE_CHAPTER",
|
||||||
|
"AcceptChapterOp",
|
||||||
|
"AssembleContext",
|
||||||
|
"AssembleFn",
|
||||||
|
"ChainState",
|
||||||
|
"ChapterDraftRepo",
|
||||||
|
"MemoryReposFactory",
|
||||||
|
"ReviewRecordRepo",
|
||||||
|
"SessionFactory",
|
||||||
|
"accept_chapter",
|
||||||
|
"build_chain_graph",
|
||||||
|
"decide",
|
||||||
|
"has_conflicts",
|
||||||
|
"initial_chain_state",
|
||||||
|
"review_chapter",
|
||||||
|
"write_chapter",
|
||||||
|
]
|
||||||
170
packages/core/ww_core/orchestrator/chain/graph.py
Normal file
170
packages/core/ww_core/orchestrator/chain/graph.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""多章链图工厂 `build_chain_graph`(chain-workflow 设计 §3.3)。
|
||||||
|
|
||||||
|
链 = LangGraph 有环图:`write → review → decide →(无冲突)→ accept →(还有章)→ write`,
|
||||||
|
`decide →(有冲突)→ interrupt ⏸ → accept`,写满 K 章 → END。本图**激活休眠资产**:
|
||||||
|
checkpointer(持久控制流 + pending handle,跨小时可恢复)+ interrupt(遇冲突暂停交人)。
|
||||||
|
|
||||||
|
依赖注入纪律(与现有「gateway 闭包绑定」同模式):节点 DB 依赖经 `session_factory` +
|
||||||
|
`*_repo_factory` + `accept_op` 闭包绑入图节点。langgraph 不收 `functools.partial`,故用
|
||||||
|
**默认参绑定**(见 memory/gotchas.md)。
|
||||||
|
|
||||||
|
checkpointer 纪律(CLAUDE.md「LangGraph」):`setup()` 只在 migrations/CI 跑(`setup_checkpointer`
|
||||||
|
在 `orchestrator/graph.py`,迁移/CI 调一次),build 函数绝不触碰 DDL;单测注 `MemorySaver`。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from langgraph.graph import END, START, StateGraph
|
||||||
|
from langgraph.types import interrupt
|
||||||
|
from ww_agents import AgentSpec
|
||||||
|
|
||||||
|
from .._protocols import GatewayRun
|
||||||
|
from ..graph import REVIEW_SPECS
|
||||||
|
from . import nodes
|
||||||
|
from .nodes import (
|
||||||
|
AcceptChapterOp,
|
||||||
|
AssembleFn,
|
||||||
|
ChapterDraftRepo,
|
||||||
|
MemoryReposFactory,
|
||||||
|
ReviewRecordRepo,
|
||||||
|
SessionFactory,
|
||||||
|
)
|
||||||
|
from .state import ChainState
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||||
|
from langgraph.graph.state import CompiledStateGraph
|
||||||
|
|
||||||
|
WRITE_CHAPTER = "write_chapter"
|
||||||
|
REVIEW_CHAPTER = "review_chapter"
|
||||||
|
DECIDE = "decide"
|
||||||
|
ACCEPT_CHAPTER = "accept_chapter"
|
||||||
|
|
||||||
|
|
||||||
|
def build_chain_graph(
|
||||||
|
gateway: GatewayRun,
|
||||||
|
*,
|
||||||
|
session_factory: SessionFactory,
|
||||||
|
memory_repos_factory: MemoryReposFactory,
|
||||||
|
chapter_repo_factory: Callable[[Any], ChapterDraftRepo],
|
||||||
|
review_repo_factory: Callable[[Any], ReviewRecordRepo],
|
||||||
|
assemble: AssembleFn,
|
||||||
|
accept_op: AcceptChapterOp,
|
||||||
|
review_specs: Sequence[AgentSpec] = REVIEW_SPECS,
|
||||||
|
checkpointer: BaseCheckpointSaver[Any] | None = None,
|
||||||
|
) -> CompiledStateGraph[ChainState, None, ChainState, ChainState]:
|
||||||
|
"""构建并编译 `draft_volume` 多章链图。
|
||||||
|
|
||||||
|
节点(`write_chapter`/`review_chapter`/`decide`/`accept_chapter`)经默认参绑定把
|
||||||
|
`gateway`/`session_factory`/各 repo 工厂/`assemble`/`accept_op` 闭包绑入。
|
||||||
|
条件边:`decide` 命中冲突 → `accept_chapter` 内 `interrupt()` 暂停交人;
|
||||||
|
`accept_chapter` 后判 `current_chapter_no <= last_chapter_no` 回 `write_chapter` 或 END。
|
||||||
|
|
||||||
|
`checkpointer`:运行时传 Postgres saver;单测传 `MemorySaver`(同进程跨 invoke 可恢复)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _write(state: ChainState) -> dict[str, Any]:
|
||||||
|
return await nodes.write_chapter(
|
||||||
|
state,
|
||||||
|
gateway=gateway,
|
||||||
|
session_factory=session_factory,
|
||||||
|
memory_repos_factory=memory_repos_factory,
|
||||||
|
chapter_repo_factory=chapter_repo_factory,
|
||||||
|
assemble=assemble,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _review(state: ChainState) -> dict[str, Any]:
|
||||||
|
return await nodes.review_chapter(
|
||||||
|
state,
|
||||||
|
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,
|
||||||
|
review_specs=review_specs,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _decide(state: ChainState) -> dict[str, Any]:
|
||||||
|
return await nodes.decide(
|
||||||
|
state,
|
||||||
|
session_factory=session_factory,
|
||||||
|
review_repo_factory=review_repo_factory,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _accept(state: ChainState) -> dict[str, Any]:
|
||||||
|
# interrupt 放节点**起始**(CLAUDE.md「LangGraph」):有冲突 → 暂停交人,恢复值 =
|
||||||
|
# 该章裁决清单;无冲突 → decisions=[],直接验收。恢复后 accept_op 落库(不变量 #3)。
|
||||||
|
decisions: list[Any] = []
|
||||||
|
if nodes.has_conflicts(state):
|
||||||
|
resumed = interrupt({"chapter_no": state["current_chapter_no"]})
|
||||||
|
decisions = list(resumed or [])
|
||||||
|
return await nodes.accept_chapter(state, decisions=decisions, accept_op=accept_op)
|
||||||
|
|
||||||
|
builder: StateGraph[ChainState, None, ChainState, ChainState] = StateGraph(ChainState)
|
||||||
|
builder.add_node(WRITE_CHAPTER, _write)
|
||||||
|
builder.add_node(REVIEW_CHAPTER, _review)
|
||||||
|
builder.add_node(DECIDE, _decide)
|
||||||
|
builder.add_node(ACCEPT_CHAPTER, _accept)
|
||||||
|
|
||||||
|
builder.add_edge(START, WRITE_CHAPTER)
|
||||||
|
builder.add_edge(WRITE_CHAPTER, REVIEW_CHAPTER)
|
||||||
|
builder.add_edge(REVIEW_CHAPTER, DECIDE)
|
||||||
|
# decide 后无论有无冲突都进 accept;interrupt(若有冲突)在 accept 节点起始处暂停。
|
||||||
|
builder.add_edge(DECIDE, ACCEPT_CHAPTER)
|
||||||
|
# accept 后条件边:还有章 → 回 write 续跑;写满 K 章 → END。
|
||||||
|
builder.add_conditional_edges(
|
||||||
|
ACCEPT_CHAPTER,
|
||||||
|
_should_continue,
|
||||||
|
{WRITE_CHAPTER: WRITE_CHAPTER, END: END},
|
||||||
|
)
|
||||||
|
return builder.compile(checkpointer=checkpointer)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_continue(state: ChainState) -> str:
|
||||||
|
"""accept 后路由:`current_chapter_no <= last_chapter_no` → 续写下一章;否则 END。
|
||||||
|
|
||||||
|
`accept_chapter` 已把 `current_chapter_no += 1`,故此处直接比较即判「是否还有章」。
|
||||||
|
纯函数,便于单测。
|
||||||
|
"""
|
||||||
|
if state["current_chapter_no"] <= state["last_chapter_no"]:
|
||||||
|
return WRITE_CHAPTER
|
||||||
|
return END
|
||||||
|
|
||||||
|
|
||||||
|
def initial_chain_state(
|
||||||
|
*,
|
||||||
|
project_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
start_chapter_no: int,
|
||||||
|
count: int,
|
||||||
|
chain_key: str = "draft_volume",
|
||||||
|
) -> ChainState:
|
||||||
|
"""构造初始 `ChainState`(区间 [start, start+count-1],current=start,written=[])。
|
||||||
|
|
||||||
|
纯函数:服务层 / 测试构初值用,集中区间换算(last = start + count - 1,含)。
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"chain_key": chain_key,
|
||||||
|
"start_chapter_no": start_chapter_no,
|
||||||
|
"last_chapter_no": start_chapter_no + count - 1,
|
||||||
|
"current_chapter_no": start_chapter_no,
|
||||||
|
"written": [],
|
||||||
|
"has_conflicts": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ACCEPT_CHAPTER",
|
||||||
|
"DECIDE",
|
||||||
|
"REVIEW_CHAPTER",
|
||||||
|
"WRITE_CHAPTER",
|
||||||
|
"build_chain_graph",
|
||||||
|
"initial_chain_state",
|
||||||
|
]
|
||||||
311
packages/core/ww_core/orchestrator/chain/nodes.py
Normal file
311
packages/core/ww_core/orchestrator/chain/nodes.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""多章链节点(chain-workflow 设计 §3.2)——write → review → decide → accept 循环。
|
||||||
|
|
||||||
|
节点是**小确定性单元**:LLM 非确定性锁在网关后;`decide` 是纯逻辑。每个做 DB IO 的
|
||||||
|
节点用**短事务、独立 session**(每章 accept 原子提交,仿 `run_overdue_scan` 自建 session)。
|
||||||
|
checkpoint 只存控制流,正文/审稿落领域表——resume 时重读领域表为真相(不变量 #1/#3/#5)。
|
||||||
|
|
||||||
|
依赖注入:本模块的节点是裸异步函数(显式关键字依赖),供直接单测注入 fake;图工厂
|
||||||
|
(`graph.py`)用默认参绑定把依赖闭包绑进图节点(langgraph 不收 functools.partial,见 gotcha)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable, Sequence
|
||||||
|
from contextlib import AbstractAsyncContextManager
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from ww_agents import AgentSpec
|
||||||
|
|
||||||
|
from .._protocols import GatewayRun
|
||||||
|
from ..collect import collect_reviews, extract_conflicts
|
||||||
|
from ..review_node import build_review_context, run_review
|
||||||
|
from ..state import ChapterState
|
||||||
|
from ..write_node import build_write_request
|
||||||
|
from .state import ChainState
|
||||||
|
|
||||||
|
log = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
# ---- 注入缝(图工厂经默认参绑定,单测直接注 fake)----
|
||||||
|
|
||||||
|
#: `session -> Repo`:节点自建短事务里从 session 造 repo(仿端点依赖工厂)。
|
||||||
|
SessionFactory = Callable[[], AbstractAsyncContextManager[Any]]
|
||||||
|
|
||||||
|
|
||||||
|
class AssembleContext(Protocol):
|
||||||
|
"""`assemble` 产出的最小读形——只需 stable_core/volatile(构造写章/审稿请求)。"""
|
||||||
|
|
||||||
|
stable_core: str
|
||||||
|
volatile: str
|
||||||
|
|
||||||
|
|
||||||
|
#: `(repos, project_id, chapter_no) -> AssembledContext`:组装记忆(确定性,不变量 #6/#9)。
|
||||||
|
AssembleFn = Callable[[Any, uuid.UUID, int], Awaitable[AssembleContext]]
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryReposFactory(Protocol):
|
||||||
|
"""`session -> MemoryRepos`(assemble 所需 8 repo 捆绑)。"""
|
||||||
|
|
||||||
|
def __call__(self, session: Any) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
class DraftView(Protocol):
|
||||||
|
"""章草稿读形最小依赖——只需 `content`(重读正文构审稿上下文)。"""
|
||||||
|
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterDraftRepo(Protocol):
|
||||||
|
"""章草稿 repo 最小依赖:write 落稿 `save_draft` + review 重读 `get_draft`(只 flush)。"""
|
||||||
|
|
||||||
|
async def save_draft(
|
||||||
|
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
|
||||||
|
) -> Any: ...
|
||||||
|
|
||||||
|
async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> DraftView | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewRecordRepo(Protocol):
|
||||||
|
"""review/accept 节点对审稿 repo 的最小依赖:collect 留痕 + accept 重读最近审稿。"""
|
||||||
|
|
||||||
|
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: ...
|
||||||
|
|
||||||
|
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CommitSession(Protocol):
|
||||||
|
"""节点短事务对 session 的最小依赖——只需 `commit()`(write/review 节点末尾提交)。"""
|
||||||
|
|
||||||
|
async def commit(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
#: accept 步:把作者裁决落库(晋升终稿 + digest + 裁决留痕 + 伏笔到期),各自独立 session。
|
||||||
|
#: 编排(链)在 core;具体验收事务(apps/api)经此闭包注入,守目录所有权 + 不变量 #3/#4。
|
||||||
|
#: 入参 `decisions` 为裁决清单(`accept_chapter` 节点经 interrupt 恢复值拿到,无冲突=[])。
|
||||||
|
AcceptChapterOp = Callable[..., Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 节点本体(裸函数,显式依赖;图工厂绑定闭包)----
|
||||||
|
|
||||||
|
|
||||||
|
async def write_chapter(
|
||||||
|
state: ChainState,
|
||||||
|
*,
|
||||||
|
gateway: GatewayRun,
|
||||||
|
session_factory: SessionFactory,
|
||||||
|
memory_repos_factory: MemoryReposFactory,
|
||||||
|
chapter_repo_factory: Callable[[Any], ChapterDraftRepo],
|
||||||
|
assemble: AssembleFn,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""write 节点:组装记忆 → 收集版写章(非 SSE)→ 落 `chapters` draft → commit。
|
||||||
|
|
||||||
|
短事务、独立 session(不跨章共享)。state 不变(正文在 DB)。返回空增量。
|
||||||
|
收集版写章复用 `build_write_request` 同一 prompt 组装路径(stream=False → `gateway.run`),
|
||||||
|
守不变量 #9(stable_core 进缓存前缀);不确定性锁在网关后,注 mock 即可单测。
|
||||||
|
"""
|
||||||
|
project_id = state["project_id"]
|
||||||
|
chapter_no = state["current_chapter_no"]
|
||||||
|
user_id = state["user_id"]
|
||||||
|
async with session_factory() as session:
|
||||||
|
repos = memory_repos_factory(session)
|
||||||
|
context = await assemble(repos, project_id, chapter_no)
|
||||||
|
req = build_write_request(
|
||||||
|
stable_core=context.stable_core,
|
||||||
|
volatile=context.volatile,
|
||||||
|
user_id=user_id,
|
||||||
|
project_id=project_id,
|
||||||
|
)
|
||||||
|
# 收集版:非流式,一次 run 拿全文(链批量量产不走 SSE)。
|
||||||
|
collect_req = req.model_copy(update={"stream": False})
|
||||||
|
resp = await gateway.run(collect_req)
|
||||||
|
chapter_repo = chapter_repo_factory(session)
|
||||||
|
await chapter_repo.save_draft(project_id, chapter_no, text=resp.text)
|
||||||
|
await _commit(session)
|
||||||
|
log.info(
|
||||||
|
"chain_write_chapter",
|
||||||
|
project_id=str(project_id),
|
||||||
|
chapter_no=chapter_no,
|
||||||
|
draft_len=len(resp.text),
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def review_chapter(
|
||||||
|
state: ChainState,
|
||||||
|
*,
|
||||||
|
gateway: GatewayRun,
|
||||||
|
session_factory: SessionFactory,
|
||||||
|
memory_repos_factory: MemoryReposFactory,
|
||||||
|
chapter_repo_factory: Callable[[Any], ChapterDraftRepo],
|
||||||
|
review_repo_factory: Callable[[Any], ReviewRecordRepo],
|
||||||
|
assemble: AssembleFn,
|
||||||
|
review_specs: Sequence[AgentSpec],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""review 节点:复用四审 → 落 `chapter_reviews` 留痕(不变量 #3 只读、不写正文)→ commit。
|
||||||
|
|
||||||
|
复用现有 `run_review × specs` + `collect_reviews`(直接顺序跑各审,非起子图——链节点本身
|
||||||
|
已是图节点,无需嵌套图)。从 DB 重读本章草稿构审稿上下文(真相在表,不信 state)。
|
||||||
|
"""
|
||||||
|
project_id = state["project_id"]
|
||||||
|
chapter_no = state["current_chapter_no"]
|
||||||
|
user_id = state["user_id"]
|
||||||
|
async with session_factory() as session:
|
||||||
|
repos = memory_repos_factory(session)
|
||||||
|
context = await assemble(repos, project_id, chapter_no)
|
||||||
|
chapter_repo = chapter_repo_factory(session)
|
||||||
|
draft_view = await _read_draft(chapter_repo, project_id, chapter_no)
|
||||||
|
review_context = build_review_context(
|
||||||
|
draft=draft_view,
|
||||||
|
stable_core=context.stable_core,
|
||||||
|
volatile=context.volatile,
|
||||||
|
)
|
||||||
|
review_state: ChapterState = {
|
||||||
|
"project_id": project_id,
|
||||||
|
"chapter_no": chapter_no,
|
||||||
|
"user_id": user_id,
|
||||||
|
"review_context": review_context,
|
||||||
|
}
|
||||||
|
reviews: dict[str, Any] = {}
|
||||||
|
for spec in review_specs:
|
||||||
|
part = await run_review(spec, review_state, gateway=gateway)
|
||||||
|
reviews.update(part.get("reviews", {}))
|
||||||
|
review_state["reviews"] = reviews
|
||||||
|
review_repo = review_repo_factory(session)
|
||||||
|
await collect_reviews(review_state, review_repo=review_repo)
|
||||||
|
await _commit(session)
|
||||||
|
log.info(
|
||||||
|
"chain_review_chapter",
|
||||||
|
project_id=str(project_id),
|
||||||
|
chapter_no=chapter_no,
|
||||||
|
reviews=sorted(reviews.keys()),
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def decide(
|
||||||
|
state: ChainState,
|
||||||
|
*,
|
||||||
|
session_factory: SessionFactory,
|
||||||
|
review_repo_factory: Callable[[Any], ReviewRecordRepo],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""decide 节点(确定性纯逻辑,无 LLM):读最近审稿留痕 → `has_conflicts`。
|
||||||
|
|
||||||
|
冲突明文**不入** state(不变量 #5);只写控制流标志,accept 时再从领域表重读冲突。
|
||||||
|
"""
|
||||||
|
project_id = state["project_id"]
|
||||||
|
chapter_no = state["current_chapter_no"]
|
||||||
|
async with session_factory() as session:
|
||||||
|
review_repo = review_repo_factory(session)
|
||||||
|
history = await review_repo.list_for_chapter(project_id, chapter_no)
|
||||||
|
latest = history[0] if history else None
|
||||||
|
conflicts = _conflicts_of(latest)
|
||||||
|
has_conflicts = len(conflicts) > 0
|
||||||
|
log.info(
|
||||||
|
"chain_decide",
|
||||||
|
project_id=str(project_id),
|
||||||
|
chapter_no=chapter_no,
|
||||||
|
has_conflicts=has_conflicts,
|
||||||
|
conflict_count=len(conflicts),
|
||||||
|
)
|
||||||
|
return {"has_conflicts": has_conflicts}
|
||||||
|
|
||||||
|
|
||||||
|
async def accept_chapter(
|
||||||
|
state: ChainState,
|
||||||
|
*,
|
||||||
|
decisions: list[Any],
|
||||||
|
accept_op: AcceptChapterOp,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""accept 节点:用裁决落库(晋升终稿 + digest + 裁决留痕 + 伏笔到期),推进章号。
|
||||||
|
|
||||||
|
具体验收事务(重读 `chapter_reviews`、`assert_conflicts_resolved` gate、终稿提炼 digest、
|
||||||
|
`run_accept_transaction`、`run_overdue_scan`)属 apps/api,经 `accept_op` 闭包注入——守目录
|
||||||
|
所有权 + 不变量 #3/#4(缺判 → `CONFLICT_UNRESOLVED` 由 op 抛,链失败)。
|
||||||
|
|
||||||
|
`decisions`:无冲突章 = [];有冲突章 = interrupt 恢复值(作者裁决清单)。
|
||||||
|
返回 `current_chapter_no += 1` + `written` 追加本章——推进控制流(不可变增量)。
|
||||||
|
"""
|
||||||
|
project_id = state["project_id"]
|
||||||
|
chapter_no = state["current_chapter_no"]
|
||||||
|
user_id = state["user_id"]
|
||||||
|
await accept_op(
|
||||||
|
project_id=project_id,
|
||||||
|
chapter_no=chapter_no,
|
||||||
|
user_id=user_id,
|
||||||
|
decisions=decisions,
|
||||||
|
)
|
||||||
|
written = list(state.get("written") or [])
|
||||||
|
written.append(chapter_no)
|
||||||
|
log.info(
|
||||||
|
"chain_accept_chapter",
|
||||||
|
project_id=str(project_id),
|
||||||
|
chapter_no=chapter_no,
|
||||||
|
written_count=len(written),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"current_chapter_no": chapter_no + 1,
|
||||||
|
"written": written,
|
||||||
|
"has_conflicts": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 辅助(纯函数 / 小工具)----
|
||||||
|
|
||||||
|
|
||||||
|
def _conflicts_of(latest_review: Any) -> list[dict[str, Any]]:
|
||||||
|
"""从最近审稿留痕取冲突清单(兼容 ReviewView Pydantic 与内存 fake dict)。"""
|
||||||
|
if latest_review is None:
|
||||||
|
return []
|
||||||
|
conflicts = getattr(latest_review, "conflicts", None)
|
||||||
|
if conflicts is None and isinstance(latest_review, dict):
|
||||||
|
conflicts = latest_review.get("conflicts")
|
||||||
|
return list(conflicts or [])
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_draft(
|
||||||
|
chapter_repo: ChapterDraftRepo, project_id: uuid.UUID, chapter_no: int
|
||||||
|
) -> str:
|
||||||
|
"""从 repo 重读本章草稿正文(真相在表,不信 state)。"""
|
||||||
|
view = await chapter_repo.get_draft(project_id, chapter_no)
|
||||||
|
return view.content if view is not None else ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _commit(session: CommitSession) -> None:
|
||||||
|
"""提交短事务(session 满足 `CommitSession`:只需 `commit()`)。"""
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# decide 暴露给条件边的辅助(纯判定,便于单测)。
|
||||||
|
def has_conflicts(state: ChainState) -> bool:
|
||||||
|
"""条件边读 state 的冲突标志(缺省 False)。"""
|
||||||
|
return bool(state.get("has_conflicts"))
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AcceptChapterOp",
|
||||||
|
"AssembleContext",
|
||||||
|
"AssembleFn",
|
||||||
|
"ChainState",
|
||||||
|
"ChapterDraftRepo",
|
||||||
|
"MemoryReposFactory",
|
||||||
|
"ReviewRecordRepo",
|
||||||
|
"SessionFactory",
|
||||||
|
"accept_chapter",
|
||||||
|
"decide",
|
||||||
|
"extract_conflicts",
|
||||||
|
"has_conflicts",
|
||||||
|
"review_chapter",
|
||||||
|
"write_chapter",
|
||||||
|
]
|
||||||
36
packages/core/ww_core/orchestrator/chain/state.py
Normal file
36
packages/core/ww_core/orchestrator/chain/state.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""多章工作流链状态 `ChainState`(chain-workflow 设计 §3.1)。
|
||||||
|
|
||||||
|
不变量 #5:langgraph 检查点 = state 内容,**只放控制流**——章号/计数/已验收清单/
|
||||||
|
冲突标志。草稿正文/上下文/冲突明文**不进** state(落 `chapters`/`chapter_reviews`,
|
||||||
|
resume 时从领域表重读为真相)。
|
||||||
|
|
||||||
|
`ChainState` 用 TypedDict(LangGraph 原生状态形态),字段全 snake_case。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
|
||||||
|
class ChainState(TypedDict, total=False):
|
||||||
|
"""`draft_volume` 链的控制流状态(检查点内容)。
|
||||||
|
|
||||||
|
- `project_id` / `user_id`:定位 + 调用作用域(原型单用户 stub)。
|
||||||
|
- `chain_key`:固定 `"draft_volume"`(本期唯一内置链)。
|
||||||
|
- `start_chapter_no` / `last_chapter_no`:本 run 区间 [start, last](含)。
|
||||||
|
- `current_chapter_no`:当前处理章;初值 = start,每验收一章 +1。
|
||||||
|
- `written`:本 run 已验收章号(进度来源;resume 后累积)。
|
||||||
|
- `has_conflicts`:`decide` 节点写入的控制流标志(不存冲突明文,不变量 #5)。
|
||||||
|
|
||||||
|
`total=False`:初始 invoke 只需填区间字段,`written`/`has_conflicts` 由节点逐步填。
|
||||||
|
"""
|
||||||
|
|
||||||
|
project_id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
chain_key: str
|
||||||
|
start_chapter_no: int
|
||||||
|
last_chapter_no: int
|
||||||
|
current_chapter_no: int
|
||||||
|
written: list[int]
|
||||||
|
has_conflicts: bool
|
||||||
@@ -27,37 +27,17 @@ from ._protocols import GatewayRun
|
|||||||
from .collect import ReviewRecorder, collect_reviews
|
from .collect import ReviewRecorder, collect_reviews
|
||||||
from .review_node import run_review
|
from .review_node import run_review
|
||||||
from .state import ChapterState
|
from .state import ChapterState
|
||||||
from .write_node import GatewayStream, write_node
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||||
from langgraph.graph.state import CompiledStateGraph
|
from langgraph.graph.state import CompiledStateGraph
|
||||||
|
|
||||||
|
# write 节点名(write_node 本体保留,被多章链 `chain.nodes.write_chapter` 复用;M1 的
|
||||||
|
# `build_write_graph` 单节点图已删——链图取代之,YAGNI,见 chain-workflow 设计 §3.3)。
|
||||||
WRITE_NODE = "write"
|
WRITE_NODE = "write"
|
||||||
COLLECT_NODE = "collect"
|
COLLECT_NODE = "collect"
|
||||||
|
|
||||||
|
|
||||||
def build_write_graph(
|
|
||||||
gateway: GatewayStream,
|
|
||||||
*,
|
|
||||||
checkpointer: BaseCheckpointSaver[Any] | None = None,
|
|
||||||
) -> CompiledStateGraph[ChapterState, None, ChapterState, ChapterState]:
|
|
||||||
"""构建并编译单 `write` 节点图。
|
|
||||||
|
|
||||||
`gateway` 经闭包绑进节点(节点对图只暴露 `(state)->dict`,确定性可单测)。
|
|
||||||
`checkpointer`:运行时传 Postgres saver;单测传 `MemorySaver` 或不传。
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def _write(state: ChapterState) -> dict[str, str]:
|
|
||||||
return await write_node(state, gateway=gateway)
|
|
||||||
|
|
||||||
builder: StateGraph[ChapterState, None, ChapterState, ChapterState] = StateGraph(ChapterState)
|
|
||||||
builder.add_node(WRITE_NODE, _write)
|
|
||||||
builder.add_edge(START, WRITE_NODE)
|
|
||||||
builder.add_edge(WRITE_NODE, END)
|
|
||||||
return builder.compile(checkpointer=checkpointer)
|
|
||||||
|
|
||||||
|
|
||||||
# M4:四审齐(continuity + foreshadow + pace + style)。第四审 = 文风漂移打分轨。
|
# M4:四审齐(continuity + foreshadow + pace + style)。第四审 = 文风漂移打分轨。
|
||||||
REVIEW_SPECS: tuple[AgentSpec, ...] = (
|
REVIEW_SPECS: tuple[AgentSpec, ...] = (
|
||||||
continuity_spec,
|
continuity_spec,
|
||||||
|
|||||||
Reference in New Issue
Block a user