feat: M2 — 写→审(一致性)→裁决→验收(事务);未决冲突禁验收
- 续审 Agent 声明(AgentSpec) + 结构化输出契约(ContinuityReview/Conflict 五类) - LangGraph 并行审子图(可扩四审) + collect 落 chapter_reviews 留痕 + review SSE(section/conflict) - 验收-side Repository:章节 accepted 版本晋升 + digest append-only + 审稿留痕/裁决 - API:review(SSE) + reviews 历史 + accept(单原子事务:晋升 version + 终稿 digest + 裁决留痕) - 冲突 gate:未决裁决拦截(CONFLICT_UNRESOLVED);digest 从终稿提炼(不变量#4) - 前端:审稿报告页 + 冲突就地标注 + 裁决(采纳/忽略/手改) + 未决禁验收 + 「本次将更新」清单 - M2 E2E:真实 DB + 多档位 mock 网关零 token 走通 写→审→裁决→验收→摘要入库 - 多 agent 协同台账(PROGRESS.md) + 共享记忆(memory/contracts·decisions·gotchas)
This commit is contained in:
160
packages/core/tests/test_chapter_promote.py
Normal file
160
packages/core/tests/test_chapter_promote.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""T2.3 章节 accepted 版本晋升单测(ARCH §3.3 多版本行 / §5.5 step 1)。
|
||||
|
||||
纯内存 fake,无 DB:断言 max(version)+1 计算、草稿行保留、按 project_id 隔离、
|
||||
返回不可变 frozen View。`(project_id, chapter_no, version)` 唯一约束的 **DB 级**强制
|
||||
由 `chapters` 模型 UniqueConstraint 定义(见 packages/db/ww_db/models.py),真实 DB
|
||||
路径由 T2.7 E2E 覆盖;此处只测 repo 计算下一版次的逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import FrozenInstanceError, dataclass, field
|
||||
|
||||
import pytest
|
||||
from ww_core.domain.chapter_repo import ChapterView
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Row:
|
||||
project_id: uuid.UUID
|
||||
chapter_no: int
|
||||
volume: int
|
||||
content: str
|
||||
status: str
|
||||
version: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakePromoteRepo:
|
||||
"""内存替身——镜像 SqlChapterRepo 晋升语义(max+1 新行,草稿保留)。"""
|
||||
|
||||
rows: list[_Row] = field(default_factory=list)
|
||||
|
||||
def _versions(self, project_id: uuid.UUID, chapter_no: int) -> list[int]:
|
||||
return [
|
||||
r.version
|
||||
for r in self.rows
|
||||
if r.project_id == project_id and r.chapter_no == chapter_no
|
||||
]
|
||||
|
||||
async def max_version(self, project_id: uuid.UUID, chapter_no: int) -> int:
|
||||
versions = self._versions(project_id, chapter_no)
|
||||
return max(versions) if versions else 0
|
||||
|
||||
async def promote_to_accepted(
|
||||
self, project_id: uuid.UUID, chapter_no: int, *, content: str, volume: int = 1
|
||||
) -> ChapterView:
|
||||
next_version = (await self.max_version(project_id, chapter_no)) + 1
|
||||
row = _Row(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
volume=volume,
|
||||
content=content,
|
||||
status="accepted",
|
||||
version=next_version,
|
||||
)
|
||||
self.rows.append(row)
|
||||
return ChapterView(
|
||||
project_id=row.project_id,
|
||||
chapter_no=row.chapter_no,
|
||||
volume=row.volume,
|
||||
content=row.content,
|
||||
status=row.status,
|
||||
version=row.version,
|
||||
)
|
||||
|
||||
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> ChapterView | None:
|
||||
accepted = [
|
||||
r
|
||||
for r in self.rows
|
||||
if r.project_id == project_id and r.chapter_no == chapter_no and r.status == "accepted"
|
||||
]
|
||||
if not accepted:
|
||||
return None
|
||||
row = max(accepted, key=lambda r: r.version)
|
||||
return ChapterView(
|
||||
project_id=row.project_id,
|
||||
chapter_no=row.chapter_no,
|
||||
volume=row.volume,
|
||||
content=row.content,
|
||||
status=row.status,
|
||||
version=row.version,
|
||||
)
|
||||
|
||||
|
||||
def _repo_with_draft() -> FakePromoteRepo:
|
||||
repo = FakePromoteRepo()
|
||||
repo.rows.append(
|
||||
_Row(
|
||||
project_id=PROJECT,
|
||||
chapter_no=1,
|
||||
volume=1,
|
||||
content="draft text",
|
||||
status="draft",
|
||||
version=1,
|
||||
)
|
||||
)
|
||||
return repo
|
||||
|
||||
|
||||
async def test_promote_uses_max_version_plus_one() -> None:
|
||||
fake = _repo_with_draft() # 已有 version=1 草稿
|
||||
view = await fake.promote_to_accepted(PROJECT, 1, content="final")
|
||||
assert view.version == 2 # max(1)+1
|
||||
assert view.status == "accepted"
|
||||
assert view.content == "final"
|
||||
|
||||
|
||||
async def test_promote_keeps_draft_row() -> None:
|
||||
fake = _repo_with_draft()
|
||||
await fake.promote_to_accepted(PROJECT, 1, content="final")
|
||||
draft = [r for r in fake.rows if r.status == "draft"]
|
||||
assert len(draft) == 1 # 草稿行保留
|
||||
assert draft[0].content == "draft text"
|
||||
|
||||
|
||||
async def test_promote_second_accept_increments_again() -> None:
|
||||
fake = _repo_with_draft()
|
||||
await fake.promote_to_accepted(PROJECT, 1, content="v2")
|
||||
again = await fake.promote_to_accepted(PROJECT, 1, content="v3")
|
||||
assert again.version == 3 # 改稿再验收 → max(2)+1
|
||||
|
||||
|
||||
async def test_promote_first_version_when_no_rows() -> None:
|
||||
fake = FakePromoteRepo() # 空,无草稿
|
||||
view = await fake.promote_to_accepted(PROJECT, 9, content="x")
|
||||
assert view.version == 1 # 0+1
|
||||
|
||||
|
||||
async def test_max_version_isolated_by_project() -> None:
|
||||
fake = _repo_with_draft() # PROJECT/ch1 有 version=1
|
||||
# OTHER 项目同章号互不影响
|
||||
assert await fake.max_version(OTHER, 1) == 0
|
||||
view = await fake.promote_to_accepted(OTHER, 1, content="other")
|
||||
assert view.version == 1
|
||||
|
||||
|
||||
async def test_latest_accepted_returns_highest_version() -> None:
|
||||
fake = _repo_with_draft()
|
||||
await fake.promote_to_accepted(PROJECT, 1, content="v2")
|
||||
await fake.promote_to_accepted(PROJECT, 1, content="v3")
|
||||
latest = await fake.latest_accepted(PROJECT, 1)
|
||||
assert latest is not None
|
||||
assert latest.version == 3
|
||||
assert latest.content == "v3"
|
||||
|
||||
|
||||
async def test_latest_accepted_none_when_only_draft() -> None:
|
||||
fake = _repo_with_draft()
|
||||
assert await fake.latest_accepted(PROJECT, 1) is None
|
||||
|
||||
|
||||
async def test_chapter_view_is_frozen() -> None:
|
||||
fake = FakePromoteRepo()
|
||||
view = await fake.promote_to_accepted(PROJECT, 1, content="x")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
view.version = 99 # type: ignore[misc]
|
||||
74
packages/core/tests/test_digest_repo.py
Normal file
74
packages/core/tests/test_digest_repo.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""T2.3 digest 追加 repo 单测(ARCH §3.3 append-only / §5.5)。
|
||||
|
||||
纯内存 fake,无 DB:断言 append-only 语义(永不覆盖)、按 project_id 过滤、
|
||||
返回不可变 frozen View。DB 级 append 行为同样由 `chapter_digests` 无唯一约束保证,
|
||||
真实 DB 路径由 T2.7 E2E 覆盖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo
|
||||
from ww_core.domain.repositories import DigestView
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Row:
|
||||
project_id: uuid.UUID
|
||||
chapter_no: int
|
||||
facts: dict[str, object]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeDigestAppendRepo:
|
||||
"""append-only 内存替身——镜像 SqlDigestAppendRepo 语义(每次 append 新增一行)。"""
|
||||
|
||||
rows: list[_Row] = field(default_factory=list)
|
||||
|
||||
async def append(
|
||||
self, project_id: uuid.UUID, chapter_no: int, *, facts: dict[str, object]
|
||||
) -> DigestView:
|
||||
self.rows.append(_Row(project_id=project_id, chapter_no=chapter_no, facts=dict(facts)))
|
||||
return DigestView(chapter_no=chapter_no, facts=dict(facts))
|
||||
|
||||
|
||||
def _repo() -> FakeDigestAppendRepo:
|
||||
return FakeDigestAppendRepo()
|
||||
|
||||
|
||||
async def test_append_returns_frozen_view() -> None:
|
||||
repo: DigestAppendRepo = _repo()
|
||||
view = await repo.append(PROJECT, 1, facts={"entities": ["林动"]})
|
||||
assert isinstance(view, DigestView)
|
||||
assert view.chapter_no == 1
|
||||
assert view.facts == {"entities": ["林动"]}
|
||||
# frozen — 不可就地突变
|
||||
with pytest.raises(ValidationError):
|
||||
view.chapter_no = 9
|
||||
|
||||
|
||||
async def test_append_is_append_only_never_overwrites() -> None:
|
||||
fake = _repo()
|
||||
repo: DigestAppendRepo = fake
|
||||
await repo.append(PROJECT, 1, facts={"v": "first"})
|
||||
await repo.append(PROJECT, 1, facts={"v": "second"})
|
||||
# 同章两次 append → 两行都在,旧的不被覆盖
|
||||
same_chapter = [r for r in fake.rows if r.chapter_no == 1]
|
||||
assert len(same_chapter) == 2
|
||||
assert [r.facts["v"] for r in same_chapter] == ["first", "second"]
|
||||
|
||||
|
||||
async def test_append_does_not_mutate_input_facts() -> None:
|
||||
fake = _repo()
|
||||
repo: DigestAppendRepo = fake
|
||||
facts: dict[str, object] = {"entities": ["林动"]}
|
||||
await repo.append(PROJECT, 1, facts=facts)
|
||||
facts["entities"] = ["改了"] # 改调用方的 dict 不应影响已存行
|
||||
assert fake.rows[0].facts == {"entities": ["林动"]}
|
||||
244
packages/core/tests/test_review.py
Normal file
244
packages/core/tests/test_review.py
Normal file
@@ -0,0 +1,244 @@
|
||||
"""T2.2 审稿子图单测——审稿节点 + collect 留痕 + review SSE 归一(ARCH §5.2/§7.3)。
|
||||
|
||||
全部注入 mock 网关 / 内存 fake repo,无真 LLM、无真 Postgres(图用 MemorySaver)。
|
||||
asyncio_mode=auto。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fakes_orchestrator import (
|
||||
FailingRunGateway,
|
||||
FakeReviewRepo,
|
||||
FakeRunGateway,
|
||||
)
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
from ww_agents import Conflict, ContinuityReview, continuity_spec
|
||||
from ww_core.orchestrator import (
|
||||
CONTINUITY,
|
||||
EVENT_CONFLICT,
|
||||
EVENT_DONE,
|
||||
EVENT_ERROR,
|
||||
EVENT_SECTION,
|
||||
REVIEW_INCOMPLETE,
|
||||
REVIEW_OK,
|
||||
SECTION_DONE,
|
||||
SECTION_INCOMPLETE,
|
||||
ChapterState,
|
||||
build_review_context,
|
||||
build_review_graph,
|
||||
build_review_request,
|
||||
collect_reviews,
|
||||
extract_conflicts,
|
||||
make_review_node,
|
||||
normalize_review,
|
||||
run_review,
|
||||
)
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
|
||||
|
||||
STABLE = "## 世界观硬规则\n灵气可凝丹"
|
||||
VOLATILE = "## 近况\n主角已破境"
|
||||
DRAFT = "他一拳轰碎山岳。"
|
||||
|
||||
_CONFLICT = Conflict(
|
||||
type="能力不符",
|
||||
where="第3段「一拳轰碎山岳」",
|
||||
refs=["人物卡:主角:力量上限=裂石"],
|
||||
suggestion="改为震裂巨石",
|
||||
)
|
||||
|
||||
|
||||
def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
|
||||
state: ChapterState = {
|
||||
"project_id": PROJECT,
|
||||
"chapter_no": 7,
|
||||
"user_id": USER,
|
||||
"stable_core": STABLE,
|
||||
"volatile": VOLATILE,
|
||||
"draft": DRAFT,
|
||||
"review_context": build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE),
|
||||
}
|
||||
if reviews is not None:
|
||||
state["reviews"] = reviews
|
||||
return state
|
||||
|
||||
|
||||
# ---- build_review_context:确定性、含草稿+材料 ----
|
||||
|
||||
|
||||
def test_build_review_context_is_deterministic_and_contains_parts() -> None:
|
||||
ctx1 = build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE)
|
||||
ctx2 = build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE)
|
||||
|
||||
assert ctx1 == ctx2 # 无时间戳/随机 → 同输入同输出
|
||||
assert DRAFT in ctx1
|
||||
assert STABLE in ctx1
|
||||
assert VOLATILE in ctx1
|
||||
|
||||
|
||||
# ---- build_review_request:tier 不变量、缓存断点、output_schema、run 非 stream ----
|
||||
|
||||
|
||||
def test_build_review_request_uses_spec_tier_and_cached_prompt() -> None:
|
||||
req = build_review_request(continuity_spec, _state())
|
||||
|
||||
assert req.tier == "analyst" # 不变量②:spec 档位透传,不传 model
|
||||
assert req.stream is False # 续审用 run() 非 stream()
|
||||
assert len(req.system) == 1
|
||||
assert req.system[0].text == continuity_spec.system_prompt
|
||||
assert req.system[0].cache is True # 不变量#9
|
||||
assert req.output_schema is ContinuityReview
|
||||
assert req.scope.user_id == USER
|
||||
assert req.scope.project_id == PROJECT
|
||||
|
||||
|
||||
# ---- run_review:结构化结果回 state、只读不写库 ----
|
||||
|
||||
|
||||
async def test_run_review_returns_ok_with_parsed_result() -> None:
|
||||
gateway = FakeRunGateway(ContinuityReview(conflicts=[_CONFLICT]))
|
||||
|
||||
result = await run_review(continuity_spec, _state(), gateway=gateway)
|
||||
|
||||
assert gateway.call_count == 1
|
||||
entry = result["reviews"][CONTINUITY]
|
||||
assert entry["status"] == REVIEW_OK
|
||||
assert entry["result"]["conflicts"][0]["type"] == "能力不符"
|
||||
|
||||
|
||||
async def test_run_review_isolates_gateway_failure_as_incomplete() -> None:
|
||||
gateway = FailingRunGateway(AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
|
||||
|
||||
result = await run_review(continuity_spec, _state(), gateway=gateway)
|
||||
|
||||
entry = result["reviews"][CONTINUITY]
|
||||
assert entry["status"] == REVIEW_INCOMPLETE # §5.2:失败隔离、不上抛
|
||||
assert entry["result"] is None
|
||||
|
||||
|
||||
async def test_make_review_node_binds_gateway() -> None:
|
||||
gateway = FakeRunGateway(ContinuityReview(conflicts=[]))
|
||||
node = make_review_node(continuity_spec, gateway)
|
||||
|
||||
result = await node(_state())
|
||||
|
||||
assert result["reviews"][CONTINUITY]["status"] == REVIEW_OK
|
||||
|
||||
|
||||
# ---- collect:抽冲突 + 落 chapter_reviews 留痕(只 flush,不在节点 commit) ----
|
||||
|
||||
|
||||
def test_extract_conflicts_only_from_ok_continuity() -> None:
|
||||
ok = {CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}}
|
||||
incomplete = {CONTINUITY: {"status": REVIEW_INCOMPLETE, "result": None}}
|
||||
|
||||
assert len(extract_conflicts(ok)) == 1
|
||||
assert extract_conflicts(incomplete) == []
|
||||
assert extract_conflicts({}) == []
|
||||
|
||||
|
||||
async def test_collect_records_conflicts_to_review_repo() -> None:
|
||||
repo = FakeReviewRepo()
|
||||
reviews = {CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}}
|
||||
|
||||
out = await collect_reviews(_state(reviews), review_repo=repo)
|
||||
|
||||
assert out == {} # 不作真相源:留痕在表(不变量#5)
|
||||
assert len(repo.records) == 1
|
||||
rec = repo.records[0]
|
||||
assert rec["project_id"] == PROJECT
|
||||
assert rec["chapter_no"] == 7
|
||||
assert rec["chapter_version"] is None # version 绑定/裁决归 T2.4
|
||||
assert rec["conflicts"][0]["type"] == "能力不符"
|
||||
|
||||
|
||||
async def test_collect_records_empty_when_review_incomplete() -> None:
|
||||
repo = FakeReviewRepo()
|
||||
reviews = {CONTINUITY: {"status": REVIEW_INCOMPLETE, "result": None}}
|
||||
|
||||
await collect_reviews(_state(reviews), review_repo=repo)
|
||||
|
||||
assert repo.records[0]["conflicts"] == [] # 未完成 → 不臆造冲突
|
||||
|
||||
|
||||
# ---- 图:START→continuity→collect→END,全程 mock,MemorySaver ----
|
||||
|
||||
|
||||
def test_build_review_graph_compiles_with_nodes() -> None:
|
||||
graph = build_review_graph(
|
||||
FakeRunGateway(ContinuityReview(conflicts=[])),
|
||||
FakeReviewRepo(),
|
||||
checkpointer=MemorySaver(),
|
||||
)
|
||||
|
||||
nodes = graph.get_graph().nodes
|
||||
assert CONTINUITY in nodes
|
||||
assert "collect" in nodes
|
||||
assert graph.checkpointer is not None
|
||||
|
||||
|
||||
async def test_review_graph_runs_continuity_then_collect_end_to_end() -> None:
|
||||
gateway = FakeRunGateway(ContinuityReview(conflicts=[_CONFLICT]))
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-1"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
|
||||
assert len(repo.records) == 1 # collect 落了留痕
|
||||
assert repo.records[0]["conflicts"][0]["type"] == "能力不符"
|
||||
|
||||
|
||||
async def test_review_graph_does_not_block_on_review_failure() -> None:
|
||||
gateway = FailingRunGateway(RuntimeError("down"))
|
||||
repo = FakeReviewRepo()
|
||||
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
|
||||
config: RunnableConfig = {"configurable": {"thread_id": "rev-2"}}
|
||||
|
||||
final = await graph.ainvoke(_state(), config=config)
|
||||
|
||||
# 审失败被隔离为 incomplete,图照常走到 collect 并留痕(空冲突)
|
||||
assert final["reviews"][CONTINUITY]["status"] == REVIEW_INCOMPLETE
|
||||
assert repo.records[0]["conflicts"] == []
|
||||
|
||||
|
||||
# ---- review SSE 归一:section + conflict + done;incomplete 路径;异常→error ----
|
||||
|
||||
|
||||
async def test_normalize_review_emits_section_conflict_done() -> None:
|
||||
reviews = {CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}}
|
||||
|
||||
events = [e async for e in normalize_review(reviews)]
|
||||
|
||||
assert [e.event for e in events] == [EVENT_SECTION, EVENT_CONFLICT, EVENT_DONE]
|
||||
assert events[0].data == {"name": CONTINUITY, "status": SECTION_DONE}
|
||||
assert events[1].data["type"] == "能力不符"
|
||||
assert events[1].data["refs"] == ["人物卡:主角:力量上限=裂石"]
|
||||
assert events[-1].data == {"length": 1}
|
||||
|
||||
|
||||
async def test_normalize_review_marks_incomplete_section_without_conflicts() -> None:
|
||||
reviews = {CONTINUITY: {"status": REVIEW_INCOMPLETE, "result": None}}
|
||||
|
||||
events = [e async for e in normalize_review(reviews)]
|
||||
|
||||
assert [e.event for e in events] == [EVENT_SECTION, EVENT_DONE]
|
||||
assert events[0].data == {"name": CONTINUITY, "status": SECTION_INCOMPLETE}
|
||||
|
||||
|
||||
async def test_normalize_review_maps_unexpected_error_to_error_event() -> None:
|
||||
# 畸形 entry(result 非 dict)触发归一内部异常 → error 事件,不上抛
|
||||
reviews = {CONTINUITY: {"status": REVIEW_OK, "result": 123}}
|
||||
|
||||
events = [e async for e in normalize_review(reviews, request_id="req-9")]
|
||||
|
||||
assert events[-1].event == EVENT_ERROR
|
||||
assert events[-1].data["code"] == ErrorCode.INTERNAL
|
||||
assert events[-1].data["request_id"] == "req-9"
|
||||
130
packages/core/tests/test_review_repo.py
Normal file
130
packages/core/tests/test_review_repo.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""T2.3 审稿留痕 repo 单测(ARCH §5.4/§5.5)。
|
||||
|
||||
纯内存 fake,无 DB:断言 record 写入字段、list 历史新→旧、set_decisions 裁决留痕,
|
||||
均返回不可变 frozen View,按 project_id 隔离。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from ww_core.domain.review_repo import ReviewRepo, ReviewView
|
||||
|
||||
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeReviewRepo:
|
||||
"""内存替身——镜像 SqlReviewRepo 语义(append 每次审稿;裁决就地补 decisions)。"""
|
||||
|
||||
rows: list[ReviewView] = field(default_factory=list)
|
||||
_seq: int = 0
|
||||
|
||||
async def record(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
*,
|
||||
chapter_version: int | None = None,
|
||||
conflicts: list[dict[str, object]],
|
||||
foreshadow_sug: list[dict[str, object]] | None = None,
|
||||
style: dict[str, object] | None = None,
|
||||
pace: dict[str, object] | None = None,
|
||||
health_score: int | None = None,
|
||||
) -> ReviewView:
|
||||
self._seq += 1
|
||||
view = ReviewView(
|
||||
id=uuid.UUID(int=self._seq),
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
chapter_version=chapter_version,
|
||||
conflicts=list(conflicts),
|
||||
foreshadow_sug=list(foreshadow_sug or []),
|
||||
style=style,
|
||||
pace=pace,
|
||||
health_score=health_score,
|
||||
decisions=None,
|
||||
)
|
||||
self.rows.append(view)
|
||||
return view
|
||||
|
||||
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
|
||||
matched = [
|
||||
r for r in self.rows if r.project_id == project_id and r.chapter_no == chapter_no
|
||||
]
|
||||
return list(reversed(matched)) # 新→旧
|
||||
|
||||
async def set_decisions(
|
||||
self, review_id: uuid.UUID, *, decisions: dict[str, object]
|
||||
) -> ReviewView:
|
||||
idx, old = next((i, r) for i, r in enumerate(self.rows) if r.id == review_id)
|
||||
updated = old.model_copy(update={"decisions": dict(decisions)})
|
||||
self.rows[idx] = updated
|
||||
return updated
|
||||
|
||||
|
||||
def _repo() -> FakeReviewRepo:
|
||||
return FakeReviewRepo()
|
||||
|
||||
|
||||
async def test_record_persists_review_fields() -> None:
|
||||
repo: ReviewRepo = _repo()
|
||||
view = await repo.record(
|
||||
PROJECT,
|
||||
5,
|
||||
chapter_version=2,
|
||||
conflicts=[{"type": "性格漂移", "where": "p3"}],
|
||||
health_score=80,
|
||||
)
|
||||
assert isinstance(view, ReviewView)
|
||||
assert view.chapter_no == 5
|
||||
assert view.chapter_version == 2
|
||||
assert view.conflicts == [{"type": "性格漂移", "where": "p3"}]
|
||||
assert view.health_score == 80
|
||||
assert view.decisions is None
|
||||
|
||||
|
||||
async def test_record_defaults_empty_collections() -> None:
|
||||
repo: ReviewRepo = _repo()
|
||||
view = await repo.record(PROJECT, 1, conflicts=[])
|
||||
assert view.foreshadow_sug == []
|
||||
assert view.style is None
|
||||
assert view.pace is None
|
||||
assert view.chapter_version is None
|
||||
|
||||
|
||||
async def test_list_for_chapter_newest_first() -> None:
|
||||
repo: ReviewRepo = _repo()
|
||||
await repo.record(PROJECT, 7, conflicts=[{"n": 1}])
|
||||
await repo.record(PROJECT, 7, conflicts=[{"n": 2}])
|
||||
history = await repo.list_for_chapter(PROJECT, 7)
|
||||
assert [h.conflicts[0]["n"] for h in history] == [2, 1] # 新→旧
|
||||
|
||||
|
||||
async def test_list_for_chapter_isolated_by_project() -> None:
|
||||
repo: ReviewRepo = _repo()
|
||||
await repo.record(PROJECT, 1, conflicts=[])
|
||||
await repo.record(OTHER, 1, conflicts=[])
|
||||
history = await repo.list_for_chapter(PROJECT, 1)
|
||||
assert len(history) == 1
|
||||
assert all(h.project_id == PROJECT for h in history)
|
||||
|
||||
|
||||
async def test_set_decisions_records_adjudication() -> None:
|
||||
repo: ReviewRepo = _repo()
|
||||
rec = await repo.record(PROJECT, 3, conflicts=[{"type": "设定违例"}])
|
||||
decisions = {"c0": "采纳", "c1": "忽略"}
|
||||
updated = await repo.set_decisions(rec.id, decisions=decisions)
|
||||
assert updated.id == rec.id
|
||||
assert updated.decisions == decisions
|
||||
|
||||
|
||||
async def test_view_is_frozen() -> None:
|
||||
repo: ReviewRepo = _repo()
|
||||
view = await repo.record(PROJECT, 1, conflicts=[])
|
||||
with pytest.raises(ValidationError):
|
||||
view.health_score = 50
|
||||
37
packages/core/ww_core/domain/__init__.py
Normal file
37
packages/core/ww_core/domain/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""领域 Repository 协议 + SQLAlchemy 实现 + 只读视图(ARCH §3.5)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_core.domain.chapter_repo import (
|
||||
ChapterDraftView,
|
||||
ChapterRepo,
|
||||
ChapterView,
|
||||
SqlChapterRepo,
|
||||
)
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
from ww_core.domain.project_repo import (
|
||||
ProjectCreate,
|
||||
ProjectRepo,
|
||||
ProjectView,
|
||||
SqlProjectRepo,
|
||||
)
|
||||
from ww_core.domain.repositories import DigestView, MemoryRepos
|
||||
from ww_core.domain.review_repo import ReviewRepo, ReviewView, SqlReviewRepo
|
||||
|
||||
__all__ = [
|
||||
"ChapterDraftView",
|
||||
"ChapterRepo",
|
||||
"ChapterView",
|
||||
"SqlChapterRepo",
|
||||
"DigestAppendRepo",
|
||||
"SqlDigestAppendRepo",
|
||||
"DigestView",
|
||||
"MemoryRepos",
|
||||
"ProjectCreate",
|
||||
"ProjectRepo",
|
||||
"ProjectView",
|
||||
"SqlProjectRepo",
|
||||
"ReviewRepo",
|
||||
"ReviewView",
|
||||
"SqlReviewRepo",
|
||||
]
|
||||
53
packages/core/ww_core/domain/digest_repo.py
Normal file
53
packages/core/ww_core/domain/digest_repo.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""章节摘要**写**侧 Repository(append-only;ARCH §3.3 / §5.5 / §6.1)。
|
||||
|
||||
`chapter_digests` 是 append-only:每次验收从**终稿**提炼一行事实,**永不覆盖**历史
|
||||
(不变量 #4:digest 从终稿提炼)。读侧(`recent`,供 assemble 注入)已在
|
||||
`ww_core.memory.sql_repositories.SqlDigestRepo` / `domain.repositories.DigestRepo` 提供;
|
||||
本模块只加**写**能力,不动读侧。
|
||||
|
||||
命名:写侧 Protocol 命名 `DigestAppendRepo`(区别于 `repositories.DigestRepo` 读侧),
|
||||
共用 `DigestView`,避免同名歧义(见 memory/decisions)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import ChapterDigest
|
||||
|
||||
from ww_core.domain.repositories import DigestView
|
||||
|
||||
|
||||
class DigestAppendRepo(Protocol):
|
||||
"""摘要追加接口(append-only,按 project_id 隔离)。"""
|
||||
|
||||
async def append(
|
||||
self, project_id: uuid.UUID, chapter_no: int, *, facts: dict[str, Any]
|
||||
) -> DigestView:
|
||||
"""追加一行 digest(永不覆盖既有行);返回不可变 frozen View。"""
|
||||
...
|
||||
|
||||
|
||||
class SqlDigestAppendRepo:
|
||||
"""SQLAlchemy 实现:始终 INSERT 新行(append-only)。
|
||||
|
||||
**只 flush 不 commit**:验收事务(T2.4)把晋升+digest+裁决留痕作为单事务提交。
|
||||
"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
async def append(
|
||||
self, project_id: uuid.UUID, chapter_no: int, *, facts: dict[str, Any]
|
||||
) -> DigestView:
|
||||
row = ChapterDigest(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
facts=dict(facts),
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return DigestView(chapter_no=row.chapter_no, facts=dict(row.facts))
|
||||
138
packages/core/ww_core/domain/review_repo.py
Normal file
138
packages/core/ww_core/domain/review_repo.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""审稿留痕 Repository(四审报告 + 裁决;ARCH §5.4 / §5.5 / §6.1)。
|
||||
|
||||
- `record`:T2.2 collect 把并行四审结果 append 一行 `chapter_reviews`(每次审稿一行)。
|
||||
- `list_for_chapter`:T2.5 审稿历史(新→旧)。
|
||||
- `set_decisions`:T2.4 验收事务里写本次裁决留痕(`chapter_reviews.decisions`)。
|
||||
|
||||
四审只读、不直接写库;本 repo 是「审稿结果落库」与「裁决留痕」的唯一写入点
|
||||
(不变量 #3:AI 产出经验收 gate)。`record`/`set_decisions` 只 flush 不 commit,
|
||||
便于在编排/验收事务里组合提交。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_db.models import ChapterReview
|
||||
|
||||
|
||||
class ReviewView(BaseModel):
|
||||
"""只读审稿留痕快照(snake_case,frozen)。"""
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
id: uuid.UUID
|
||||
project_id: uuid.UUID
|
||||
chapter_no: int
|
||||
chapter_version: int | None = None
|
||||
conflicts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
|
||||
style: dict[str, Any] | None = None
|
||||
pace: dict[str, Any] | None = None
|
||||
health_score: int | None = None
|
||||
decisions: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ReviewRepo(Protocol):
|
||||
"""审稿留痕读写接口(按 project_id 隔离)。"""
|
||||
|
||||
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,
|
||||
) -> ReviewView:
|
||||
"""append 一行审稿报告(T2.2 collect 写入)。"""
|
||||
...
|
||||
|
||||
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
|
||||
"""该章审稿历史,新→旧(T2.5)。"""
|
||||
...
|
||||
|
||||
async def set_decisions(self, review_id: uuid.UUID, *, decisions: dict[str, Any]) -> ReviewView:
|
||||
"""写本次裁决留痕到指定审稿行(T2.4 验收事务)。"""
|
||||
...
|
||||
|
||||
|
||||
def _to_view(row: ChapterReview) -> ReviewView:
|
||||
return ReviewView(
|
||||
id=row.id,
|
||||
project_id=row.project_id,
|
||||
chapter_no=row.chapter_no,
|
||||
chapter_version=row.chapter_version,
|
||||
conflicts=list(row.conflicts or []),
|
||||
foreshadow_sug=list(row.foreshadow_sug or []),
|
||||
style=row.style,
|
||||
pace=row.pace,
|
||||
health_score=row.health_score,
|
||||
decisions=row.decisions,
|
||||
)
|
||||
|
||||
|
||||
class SqlReviewRepo:
|
||||
"""SQLAlchemy 实现:append 审稿行 + 历史查询 + 裁决留痕(只 flush 不 commit)。"""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._s = session
|
||||
|
||||
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,
|
||||
) -> ReviewView:
|
||||
row = ChapterReview(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
chapter_version=chapter_version,
|
||||
conflicts=list(conflicts),
|
||||
foreshadow_sug=list(foreshadow_sug or []),
|
||||
style=style,
|
||||
pace=pace,
|
||||
health_score=health_score,
|
||||
decisions=None,
|
||||
)
|
||||
self._s.add(row)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
|
||||
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
|
||||
rows = (
|
||||
await self._s.execute(
|
||||
select(ChapterReview)
|
||||
.where(
|
||||
ChapterReview.project_id == project_id,
|
||||
ChapterReview.chapter_no == chapter_no,
|
||||
)
|
||||
.order_by(ChapterReview.created_at.desc())
|
||||
)
|
||||
).scalars()
|
||||
return [_to_view(r) for r in rows]
|
||||
|
||||
async def set_decisions(self, review_id: uuid.UUID, *, decisions: dict[str, Any]) -> ReviewView:
|
||||
row = (
|
||||
await self._s.execute(select(ChapterReview).where(ChapterReview.id == review_id))
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise LookupError(f"chapter_review not found: {review_id}")
|
||||
row.decisions = dict(decisions)
|
||||
await self._s.flush()
|
||||
await self._s.refresh(row)
|
||||
return _to_view(row)
|
||||
92
packages/core/ww_core/orchestrator/collect.py
Normal file
92
packages/core/ww_core/orchestrator/collect.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""collect 节点(C4 扩 / ARCH §5.2 collect 行)——汇总并行审 → 落 `chapter_reviews` 留痕。
|
||||
|
||||
并行四审各把 `{spec.name: {status, result}}` 合并进 `state["reviews"]`(reducer,见 state.py);
|
||||
collect 把它们汇总,调 `review_repo.record(...)` append 一行 `chapter_reviews`(真相源留痕)。
|
||||
|
||||
不变量 #3:审稿节点只读不写库;**唯一写入点是这里经 `review_repo`**(仍是「留痕」非「生效」,
|
||||
AI 产出真正入库经 T2.4 验收事务的裁决)。
|
||||
|
||||
记账边界(gotcha「网关 ledger 只 flush」):审稿经网关产 usage(网关内 `flush` 进同一 session);
|
||||
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点(T2.5)/验收事务(T2.4),
|
||||
与 draft 端点一致(端点在流耗尽后 `await session.commit()`)。本节点绝不 commit。
|
||||
|
||||
M2 只接 continuity:从 `reviews["continuity"].result.conflicts` 抽冲突落 `conflicts` 列;
|
||||
foreshadow/style/pace 列留空(M3/M4 接入后填)。设计成按 spec 名取分项,便于扩展。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any, Protocol
|
||||
|
||||
import structlog
|
||||
|
||||
from .review_node import REVIEW_OK
|
||||
from .state import ChapterState
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
CONTINUITY = "continuity"
|
||||
|
||||
|
||||
class ReviewRecorder(Protocol):
|
||||
"""collect 对审稿留痕 repo 的最小依赖——只需 `record`(注入真 repo 或内存 fake)。
|
||||
|
||||
形对齐 `domain.review_repo.ReviewRepo.record`(只 flush 不 commit)。
|
||||
"""
|
||||
|
||||
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: ...
|
||||
|
||||
|
||||
def extract_conflicts(reviews: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""从 continuity 分项抽冲突清单(纯函数)。
|
||||
|
||||
仅当该审 `ok` 且有结果时取其 `conflicts`;未完成/缺席 → 空列表(不臆造)。
|
||||
"""
|
||||
entry = reviews.get(CONTINUITY)
|
||||
if not entry or entry.get("status") != REVIEW_OK:
|
||||
return []
|
||||
result = entry.get("result") or {}
|
||||
conflicts = result.get("conflicts") or []
|
||||
return [dict(c) for c in conflicts]
|
||||
|
||||
|
||||
async def collect_reviews(
|
||||
state: ChapterState,
|
||||
*,
|
||||
review_repo: ReviewRecorder,
|
||||
) -> dict[str, Any]:
|
||||
"""collect 节点:汇总并行审 → 抽冲突 → `review_repo.record` 留痕(只 flush 不 commit)。
|
||||
|
||||
直接调用本函数(注入内存 fake repo)即可单测,无需图运行时。
|
||||
`chapter_version=None`:留痕行不绑具体 version(T2.4 验收时再绑/裁决,R3/R4)。
|
||||
返回空增量字典——审稿真相已在表,state 不作真相源(不变量 #5)。
|
||||
"""
|
||||
reviews = state.get("reviews") or {}
|
||||
conflicts = extract_conflicts(reviews)
|
||||
await review_repo.record(
|
||||
state["project_id"],
|
||||
state["chapter_no"],
|
||||
chapter_version=None,
|
||||
conflicts=conflicts,
|
||||
)
|
||||
log.info(
|
||||
"collect_reviews_recorded",
|
||||
project_id=str(state["project_id"]),
|
||||
chapter_no=state["chapter_no"],
|
||||
conflict_count=len(conflicts),
|
||||
reviews=sorted(reviews.keys()),
|
||||
)
|
||||
# 留痕已在表(真相源);不在节点 commit(端点/事务层负责,见模块 docstring)。
|
||||
return {}
|
||||
118
packages/core/ww_core/orchestrator/review_node.py
Normal file
118
packages/core/ww_core/orchestrator/review_node.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""审稿节点 + 审稿上下文拼装(C4 扩 / ARCH §5.2 §5.4 reviewer 行)。
|
||||
|
||||
M2 先接 **continuity**(C6 `continuity_spec`),设计成可扩——图按一组 review specs
|
||||
循环加并行分支(M3/M4 再加 foreshadow/style/pace 三审)。
|
||||
|
||||
确定性边界(CLAUDE.md「LangGraph」纪律):
|
||||
- 节点逻辑里**无 LLM 非确定性**——不确定性藏在网关后;节点只做
|
||||
`AgentSpec + review_context → LlmRequest` 的纯构造 + 转发 `Gateway.run` 的 `parsed`。
|
||||
- 因此注入 mock 网关(产 `parsed`)即可单测,不需要图运行时、不需要真 Postgres。
|
||||
|
||||
不变量 #2:agent 只声明 `tier`,绝不传具体 model(spec.tier 透传)。
|
||||
不变量 #3:审稿节点**只读、不写库**——结构化结果回到 state,落库经 collect→review_repo。
|
||||
不变量 #9:`spec.system_prompt` 进缓存断点前块(cache=True);草稿+近况材料进 `input`(断点后)。
|
||||
|
||||
任一审失败不阻塞其余(§5.2):节点内捕获网关异常 → 返回该审「未完成」结构化占位,
|
||||
不上抛、不毁图——其余并行分支照常汇入 collect。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
import structlog
|
||||
from ww_agents import AgentSpec
|
||||
from ww_llm_gateway.types import Block, LlmRequest, LlmResponse, Scope
|
||||
|
||||
from .state import ChapterState
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# 审稿分项状态(落 state[reviews][name].status;collect / SSE 据此分流)。
|
||||
REVIEW_OK = "ok"
|
||||
REVIEW_INCOMPLETE = "incomplete" # 该审网关失败 → 标未完成,不阻塞其余(§5.2)
|
||||
|
||||
# 已绑定 gateway 的节点形(图工厂 / T2.5 直接跑审稿时用)。
|
||||
BoundReviewNode = Callable[[ChapterState], Awaitable[dict[str, Any]]]
|
||||
|
||||
|
||||
class GatewayRun(Protocol):
|
||||
"""审稿节点对网关的最小依赖——只需 `run`(注入真网关或 mock)。"""
|
||||
|
||||
async def run(self, req: LlmRequest) -> LlmResponse: ...
|
||||
|
||||
|
||||
def build_review_context(*, draft: str, stable_core: str, volatile: str) -> str:
|
||||
"""拼装审稿注入文本(确定性,无时间戳)。
|
||||
|
||||
审稿材料 = 本章草稿 + 近况摘要/人物卡/世界观硬规则——后者复用 `memory.assemble`
|
||||
的 `stable_core`(世界观硬规则/定型主角/文风) + `volatile`(注入卡片/伏笔/近况)。
|
||||
纯拼接、无随机/时间戳 → 同输入同输出,便于单测与缓存。
|
||||
"""
|
||||
return (
|
||||
"## 审稿材料:作品真相源(硬规则 / 人物卡 / 近况)\n"
|
||||
f"{stable_core}\n\n{volatile}\n\n"
|
||||
"## 待审本章草稿\n"
|
||||
f"{draft}"
|
||||
)
|
||||
|
||||
|
||||
def build_review_request(spec: AgentSpec, state: ChapterState) -> LlmRequest:
|
||||
"""据 spec + 审稿上下文构造审稿请求(纯函数)。
|
||||
|
||||
`spec.system_prompt` → `system` 缓存断点前块(cache=True,不变量 #9);
|
||||
`review_context`(草稿 + 近况材料)→ `input`(断点后)。续审用 `run()` 非 `stream()`。
|
||||
"""
|
||||
return LlmRequest(
|
||||
tier=spec.tier, # 不变量 #2:只传档位,不传 model
|
||||
system=[Block(text=spec.system_prompt, cache=True)],
|
||||
input=state["review_context"],
|
||||
output_schema=spec.output_schema,
|
||||
scope=Scope(user_id=state["user_id"], project_id=state["project_id"]),
|
||||
)
|
||||
|
||||
|
||||
def make_review_node(spec: AgentSpec, gateway: GatewayRun) -> BoundReviewNode:
|
||||
"""工厂:把一个审稿 `AgentSpec` + 网关包成 LangGraph 节点 `async (state) -> dict`。
|
||||
|
||||
网关经闭包绑定(langgraph 不收 functools.partial,见 gotcha;用 `async def` 闭包)。
|
||||
节点产 `{"reviews": {spec.name: {...}}}`——结构化结果回到 state(**只读不写库**,
|
||||
不变量 #3)。`run_review` 是裸函数(显式 gateway 关键字),供直接单测注入 mock。
|
||||
T2.5 若想「直接跑某审」可用本工厂或 `run_review`。
|
||||
"""
|
||||
|
||||
async def _node(state: ChapterState) -> dict[str, Any]:
|
||||
return await run_review(spec, state, gateway=gateway)
|
||||
|
||||
return _node
|
||||
|
||||
|
||||
async def run_review(
|
||||
spec: AgentSpec,
|
||||
state: ChapterState,
|
||||
*,
|
||||
gateway: GatewayRun,
|
||||
) -> dict[str, Any]:
|
||||
"""跑单个审稿:构请求 → `gateway.run` → 归一为 `{spec.name: {status, result}}`。
|
||||
|
||||
直接调用本函数(注入 mock 网关)即可单测,无需图运行时。
|
||||
任一审失败 → 捕获异常、返回 `incomplete` 占位(§5.2:不阻塞其余、不毁图)。
|
||||
返回**增量字典**(LangGraph 合并进 `state["reviews"]`),不原地改 `state`。
|
||||
"""
|
||||
req = build_review_request(spec, state)
|
||||
try:
|
||||
resp = await gateway.run(req)
|
||||
except Exception as exc: # noqa: BLE001 — 该审失败隔离:标未完成,不上抛(§5.2)
|
||||
log.warning(
|
||||
"review_node_incomplete",
|
||||
review=spec.name,
|
||||
project_id=str(state["project_id"]),
|
||||
chapter_no=state["chapter_no"],
|
||||
error=str(exc),
|
||||
)
|
||||
return {"reviews": {spec.name: {"status": REVIEW_INCOMPLETE, "result": None}}}
|
||||
|
||||
parsed = resp.parsed
|
||||
result = parsed.model_dump() if parsed is not None else None
|
||||
return {"reviews": {spec.name: {"status": REVIEW_OK, "result": result}}}
|
||||
Reference in New Issue
Block a user