Files
writer-work-flow/packages/core/tests/test_review.py
Yaojia Wang 68f194a043 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)
2026-06-18 11:38:28 +02:00

245 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_requesttier 不变量、缓存断点、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全程 mockMemorySaver ----
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 + doneincomplete 路径异常→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:
# 畸形 entryresult 非 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"