"""验收事务 + 冲突 gate(T2.4;ARCH §5.5 四步,不变量 #3/#4/#5)。 把作者裁决后的变更**单原子事务**落库(R3/R4):晋升终稿 → 终稿提炼 digest → 裁决留痕 →(占位)人物/伏笔状态。digest 提炼在**事务外**先做(R2)。 冲突 gate(R5,事务前拦截):核对最近一次审稿留痕的**每个冲突**在裁决清单里都有 采纳/忽略/手改之一;缺判 → `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: """冲突 gate(R5):最近审稿的每个冲突都须有裁决,否则 `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 步骤 1–4),末尾一次 commit。 `digest_facts` 已在事务外提炼好(R2)。各 repo 写方法只 flush;本函数统一在末尾 `await session.commit()`,任一步抛错由调用方/上下文回滚(不显式半提交)。 `session` 类型用 object 以免绑定 SQLAlchemy(测试注入 fake session 亦可)。 """ # 步骤 1:终稿晋升 accepted 新 version(max+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, )