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