- 续审 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)
137 lines
5.2 KiB
Python
137 lines
5.2 KiB
Python
"""验收事务 + 冲突 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,
|
||
)
|