Files
writer-work-flow/apps/api/ww_api/services/accept_service.py
Yaojia Wang 68f194a043 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)
2026-06-18 11:38:28 +02:00

137 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""验收事务 + 冲突 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,
)