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:
Yaojia Wang
2026-06-18 11:38:28 +02:00
parent b523b4fd21
commit 68f194a043
36 changed files with 3881 additions and 0 deletions

View 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]

View 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": ["林动"]}

View 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_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"

View 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