"""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, SchemaRoutingRunGateway, ) from langchain_core.runnables import RunnableConfig from langgraph.checkpoint.memory import MemorySaver from ww_agents import ( Conflict, ContinuityReview, ForeshadowReview, ForeshadowSuggestion, PaceIssue, PaceReview, continuity_spec, foreshadow_spec, pace_spec, ) from ww_core.orchestrator import ( CONTINUITY, EVENT_CONFLICT, EVENT_DONE, EVENT_ERROR, EVENT_FORESHADOW, EVENT_PACE, EVENT_SECTION, FORESHADOW, PACE, REVIEW_INCOMPLETE, REVIEW_OK, SECTION_DONE, SECTION_INCOMPLETE, ChapterState, build_review_context, build_review_graph, build_review_request, collect_reviews, extract_conflicts, extract_foreshadow_sug, extract_pace, 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" # ============================================================================ # T3.3 三审齐:foreshadow + pace 并入并行审 / collect 列映射 / SSE surface # ============================================================================ _FORESHADOW = ForeshadowReview( planted=[ ForeshadowSuggestion(code="FS-MARK", title="胸口胎记", where="第2段", note="皇族暗示") ], resolved=[ForeshadowSuggestion(code="FS-BIRTH", title="身世", where="第9段", note="回收")], ) _PACE = PaceReview( water=[PaceIssue(where="第4-6段", reason="信息密度低")], hook=True, beat_map=[1, 3, 5, 0, 4], ) def _three_reviews() -> dict[str, Any]: """三审皆 ok 的 state['reviews'](continuity/foreshadow/pace)。""" return { CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}, FORESHADOW: {"status": REVIEW_OK, "result": _FORESHADOW.model_dump()}, PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()}, } # ---- collect 三审列映射 ---- def test_extract_foreshadow_sug_flattens_planted_and_resolved() -> None: sug = extract_foreshadow_sug(_three_reviews()) assert len(sug) == 2 # 1 planted + 1 resolved 扁平 kinds = {s["kind"] for s in sug} assert kinds == {"planted", "resolved"} planted = next(s for s in sug if s["kind"] == "planted") assert planted["code"] == "FS-MARK" def test_extract_pace_returns_dict() -> None: pace = extract_pace(_three_reviews()) assert pace is not None assert pace["hook"] is True assert pace["beat_map"] == [1, 3, 5, 0, 4] def test_extract_foreshadow_and_pace_empty_when_incomplete() -> None: reviews = { FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None}, PACE: {"status": REVIEW_INCOMPLETE, "result": None}, } assert extract_foreshadow_sug(reviews) == [] assert extract_pace(reviews) is None async def test_collect_records_three_audits_to_their_columns() -> None: repo = FakeReviewRepo() await collect_reviews(_state(_three_reviews()), review_repo=repo) assert len(repo.records) == 1 # 一次 record 落齐三审 rec = repo.records[0] assert rec["conflicts"][0]["type"] == "能力不符" # continuity→conflicts assert len(rec["foreshadow_sug"]) == 2 # foreshadow→foreshadow_sug(扁平) assert rec["pace"]["hook"] is True # pace→pace # ---- 三审并行图跑通(mock 网关按 schema 路由产三 parsed)---- async def test_three_review_graph_runs_all_audits_end_to_end() -> None: gateway = SchemaRoutingRunGateway( { ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]), ForeshadowReview: _FORESHADOW, PaceReview: _PACE, } ) repo = FakeReviewRepo() graph = build_review_graph( gateway, repo, review_specs=(continuity_spec, foreshadow_spec, pace_spec), checkpointer=MemorySaver(), ) config: RunnableConfig = {"configurable": {"thread_id": "rev-3"}} final = await graph.ainvoke(_state(), config=config) assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK assert final["reviews"][FORESHADOW]["status"] == REVIEW_OK assert final["reviews"][PACE]["status"] == REVIEW_OK assert len(gateway.calls) == 3 # 三审各调一次网关 # collect 一次 record 落齐三列 rec = repo.records[0] assert rec["conflicts"] and rec["foreshadow_sug"] and rec["pace"] async def test_three_review_graph_isolates_one_failing_audit() -> None: # foreshadow schema 缺失 → SchemaRoutingRunGateway 抛 KeyError → run_review 隔离为 incomplete gateway = SchemaRoutingRunGateway( { ContinuityReview: ContinuityReview(conflicts=[_CONFLICT]), PaceReview: _PACE, } ) repo = FakeReviewRepo() graph = build_review_graph( gateway, repo, review_specs=(continuity_spec, foreshadow_spec, pace_spec), checkpointer=MemorySaver(), ) config: RunnableConfig = {"configurable": {"thread_id": "rev-4"}} final = await graph.ainvoke(_state(), config=config) # foreshadow 失败被隔离,其余两审照常 + collect 照常留痕(foreshadow_sug 空) assert final["reviews"][FORESHADOW]["status"] == REVIEW_INCOMPLETE assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK assert final["reviews"][PACE]["status"] == REVIEW_OK rec = repo.records[0] assert rec["conflicts"] # continuity 落库 assert rec["foreshadow_sug"] == [] # 失败列留空,不阻塞其余 assert rec["pace"]["hook"] is True # pace 落库 # ---- SSE:normalize_review surface 三审(section + conflict + foreshadow + pace + done)---- async def test_normalize_review_surfaces_three_audits() -> None: events = [e async for e in normalize_review(_three_reviews())] kinds = [e.event for e in events] # 字典序遍历:continuity < foreshadow < pace # continuity: section + 1 conflict # foreshadow: section + 2(planted+resolved);pace: section + 1 assert kinds == [ EVENT_SECTION, EVENT_CONFLICT, EVENT_SECTION, EVENT_FORESHADOW, EVENT_FORESHADOW, EVENT_SECTION, EVENT_PACE, EVENT_DONE, ] # foreshadow 事件带 kind + code fs_events = [e for e in events if e.event == EVENT_FORESHADOW] assert {e.data["kind"] for e in fs_events} == {"planted", "resolved"} # pace 事件带节拍图 pace_evt = next(e for e in events if e.event == EVENT_PACE) assert pace_evt.data["beat_map"] == [1, 3, 5, 0, 4] assert pace_evt.data["hook"] is True assert events[-1].data == {"length": 3} # 三审项 async def test_normalize_review_skips_results_for_incomplete_audit() -> None: reviews = { CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": []}}, FORESHADOW: {"status": REVIEW_INCOMPLETE, "result": None}, PACE: {"status": REVIEW_OK, "result": _PACE.model_dump()}, } events = [e async for e in normalize_review(reviews)] kinds = [e.event for e in events] # foreshadow incomplete → 仅 section,无 foreshadow 事件 assert kinds == [EVENT_SECTION, EVENT_SECTION, EVENT_SECTION, EVENT_PACE, EVENT_DONE] fs_section = next(e for e in events if e.data.get("name") == FORESHADOW) assert fs_section.data["status"] == SECTION_INCOMPLETE