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

View File

@@ -0,0 +1,136 @@
"""验收事务 + 冲突 gateT2.4ARCH §5.5 四步,不变量 #3/#4/#5
把作者裁决后的变更**单原子事务**落库R3/R4晋升终稿 → 终稿提炼 digest →
裁决留痕 →(占位)人物/伏笔状态。digest 提炼在**事务外**先做R2
冲突 gateR5事务前拦截核对最近一次审稿留痕的**每个冲突**在裁决清单里都有
采纳/忽略/手改之一;缺判 → `AppError(CONFLICT_UNRESOLVED)`,不写库。
accept 是**确定性事务代码**,不跑 graph 正文/审稿节点;真相从领域表(`chapters` /
`chapter_reviews`重读R3不变量 #5。提交边界全部只 flush → 末尾一次
`await session.commit()`;任一步失败 → 整体回滚。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
import structlog
from ww_core.domain.chapter_repo import ChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.review_repo import ReviewRepo, ReviewView
from ww_shared import AppError, ErrorCode
from ww_api.schemas.projects import ConflictDecision
log = structlog.get_logger(__name__)
@dataclass(frozen=True)
class AcceptOutcome:
"""验收事务结果(供端点组「本次将更新」清单)。"""
accepted_version: int
digest_added: bool
decisions_recorded: int
review_id: uuid.UUID | None
def assert_conflicts_resolved(
latest_review: ReviewView | None,
decisions: list[ConflictDecision],
) -> None:
"""冲突 gateR5最近审稿的每个冲突都须有裁决否则 `CONFLICT_UNRESOLVED`。
判据:以最近审稿留痕的 `conflicts` 列表下标为冲突身份;裁决清单里出现的
`conflict_index` 集合必须**覆盖** `range(len(conflicts))`。缺判 → 拦截(不写库)。
无审稿留痕或零冲突 → 直接通过(无需裁决)。纯函数:只判定、不副作用。
"""
if latest_review is None:
return
conflict_count = len(latest_review.conflicts)
if conflict_count == 0:
return
decided = {d.conflict_index for d in decisions}
missing = [i for i in range(conflict_count) if i not in decided]
if missing:
raise AppError(
ErrorCode.CONFLICT_UNRESOLVED,
"存在未裁决的冲突,无法验收:请对每个冲突选择 采纳/忽略/手改",
{"missing_conflict_indices": missing, "conflict_count": conflict_count},
)
def _serialize_decisions(
latest_review: ReviewView | None,
decisions: list[ConflictDecision],
) -> dict[str, object]:
"""把裁决清单序列化为可落 `chapter_reviews.decisions` 的 JSON 形(确定性)。"""
return {
"items": [
{
"conflict_index": d.conflict_index,
"verdict": d.verdict,
"note": d.note,
}
for d in sorted(decisions, key=lambda x: x.conflict_index)
],
"conflict_count": len(latest_review.conflicts) if latest_review else 0,
}
async def run_accept_transaction(
*,
session: object,
chapter_repo: ChapterRepo,
digest_repo: DigestAppendRepo,
review_repo: ReviewRepo,
project_id: uuid.UUID,
chapter_no: int,
final_text: str,
digest_facts: dict[str, object],
latest_review: ReviewView | None,
decisions: list[ConflictDecision],
) -> AcceptOutcome:
"""单原子事务落库R3/R4§5.5 步骤 14末尾一次 commit。
`digest_facts` 已在事务外提炼好R2。各 repo 写方法只 flush本函数统一在末尾
`await session.commit()`,任一步抛错由调用方/上下文回滚(不显式半提交)。
`session` 类型用 object 以免绑定 SQLAlchemy测试注入 fake session 亦可)。
"""
# 步骤 1终稿晋升 accepted 新 versionmax+1草稿行保留R4
chapter = await chapter_repo.promote_to_accepted(project_id, chapter_no, content=final_text)
# 步骤 2终稿 digest 追加append-only不变量 #4
await digest_repo.append(project_id, chapter_no, facts=digest_facts)
# 步骤 4裁决留痕写到最近一次审稿行无审稿行则跳过
review_id: uuid.UUID | None = None
if latest_review is not None:
updated = await review_repo.set_decisions(
latest_review.id,
decisions=_serialize_decisions(latest_review, decisions),
)
review_id = updated.id
# 步骤 3占位人物 latest_state / 伏笔状态更新——M3 才正式接伏笔表,
# 这里按 §5.5 步骤 3 留占位,不引入 M3 表逻辑(避免越界写未就绪的状态机)。
# TODO(M3): 按裁决应用 latest_state 变更 + 伏笔登记/到期扫描§6.2)。
await session.commit() # type: ignore[attr-defined] # AsyncSession.commit()fake 同形)
log.info(
"chapter_accepted",
project_id=str(project_id),
chapter_no=chapter_no,
accepted_version=chapter.version,
decisions_recorded=len(decisions),
review_id=str(review_id) if review_id else None,
)
return AcceptOutcome(
accepted_version=chapter.version,
digest_added=True,
decisions_recorded=len(decisions),
review_id=review_id,
)

View File

@@ -0,0 +1,99 @@
"""终稿 digest 提炼(验收事务的 R2 步骤ARCH §5.5 / §6.1,不变量 #4
验收时用**终稿**(作者裁决/改稿后的最终文本)经网关跑一次轻量结构化提炼,得 digest
(结构化事实)。**在开原子事务之前**完成——别在持开事务里跨网络调 LLMR2
`ChapterDigestFacts` 是 digest 的结构化形:本章关键事实清单,供后续章节注入近况摘要
assemble 的 `recent_digests`+ 一致性比对。轻量档位tier=light只读终稿、产事实。
记账digest 提炼这次网关调用产 usage经 `SqlAlchemyLedgerSink` flush 进**同一请求
session**——由验收事务在末尾一次 commit 落 `usage_ledger`(见 ledger gotcha
"""
from __future__ import annotations
import uuid
from typing import Any
import structlog
from pydantic import BaseModel, Field
from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope
log = structlog.get_logger(__name__)
DIGEST_SYSTEM_PROMPT = """你是长篇连载小说的「章节摘要提炼」。读入本章**终稿**,抽取后续\
章节一致性比对所需的结构化事实,产出结构化摘要。
只提炼**终稿明确写出**的事实,不臆造、不推断未写明的内容:
- summary本章一句话主线≤60 字);
- events本章发生的关键事件按时序
- characters登场人物及其本章状态变化姓名 + 状态/变化);
- locations出现的地点
- foreshadow本章埋下或回收的伏笔线索。
纪律:只读终稿、只产事实,不评价、不改稿、不报冲突(冲突在审稿期产)。"""
class CharacterStateFact(BaseModel):
"""单个人物的本章状态事实。"""
name: str = Field(description="人物姓名")
state: str = Field(description="本章该人物的状态/变化")
class ChapterDigestFacts(BaseModel):
"""终稿提炼的结构化事实(落 `chapter_digests.facts`)。"""
summary: str = Field(default="", description="本章一句话主线")
events: list[str] = Field(default_factory=list, description="关键事件(时序)")
characters: list[CharacterStateFact] = Field(
default_factory=list, description="登场人物及其本章状态变化"
)
locations: list[str] = Field(default_factory=list, description="出现的地点")
foreshadow: list[str] = Field(default_factory=list, description="埋下/回收的伏笔线索")
def build_digest_request(
*, final_text: str, user_id: uuid.UUID, project_id: uuid.UUID
) -> LlmRequest:
"""据终稿构造 digest 提炼请求(纯函数)。
`system_prompt` 进缓存断点前块;终稿进 `input`断点后。tier=light不变量 #2
`output_schema=ChapterDigestFacts` → 网关经 instructor 保证产结构化实例C1
"""
return LlmRequest(
tier="light",
system=[Block(text=DIGEST_SYSTEM_PROMPT, cache=True)],
input=f"## 本章终稿\n{final_text}",
output_schema=ChapterDigestFacts,
scope=Scope(user_id=user_id, project_id=project_id),
)
async def extract_digest_facts(
gateway: Gateway,
*,
final_text: str,
user_id: uuid.UUID,
project_id: uuid.UUID,
chapter_no: int,
) -> dict[str, Any]:
"""从终稿提炼结构化事实,返回可直接落 `facts` 列的 dict。
**在开原子事务之前**调用R2别在持开事务里跨网络调 LLM。`gateway.run(req).parsed`
带 schema 时必非 NoneC1防御性兜底若 parsed 缺失则落空事实(不崩验收)。
日志脱敏:只记终稿长度,不记正文。
"""
req = build_digest_request(final_text=final_text, user_id=user_id, project_id=project_id)
resp = await gateway.run(req)
parsed = resp.parsed
facts = parsed.model_dump() if parsed is not None else ChapterDigestFacts().model_dump()
log.info(
"digest_extracted",
project_id=str(project_id),
chapter_no=chapter_no,
final_text_len=len(final_text),
event_count=len(facts.get("events", [])),
)
return facts