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,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