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:
136
apps/api/ww_api/services/accept_service.py
Normal file
136
apps/api/ww_api/services/accept_service.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""验收事务 + 冲突 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,
|
||||
)
|
||||
99
apps/api/ww_api/services/digest_extraction.py
Normal file
99
apps/api/ww_api/services/digest_extraction.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""终稿 digest 提炼(验收事务的 R2 步骤;ARCH §5.5 / §6.1,不变量 #4)。
|
||||
|
||||
验收时用**终稿**(作者裁决/改稿后的最终文本)经网关跑一次轻量结构化提炼,得 digest
|
||||
(结构化事实)。**在开原子事务之前**完成——别在持开事务里跨网络调 LLM(R2)。
|
||||
|
||||
`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 时必非 None(C1);防御性兜底:若 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
|
||||
Reference in New Issue
Block a user