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,286 @@
"""T2.5 review API + 历史 / T2.4 验收事务 + 冲突 gate内存替身无 DB/无网络)。
覆盖review SSE 帧序列section/conflict/done、reviews 历史、accept happy path
(晋升 + digest + 裁决)、冲突 gate 拦截(缺判 → CONFLICT_UNRESOLVED 不写库)、
事务回滚(某步失败全回滚不 commit。审稿/digest 网关均注 mock产 parsed绝不联网。
"""
from __future__ import annotations
import uuid
import httpx
import pytest
from fakes_projects import (
FakeChapterRepo,
FakeDigestAppendRepo,
FakeReviewGateway,
FakeReviewRepo,
FakeSession,
)
from test_projects import _empty_memory_repos
from ww_agents import ContinuityReview
from ww_agents.schemas import Conflict
from ww_shared import ErrorCode
def _make_client(
*,
chapter_repo: FakeChapterRepo | None = None,
review_repo: FakeReviewRepo | None = None,
digest_repo: FakeDigestAppendRepo | None = None,
review_gateway: FakeReviewGateway | None = None,
digest_gateway: FakeReviewGateway | None = None,
session: FakeSession | None = None,
) -> tuple[
httpx.AsyncClient,
FakeChapterRepo,
FakeReviewRepo,
FakeDigestAppendRepo,
FakeSession,
]:
import os
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_chapter_repo,
get_digest_append_repo,
get_digest_gateway,
get_memory_repos,
get_review_gateway,
get_review_repo,
)
from ww_db import get_session
chapter_repo = chapter_repo or FakeChapterRepo()
review_repo = review_repo or FakeReviewRepo()
digest_repo = digest_repo or FakeDigestAppendRepo()
review_gateway = review_gateway or FakeReviewGateway(parsed=ContinuityReview())
digest_gateway = digest_gateway or FakeReviewGateway(parsed=ContinuityReview())
session = session or FakeSession()
app = create_app()
app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
app.dependency_overrides[get_review_repo] = lambda: review_repo
app.dependency_overrides[get_digest_append_repo] = lambda: digest_repo
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
app.dependency_overrides[get_review_gateway] = lambda: review_gateway
app.dependency_overrides[get_digest_gateway] = lambda: digest_gateway
app.dependency_overrides[get_session] = lambda: session
transport = httpx.ASGITransport(app=app)
client = httpx.AsyncClient(transport=transport, base_url="http://test")
return client, chapter_repo, review_repo, digest_repo, session
# ---- T2.5 review SSE ----
@pytest.mark.asyncio
async def test_review_stream_yields_section_and_done() -> None:
gw = FakeReviewGateway(parsed=ContinuityReview())
client, _, review_repo, _, session = _make_client(review_gateway=gw)
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/review", json={"draft": "本章草稿正文。"}
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/event-stream")
text = resp.text
assert "event: section" in text
assert '"name": "continuity"' in text
assert '"status": "done"' in text
assert "event: done" in text
# collect 落了一行留痕;端点流耗尽后 commit。
assert len(review_repo.rows) == 1
assert session.commits == 1
@pytest.mark.asyncio
async def test_review_stream_emits_conflict_events() -> None:
review = ContinuityReview(
conflicts=[
Conflict(type="性格漂移", where="第3段", refs=["第2章"], suggestion="改回冷静"),
]
)
gw = FakeReviewGateway(parsed=review)
client, _, review_repo, _, _ = _make_client(review_gateway=gw)
pid = uuid.uuid4()
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/review", json={"draft": "草稿"})
text = resp.text
assert "event: conflict" in text
assert '"type": "性格漂移"' in text
# 冲突落进 chapter_reviews 留痕。
assert review_repo.rows[0].conflicts[0]["type"] == "性格漂移"
@pytest.mark.asyncio
async def test_review_without_draft_falls_back_to_saved() -> None:
chapter_repo = FakeChapterRepo()
pid = uuid.uuid4()
await chapter_repo.save_draft(pid, 1, text="已存草稿")
client, _, _, _, _ = _make_client(chapter_repo=chapter_repo)
async with client:
resp = await client.post(f"/projects/{pid}/chapters/1/review", json={})
assert resp.status_code == 200
assert "event: done" in resp.text
@pytest.mark.asyncio
async def test_review_without_any_draft_404() -> None:
client, _, _, _, _ = _make_client()
pid = uuid.uuid4()
async with client:
resp = await client.post(f"/projects/{pid}/chapters/9/review", json={})
assert resp.status_code == 404
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
# ---- T2.5 reviews 历史 ----
@pytest.mark.asyncio
async def test_list_reviews_returns_history_newest_first() -> None:
review_repo = FakeReviewRepo()
pid = uuid.uuid4()
await review_repo.record(pid, 1, conflicts=[{"type": "设定违例"}])
await review_repo.record(pid, 1, conflicts=[])
client, _, _, _, _ = _make_client(review_repo=review_repo)
async with client:
resp = await client.get(f"/projects/{pid}/chapters/1/reviews")
assert resp.status_code == 200
rows = resp.json()["reviews"]
assert len(rows) == 2
# 新→旧:最近 record 的(空冲突)在前。
assert rows[0]["conflicts"] == []
# ---- T2.4 accept happy path ----
async def _seed_review(
review_repo: FakeReviewRepo, pid: uuid.UUID, conflicts: list[dict[str, object]]
) -> None:
await review_repo.record(pid, 1, conflicts=conflicts)
@pytest.mark.asyncio
async def test_accept_promotes_appends_digest_records_decisions() -> None:
review_repo = FakeReviewRepo()
pid = uuid.uuid4()
await _seed_review(
review_repo, pid, [{"type": "性格漂移", "where": "x", "refs": [], "suggestion": "y"}]
)
from ww_api.services.digest_extraction import ChapterDigestFacts
digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="本章主线", events=["开战"]))
client, chapter_repo, _, digest_repo, session = _make_client(
review_repo=review_repo, digest_gateway=digest_gw
)
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/accept",
json={
"final_text": "作者改稿后的终稿正文。",
"decisions": [{"conflict_index": 0, "verdict": "accept"}],
},
)
assert resp.status_code == 200
body = resp.json()
assert body["accepted_version"] == 1
assert body["digest_added"] is True
assert body["decisions_recorded"] == 1
# 晋升落 accepted v1终稿正文。
accepted = await chapter_repo.latest_accepted(pid, 1)
assert accepted is not None
assert accepted.content == "作者改稿后的终稿正文。"
# digest 从终稿提炼(不变量 #4
assert digest_repo.rows[0][2]["summary"] == "本章主线"
# 裁决写回留痕。
assert review_repo.rows[0].decisions is not None
assert review_repo.rows[0].decisions["items"][0]["verdict"] == "accept"
# 单事务一次 commit。
assert session.commits == 1
@pytest.mark.asyncio
async def test_accept_with_no_review_succeeds() -> None:
"""无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。"""
from ww_api.services.digest_extraction import ChapterDigestFacts
digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s"))
client, chapter_repo, _, digest_repo, session = _make_client(digest_gateway=digest_gw)
pid = uuid.uuid4()
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/accept",
json={"final_text": "终稿", "decisions": []},
)
assert resp.status_code == 200
assert resp.json()["accepted_version"] == 1
assert session.commits == 1
assert len(digest_repo.rows) == 1
# ---- T2.4 冲突 gate 拦截 ----
@pytest.mark.asyncio
async def test_accept_blocks_when_conflict_unresolved() -> None:
review_repo = FakeReviewRepo()
pid = uuid.uuid4()
await _seed_review(
review_repo,
pid,
[
{"type": "性格漂移", "where": "a", "refs": [], "suggestion": "b"},
{"type": "设定违例", "where": "c", "refs": [], "suggestion": "d"},
],
)
digest_gw = FakeReviewGateway(parsed=ContinuityReview())
client, chapter_repo, _, digest_repo, session = _make_client(
review_repo=review_repo, digest_gateway=digest_gw
)
async with client:
resp = await client.post(
f"/projects/{pid}/chapters/1/accept",
json={
"final_text": "终稿",
# 只裁决了 0 号冲突1 号缺判 → 拦截。
"decisions": [{"conflict_index": 0, "verdict": "ignore"}],
},
)
assert resp.status_code == 409
assert resp.json()["error"]["code"] == ErrorCode.CONFLICT_UNRESOLVED
assert resp.json()["error"]["details"]["missing_conflict_indices"] == [1]
# 不写库:未晋升、未提炼 digest、未提交、未调 digest 网关。
assert await chapter_repo.latest_accepted(pid, 1) is None
assert len(digest_repo.rows) == 0
assert session.commits == 0
assert len(digest_gw.requests) == 0
# ---- T2.4 事务回滚 ----
@pytest.mark.asyncio
async def test_accept_rolls_back_when_a_step_fails() -> None:
"""裁决留痕步骤抛错 → 整体回滚(不 commit"""
review_repo = FakeReviewRepo(fail_set_decisions=True)
pid = uuid.uuid4()
await _seed_review(review_repo, pid, [])
from ww_api.services.digest_extraction import ChapterDigestFacts
digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s"))
client, _, _, _, session = _make_client(review_repo=review_repo, digest_gateway=digest_gw)
# set_decisions 在事务中途抛错ASGITransport 默认上抛未处理异常。
with pytest.raises(RuntimeError, match="rollback test"):
async with client:
await client.post(
f"/projects/{pid}/chapters/1/accept",
json={"final_text": "终稿", "decisions": []},
)
# 关键:事务未 commit半态不落库整体回滚
assert session.commits == 0