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,37 @@
"""领域 Repository 协议 + SQLAlchemy 实现 + 只读视图ARCH §3.5)。"""
from __future__ import annotations
from ww_core.domain.chapter_repo import (
ChapterDraftView,
ChapterRepo,
ChapterView,
SqlChapterRepo,
)
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.project_repo import (
ProjectCreate,
ProjectRepo,
ProjectView,
SqlProjectRepo,
)
from ww_core.domain.repositories import DigestView, MemoryRepos
from ww_core.domain.review_repo import ReviewRepo, ReviewView, SqlReviewRepo
__all__ = [
"ChapterDraftView",
"ChapterRepo",
"ChapterView",
"SqlChapterRepo",
"DigestAppendRepo",
"SqlDigestAppendRepo",
"DigestView",
"MemoryRepos",
"ProjectCreate",
"ProjectRepo",
"ProjectView",
"SqlProjectRepo",
"ReviewRepo",
"ReviewView",
"SqlReviewRepo",
]

View File

@@ -0,0 +1,53 @@
"""章节摘要**写**侧 Repositoryappend-onlyARCH §3.3 / §5.5 / §6.1)。
`chapter_digests` 是 append-only每次验收从**终稿**提炼一行事实,**永不覆盖**历史
(不变量 #4digest 从终稿提炼)。读侧(`recent`,供 assemble 注入)已在
`ww_core.memory.sql_repositories.SqlDigestRepo` / `domain.repositories.DigestRepo` 提供;
本模块只加**写**能力,不动读侧。
命名:写侧 Protocol 命名 `DigestAppendRepo`(区别于 `repositories.DigestRepo` 读侧),
共用 `DigestView`,避免同名歧义(见 memory/decisions
"""
from __future__ import annotations
import uuid
from typing import Any, Protocol
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import ChapterDigest
from ww_core.domain.repositories import DigestView
class DigestAppendRepo(Protocol):
"""摘要追加接口append-only按 project_id 隔离)。"""
async def append(
self, project_id: uuid.UUID, chapter_no: int, *, facts: dict[str, Any]
) -> DigestView:
"""追加一行 digest永不覆盖既有行返回不可变 frozen View。"""
...
class SqlDigestAppendRepo:
"""SQLAlchemy 实现:始终 INSERT 新行append-only
**只 flush 不 commit**验收事务T2.4)把晋升+digest+裁决留痕作为单事务提交。
"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def append(
self, project_id: uuid.UUID, chapter_no: int, *, facts: dict[str, Any]
) -> DigestView:
row = ChapterDigest(
project_id=project_id,
chapter_no=chapter_no,
facts=dict(facts),
)
self._s.add(row)
await self._s.flush()
await self._s.refresh(row)
return DigestView(chapter_no=row.chapter_no, facts=dict(row.facts))

View File

@@ -0,0 +1,138 @@
"""审稿留痕 Repository四审报告 + 裁决ARCH §5.4 / §5.5 / §6.1)。
- `record`T2.2 collect 把并行四审结果 append 一行 `chapter_reviews`(每次审稿一行)。
- `list_for_chapter`T2.5 审稿历史(新→旧)。
- `set_decisions`T2.4 验收事务里写本次裁决留痕(`chapter_reviews.decisions`)。
四审只读、不直接写库;本 repo 是「审稿结果落库」与「裁决留痕」的唯一写入点
(不变量 #3AI 产出经验收 gate。`record`/`set_decisions` 只 flush 不 commit
便于在编排/验收事务里组合提交。
"""
from __future__ import annotations
import uuid
from typing import Any, Protocol
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import ChapterReview
class ReviewView(BaseModel):
"""只读审稿留痕快照snake_casefrozen"""
model_config = {"frozen": True}
id: uuid.UUID
project_id: uuid.UUID
chapter_no: int
chapter_version: int | None = None
conflicts: list[dict[str, Any]] = Field(default_factory=list)
foreshadow_sug: list[dict[str, Any]] = Field(default_factory=list)
style: dict[str, Any] | None = None
pace: dict[str, Any] | None = None
health_score: int | None = None
decisions: dict[str, Any] | None = None
class ReviewRepo(Protocol):
"""审稿留痕读写接口(按 project_id 隔离)。"""
async def record(
self,
project_id: uuid.UUID,
chapter_no: int,
*,
chapter_version: int | None = None,
conflicts: list[dict[str, Any]],
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
health_score: int | None = None,
) -> ReviewView:
"""append 一行审稿报告T2.2 collect 写入)。"""
...
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
"""该章审稿历史新→旧T2.5)。"""
...
async def set_decisions(self, review_id: uuid.UUID, *, decisions: dict[str, Any]) -> ReviewView:
"""写本次裁决留痕到指定审稿行T2.4 验收事务)。"""
...
def _to_view(row: ChapterReview) -> ReviewView:
return ReviewView(
id=row.id,
project_id=row.project_id,
chapter_no=row.chapter_no,
chapter_version=row.chapter_version,
conflicts=list(row.conflicts or []),
foreshadow_sug=list(row.foreshadow_sug or []),
style=row.style,
pace=row.pace,
health_score=row.health_score,
decisions=row.decisions,
)
class SqlReviewRepo:
"""SQLAlchemy 实现append 审稿行 + 历史查询 + 裁决留痕(只 flush 不 commit"""
def __init__(self, session: AsyncSession) -> None:
self._s = session
async def record(
self,
project_id: uuid.UUID,
chapter_no: int,
*,
chapter_version: int | None = None,
conflicts: list[dict[str, Any]],
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
health_score: int | None = None,
) -> ReviewView:
row = ChapterReview(
project_id=project_id,
chapter_no=chapter_no,
chapter_version=chapter_version,
conflicts=list(conflicts),
foreshadow_sug=list(foreshadow_sug or []),
style=style,
pace=pace,
health_score=health_score,
decisions=None,
)
self._s.add(row)
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
rows = (
await self._s.execute(
select(ChapterReview)
.where(
ChapterReview.project_id == project_id,
ChapterReview.chapter_no == chapter_no,
)
.order_by(ChapterReview.created_at.desc())
)
).scalars()
return [_to_view(r) for r in rows]
async def set_decisions(self, review_id: uuid.UUID, *, decisions: dict[str, Any]) -> ReviewView:
row = (
await self._s.execute(select(ChapterReview).where(ChapterReview.id == review_id))
).scalar_one_or_none()
if row is None:
raise LookupError(f"chapter_review not found: {review_id}")
row.decisions = dict(decisions)
await self._s.flush()
await self._s.refresh(row)
return _to_view(row)

View File

@@ -0,0 +1,92 @@
"""collect 节点C4 扩 / ARCH §5.2 collect 行)——汇总并行审 → 落 `chapter_reviews` 留痕。
并行四审各把 `{spec.name: {status, result}}` 合并进 `state["reviews"]`reducer见 state.py
collect 把它们汇总,调 `review_repo.record(...)` append 一行 `chapter_reviews`(真相源留痕)。
不变量 #3审稿节点只读不写库**唯一写入点是这里经 `review_repo`**(仍是「留痕」非「生效」,
AI 产出真正入库经 T2.4 验收事务的裁决)。
记账边界gotcha「网关 ledger 只 flush」审稿经网关产 usage网关内 `flush` 进同一 session
collect 与 review_repo 同样**只 flush 不 commit**——`commit` 归 HTTP 端点T2.5/验收事务T2.4
与 draft 端点一致(端点在流耗尽后 `await session.commit()`)。本节点绝不 commit。
M2 只接 continuity从 `reviews["continuity"].result.conflicts` 抽冲突落 `conflicts` 列;
foreshadow/style/pace 列留空M3/M4 接入后填)。设计成按 spec 名取分项,便于扩展。
"""
from __future__ import annotations
import uuid
from typing import Any, Protocol
import structlog
from .review_node import REVIEW_OK
from .state import ChapterState
log = structlog.get_logger(__name__)
CONTINUITY = "continuity"
class ReviewRecorder(Protocol):
"""collect 对审稿留痕 repo 的最小依赖——只需 `record`(注入真 repo 或内存 fake
形对齐 `domain.review_repo.ReviewRepo.record`(只 flush 不 commit
"""
async def record(
self,
project_id: uuid.UUID,
chapter_no: int,
*,
chapter_version: int | None = None,
conflicts: list[dict[str, Any]],
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
health_score: int | None = None,
) -> Any: ...
def extract_conflicts(reviews: dict[str, Any]) -> list[dict[str, Any]]:
"""从 continuity 分项抽冲突清单(纯函数)。
仅当该审 `ok` 且有结果时取其 `conflicts`;未完成/缺席 → 空列表(不臆造)。
"""
entry = reviews.get(CONTINUITY)
if not entry or entry.get("status") != REVIEW_OK:
return []
result = entry.get("result") or {}
conflicts = result.get("conflicts") or []
return [dict(c) for c in conflicts]
async def collect_reviews(
state: ChapterState,
*,
review_repo: ReviewRecorder,
) -> dict[str, Any]:
"""collect 节点:汇总并行审 → 抽冲突 → `review_repo.record` 留痕(只 flush 不 commit
直接调用本函数(注入内存 fake repo即可单测无需图运行时。
`chapter_version=None`:留痕行不绑具体 versionT2.4 验收时再绑/裁决R3/R4
返回空增量字典——审稿真相已在表state 不作真相源(不变量 #5
"""
reviews = state.get("reviews") or {}
conflicts = extract_conflicts(reviews)
await review_repo.record(
state["project_id"],
state["chapter_no"],
chapter_version=None,
conflicts=conflicts,
)
log.info(
"collect_reviews_recorded",
project_id=str(state["project_id"]),
chapter_no=state["chapter_no"],
conflict_count=len(conflicts),
reviews=sorted(reviews.keys()),
)
# 留痕已在表(真相源);不在节点 commit端点/事务层负责,见模块 docstring
return {}

View File

@@ -0,0 +1,118 @@
"""审稿节点 + 审稿上下文拼装C4 扩 / ARCH §5.2 §5.4 reviewer 行)。
M2 先接 **continuity**C6 `continuity_spec`),设计成可扩——图按一组 review specs
循环加并行分支M3/M4 再加 foreshadow/style/pace 三审)。
确定性边界CLAUDE.md「LangGraph」纪律
- 节点逻辑里**无 LLM 非确定性**——不确定性藏在网关后;节点只做
`AgentSpec + review_context → LlmRequest` 的纯构造 + 转发 `Gateway.run` 的 `parsed`。
- 因此注入 mock 网关(产 `parsed`)即可单测,不需要图运行时、不需要真 Postgres。
不变量 #2agent 只声明 `tier`,绝不传具体 modelspec.tier 透传)。
不变量 #3审稿节点**只读、不写库**——结构化结果回到 state落库经 collect→review_repo。
不变量 #9`spec.system_prompt` 进缓存断点前块cache=True草稿+近况材料进 `input`(断点后)。
任一审失败不阻塞其余§5.2):节点内捕获网关异常 → 返回该审「未完成」结构化占位,
不上抛、不毁图——其余并行分支照常汇入 collect。
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any, Protocol
import structlog
from ww_agents import AgentSpec
from ww_llm_gateway.types import Block, LlmRequest, LlmResponse, Scope
from .state import ChapterState
log = structlog.get_logger(__name__)
# 审稿分项状态(落 state[reviews][name].statuscollect / SSE 据此分流)。
REVIEW_OK = "ok"
REVIEW_INCOMPLETE = "incomplete" # 该审网关失败 → 标未完成不阻塞其余§5.2
# 已绑定 gateway 的节点形(图工厂 / T2.5 直接跑审稿时用)。
BoundReviewNode = Callable[[ChapterState], Awaitable[dict[str, Any]]]
class GatewayRun(Protocol):
"""审稿节点对网关的最小依赖——只需 `run`(注入真网关或 mock"""
async def run(self, req: LlmRequest) -> LlmResponse: ...
def build_review_context(*, draft: str, stable_core: str, volatile: str) -> str:
"""拼装审稿注入文本(确定性,无时间戳)。
审稿材料 = 本章草稿 + 近况摘要/人物卡/世界观硬规则——后者复用 `memory.assemble`
的 `stable_core`(世界观硬规则/定型主角/文风) + `volatile`(注入卡片/伏笔/近况)。
纯拼接、无随机/时间戳 → 同输入同输出,便于单测与缓存。
"""
return (
"## 审稿材料:作品真相源(硬规则 / 人物卡 / 近况)\n"
f"{stable_core}\n\n{volatile}\n\n"
"## 待审本章草稿\n"
f"{draft}"
)
def build_review_request(spec: AgentSpec, state: ChapterState) -> LlmRequest:
"""据 spec + 审稿上下文构造审稿请求(纯函数)。
`spec.system_prompt` → `system` 缓存断点前块cache=True不变量 #9
`review_context`(草稿 + 近况材料)→ `input`(断点后)。续审用 `run()` 非 `stream()`。
"""
return LlmRequest(
tier=spec.tier, # 不变量 #2只传档位不传 model
system=[Block(text=spec.system_prompt, cache=True)],
input=state["review_context"],
output_schema=spec.output_schema,
scope=Scope(user_id=state["user_id"], project_id=state["project_id"]),
)
def make_review_node(spec: AgentSpec, gateway: GatewayRun) -> BoundReviewNode:
"""工厂:把一个审稿 `AgentSpec` + 网关包成 LangGraph 节点 `async (state) -> dict`。
网关经闭包绑定langgraph 不收 functools.partial见 gotcha用 `async def` 闭包)。
节点产 `{"reviews": {spec.name: {...}}}`——结构化结果回到 state**只读不写库**
不变量 #3。`run_review` 是裸函数(显式 gateway 关键字),供直接单测注入 mock。
T2.5 若想「直接跑某审」可用本工厂或 `run_review`。
"""
async def _node(state: ChapterState) -> dict[str, Any]:
return await run_review(spec, state, gateway=gateway)
return _node
async def run_review(
spec: AgentSpec,
state: ChapterState,
*,
gateway: GatewayRun,
) -> dict[str, Any]:
"""跑单个审稿:构请求 → `gateway.run` → 归一为 `{spec.name: {status, result}}`。
直接调用本函数(注入 mock 网关)即可单测,无需图运行时。
任一审失败 → 捕获异常、返回 `incomplete` 占位§5.2:不阻塞其余、不毁图)。
返回**增量字典**LangGraph 合并进 `state["reviews"]`),不原地改 `state`。
"""
req = build_review_request(spec, state)
try:
resp = await gateway.run(req)
except Exception as exc: # noqa: BLE001 — 该审失败隔离标未完成不上抛§5.2
log.warning(
"review_node_incomplete",
review=spec.name,
project_id=str(state["project_id"]),
chapter_no=state["chapter_no"],
error=str(exc),
)
return {"reviews": {spec.name: {"status": REVIEW_INCOMPLETE, "result": None}}}
parsed = resp.parsed
result = parsed.model_dump() if parsed is not None else None
return {"reviews": {spec.name: {"status": REVIEW_OK, "result": result}}}