- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点 - 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件 - 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板 - 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE - bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试 - M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug - 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""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,
|
||
FakeSessionFactory,
|
||
)
|
||
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,
|
||
get_session_factory,
|
||
)
|
||
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
|
||
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
|
||
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
|