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