diff --git a/PROGRESS.md b/PROGRESS.md
new file mode 100644
index 0000000..c3c526d
--- /dev/null
+++ b/PROGRESS.md
@@ -0,0 +1,72 @@
+# PROGRESS — 多 Agent 协同任务台账
+
+> 多 agent 共享的**唯一协同入口**。开工前读这里 + `memory/`;认领任务、尊重依赖、完成即更新。
+> 完整任务定义见 `DEV_PLAN.md`;本台账只展开**当前阶段**,进入新阶段时再把该阶段任务拉进来。
+> 协同规则见 `CLAUDE.md` →「多 Agent 协同」。
+
+**状态图例**:⬜ 待办 · 🔵 进行中(注明 @skill + 日期) · ✅ 完成 · ⛔ 阻塞(注明等什么)
+
+---
+
+## 当前阶段:Phase 2 (M2) · 一致性 + 验收
+
+> 目标:写 → 审(一致性) → 裁决 → 验收(事务写回);**未决冲突禁验收**。**契约先行**:C6 续审 AgentSpec + 结构化输出 schema → C4 扩(并行审图/SSE) → C3 扩(review/accept/reviews)。
+> 执行波次(依赖驱动):A(T2.1‖T2.3) → B(T2.2) → C(T2.4+T2.5) → D(T2.6) → E(T2.7)。
+> 计划已定的设计决策(见变更日志 + `memory/decisions.md` 待 Wave A 落地后回写):
+> **R2** digest 提炼在事务外做、原子事务只写库(不在持开事务里跨网络调 LLM);
+> **R3** accept 为确定性事务代码、读领域表(`chapters`/`chapter_reviews`)重建真相,checkpoint 仅控制流句柄(不变量 #5);
+> **R4** accept 取该章 `max(version)+1` 晋升 `status='accepted'`,草稿行保留;
+> **R5** 裁决清单每个 conflict 必须 采纳/忽略/手改 之一,缺判决 → `CONFLICT_UNRESOLVED`。
+
+| 任务 | 状态 | 负责 | 依赖 | 契约 | 备注 |
+|---|---|---|---|---|---|
+| T2.1 续审 AgentSpec + 结构化输出契约 + 网关 instructor 接线 | ✅ @llm 2026-06-18 | @llm | T1.1✅ | C6✅稳定 | `packages/agents/`+`packages/llm_gateway/`;`ContinuityReview/Conflict`(五类枚举)+`continuity_spec`(只读);`Gateway.run` 经 instructor 填 `parsed`(改 C1);25 tests 绿 |
+| T2.2 LangGraph 并行审 + collect 留痕 + review SSE | ✅ @llm 2026-06-18 | @llm | T1.3✅, T2.1✅ | C4✅扩稳定 | `orchestrator/`:`build_review_graph`(continuity 并行,可扩四审)+`collect_reviews`落 `chapter_reviews`+`normalize_review`(section/conflict);accept 接线留 T2.4;15 新测;只 flush 不 commit |
+| T2.3 摘要/审稿留痕 Repository + 章节 version | ✅ @backend 2026-06-18 | @backend | T0.2✅ | — | `core/ww_core/domain/`:`chapter_repo`(promote_to_accepted/max_version/latest_accepted)+`digest_repo`(append-only)+`review_repo`(record/list/set_decisions);纯 fake 17 新测;只 flush 不 commit(交 T2.4 事务);唯一约束 DB 级→T2.7 E2E 覆盖 |
+| T2.4 验收事务 + 冲突 gate | ✅ @backend 2026-06-18 | @backend | T2.2✅, T2.3✅ | C3✅扩 | `accept_service`(冲突 gate+R3/R4 单事务)+`digest_extraction`(R2 事务外提炼,light);`POST .../accept`;缺判→409 `CONFLICT_UNRESOLVED`+missing_indices |
+| T2.5 review API + 历史 | ✅ @backend 2026-06-18 | @backend | T2.2✅ | C3✅扩 | `POST .../review`(SSE section/conflict/done)+`GET .../reviews`(新→旧);analyst 网关;流耗尽 commit;+9 测全仓 112 passed |
+| T2.6 审稿报告页 + 冲突裁决 + 验收 gate | ✅ @frontend 2026-06-18 | @frontend | T2.4✅, T2.5✅ | — | `app/projects/[id]/review`+`components/review/*`+`lib/review/*`;gen:api 纳三端点;就地标注(R6 段级锚点)+裁决+「将更新」清单+未决禁验收;vitest 45(+21) |
+| T2.7 M2 E2E | ✅ @qa 2026-06-18 | @qa | T2.6✅ | — | `tests/test_m2_e2e.py` 2 用例真跑过(非 skip);闭环 + 零冲突直通;DB 真源断言(accepted v2 / digest 来自终稿#4 / 裁决留痕 / 三档位 ledger);无新 bug |
+
+**M2 出口(DoD)✅ 全部达成**:写→审(一致性)→裁决→验收(事务);未决冲突禁验收。后端门禁绿(ruff / mypy **90 文件** / pytest **114 passed** / alembic **无漂移**);前端门禁绿(gen:api / lint / tsc / vitest **45** / build);E2E 真实 DB + mock 网关零 token 走通 写→审→裁决→验收→摘要入库,DB 真源逐项断言。**M2 完成。** 下一阶段 M3(伏笔 + 节奏)见 DEV_PLAN,进入时拉入本表。
+
+---
+
+## 已归档阶段
+
+Phase 1 · 写章闭环骨架 (M1) — ✅ 全部完成(2026-06-18)
+
+T1.1 LLM 网关接口+DeepSeek 适配器+usage_ledger 记账(C1✅稳定)@llm · T1.2 记忆 assemble 确定性选择(C5✅稳定)@backend · T1.3 LangGraph 写章节点+Postgres checkpointer+SSE(C4✅稳定)@llm · T1.4 立项+写章 draft(SSE)+PUT 自动保存(C3✅稳定)@backend · T1.5 AppShell+作品库+5 步立项向导 @frontend · T1.6 写作工作台(三栏+流式打字机+注入透明面板+自动保存)@frontend · T1.7 提供商凭据管理(Fernet 加密+测试连接)@backend · T1.8 设置页(档位路由+凭据行)@frontend · T1.9 M1 E2E(真实 DB+mock 网关零 token;暴露并修复 ledger 提交 bug)@qa。
+出口达成:后端门禁绿(ruff / mypy 68 / pytest 57 / alembic 无漂移);前端门禁绿(gen:api / lint / tsc / vitest 24 / build);E2E 闭环走通。
+M2 起可补的 M1 占位:本章助手/注入透明面板、向导主角/世界观字段、档位路由可写。
+
+
+
+Phase 0 · 基建与栈修订 — ✅ 全部完成(2026-06-18)
+
+T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users stub)✅ @db · T0.3 FastAPI 骨架 + config + 错误信封(带 request_id) + OpenAPI + `GET /jobs/:id` + structlog ✅ @backend · T0.4 前端骨架 + 纸感 token + OpenAPI→TS gen:api ✅ @frontend · T0.5 CI(含 pg service + alembic 漂移校验)✅ @devops · T0.6 架构栈修订回写规格 ✅ @docs。
+出口达成:backend 门禁绿、frontend 门禁绿、API 实跑 200 带 `x-request-id`。
+
+
+
+---
+
+## 变更日志(append-only,最新在最上)
+
+> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
+
+- [2026-06-18] @orchestrator — **🎉 M2 完成**(Wave E:T2.7✅)。`tests/test_m2_e2e.py` 真实 pg + 多档位 mock 网关(零 token)真跑通过 2 用例:主用例串 立项→写章 SSE→自动保存→**审稿 SSE(section/conflict)**→GET 历史→**残缺裁决触发 409 `CONFLICT_UNRESOLVED`+missing_indices(gate 事务前拦截未写库)**→全量裁决+终稿验收(200, accepted v2)→**DB 真源逐项断言**:chapters draft v1 留存 + accepted v2(content==final_text)、`chapter_digests.facts` 追溯终稿标记(证 #4 digest 从终稿非草稿)、`chapter_reviews.decisions` 写回、`usage_ledger` 三档位(writer/analyst/light)各落一条且事务已 commit;补充用例验证零冲突直通。**无新 bug**(M1 曾暴露的 ledger 提交 bug 在 M2 三档位无复发——review 流末 + accept 事务末均 commit)。最终全收口门禁绿:ruff/format 干净、mypy **90 文件**、pytest **114 passed**、**alembic 无漂移**(M2 全程零建表迁移)。M2 出口 DoD 全达成。下一阶段 **M3(伏笔+节奏)**。
+- [2026-06-18] @orchestrator — **Wave D 完成**(T2.6✅)。审稿报告页落地:`app/projects/[id]/review`(RSC 取历史)+ `components/review/*`(报告卡/就地标注/AcceptPanel)+ `lib/review/*`(SSE reducer/裁决完整性/请求体组装,纯逻辑单测)。`pnpm gen:api` 纳入 review/accept/reviews 三端点类型(不手写);SSE 沿用 `useDraftStream` 的 fetch+ReadableStream 范式;五类冲突就地朱砂锚点(R6 段级降级)+裁决(采纳/忽略/手改)+**未决禁验收 gate**+409 missing_indices 高亮+「本次将更新」清单。前端门禁绿:gen:api/lint/tsc/**vitest 45(+21)**/build。memory +1 decision +2 gotchas。下一步 **Wave E:T2.7 M2 E2E**(@qa 真实 DB + mock 网关零 token)。
+- [2026-06-18] @orchestrator — **Wave C 完成**(T2.4✅ + T2.5✅,C3 扩稳定,单 @backend agent 顺序做)。三端点入 OpenAPI:`POST .../review`(SSE section/conflict/done) + `GET .../reviews`(新→旧) + `POST .../accept`。验收:冲突 gate(裁决 `conflict_index` 须覆盖 review.conflicts 全下标,缺判→409 `CONFLICT_UNRESOLVED`+missing_indices)→ 终稿 digest 提炼在事务外(R2,light 档)→ 单原子事务 promote(R4)+digest.append(#4)+set_decisions、末尾一次 commit、失败全回滚(R3)。`build_gateway_for_tier` 统一多档位网关注入。全仓门禁绿:ruff/format 干净、mypy **83 文件**、pytest **112 passed**(+9)。memory 登记 C3 扩 + 1 decision + 2 gotchas。下一步 **Wave D:T2.6**(@frontend 审稿页+裁决+验收 gate,先 `pnpm gen:api`)。
+- [2026-06-18] @orchestrator — **Wave B 完成**(T2.2✅,C4 扩稳定)。审稿子图落地:`build_review_graph`(START→continuity 并行审→collect→END,可扩四审) + `collect_reviews` 落 `chapter_reviews` 留痕 + `normalize_review`(SSE 加 `section`/`conflict`);M1 `build_write_graph` 不动。划清边界:本任务到 collect+归一缝为止,`interrupt`/accept 事务接线交 T2.4(对齐 R3:accept 从领域表重读、checkpoint 仅控制流)。全仓门禁绿:ruff/format 干净、mypy **80 文件**、pytest **103 passed**。memory 登记 C4 扩 + 3 gotchas(reducer/add_node 重载/失败隔离两层)。下一步 **Wave C:T2.4+T2.5**(单 @backend agent 顺序做:验收事务+冲突 gate / review API+历史,避免 apps/api 路由+OpenAPI 写冲突)。
+- [2026-06-18] @orchestrator — **Wave A 完成**(T2.1✅ C6 稳定 + T2.3✅)。两个 fresh agent 并行(@llm packages/agents+llm_gateway ‖ @backend core/domain,目录不相交无冲突)。**补上 M1 遗留网关缺口**:`Gateway.run()` 经 instructor 接通 `output_schema→LlmResponse.parsed`(改 C1,见 contracts 变更日志)。T2.3 写侧三 repo(章节 accepted 晋升/digest append/审稿留痕+裁决)只 flush 不 commit、交 T2.4 事务。全仓 compose 门禁绿:ruff 干净、format 干净、mypy **77 文件**、pytest **89 passed**。memory 已登记 C6+C1 变更+2 decisions+3 gotchas。下一步 **Wave B:T2.2**(@llm 并行审图+collect 留痕+review SSE,依赖 C6+C4)。
+- [2026-06-18] @orchestrator — **进入 M2**:归档 Phase 1(M1) 至已归档;Phase 2 七任务拉入台账。计划核对两处偏差并据实修正:(1) **T2.3「Repository 逻辑」落 `packages/core/ww_core/domain/`(@backend owned)而非 `packages/db`**——DB 全 MVP 表 + 唯一约束 `(project_id,chapter_no,version)` 已在 T0.2(@db) 一次建齐、M2 无新建表迁移,故 T2.3 由 @backend 承接 Repository 逻辑;(2) **网关结构化输出缺口**:`Gateway.run()` 当前不消费 `output_schema`/不填 `LlmResponse.parsed`(instructor 未接线),续审依赖此,归入 T2.1(@llm)。启动 **Wave A:T2.1(@llm·packages/agents+llm_gateway) ‖ T2.3(@backend·core/domain)**(不同目录,无写冲突)。
+- [2026-06-18] @orchestrator — **🎉 M1 完成**(Wave E:T1.9 E2E✅)。真实 DB + mock 网关 E2E 走通 立项→写章 SSE→自动保存→DB 真源断言(projects/chapters/usage_ledger)。**E2E 暴露并修复一个生产 bug**:draft SSE 端点未提交注入 ledger 的请求 session(`SqlAlchemyLedgerSink` 按设计只 flush、不 commit——写库事务归编排层),导致真实写章的 `usage_ledger` 行被回滚丢失(违背「成本账本从第一天」)。修复:draft 端点流耗尽后 `await session.commit()`;E2E 改用请求 session 做 ledger、去掉手动 commit 以回归校验该提交。最终全门禁绿:ruff / mypy 68 / pytest **57** / alembic 无漂移;前端 gen:api/lint/tsc/vitest 24/build 绿。
+- [2026-06-18] @orchestrator — **Wave D 完成**(T1.5+T1.6+T1.8✅,单前端 agent)。`pnpm gen:api` 重生成客户端含全 M1 端点;路由 `/`(作品库)、`/projects/new`(5 步向导)、`/projects/[id]/write`(工作台 SSE 打字机+停+自动保存)、`/settings/providers`(脱敏+测试连接+能力徽标)。前端门禁全绿:gen:api/lint/tsc/**test 24**/build。M1 占位(无 API 支撑、未造假端点):单章导航、本章助手/注入透明面板(M2)、向导主角/世界观字段折叠进 premise、档位路由只读。下一步 Wave E:T1.9 E2E(@qa, mock 网关)。
+- [2026-06-18] @orchestrator — **Wave C 完成**(T1.4✅ 立项+写章 API,**C3 稳定**:M1 端点全落)。记忆→网关→编排器串成 HTTP:5 端点入 OpenAPI,写章 SSE(token/done/error) + 幂等自动保存,lifespan 幂等 seed stub user(修 owner_id FK 缺口)。网关从凭据构建、测试经 dependency_overrides 注 mock。全仓 compose 绿:ruff、mypy **68 文件**、pytest **56 passed**。后端/编排全部完成。下一步 Wave D:单个前端 agent 做 T1.5+T1.6+T1.8(同一 Next 应用合并做,先 `pnpm gen:api`),避免 apps/web 写冲突。
+- [2026-06-18] @orchestrator — **Wave B 完成**(T1.3✅ 编排器/C4稳定 + T1.7✅ 凭据/C3部分)。两个 fresh agent 并行(各 ~90–98k tokens,比 fork 省)。全仓 compose 门禁绿:ruff、mypy **61 文件**、pytest **48 passed**。修了跨包测试碰撞(apps/api `fakes.py`→`fakes_providers.py`;root mypy 排除 `conftest.py` 解决多 rootless conftest 同名)。⚠️ **Wave C 前置**:DB 无 stub user,而 projects/usage_ledger/provider_credentials.owner_id 均 FK→users.id;T1.4 须先幂等 seed stub user(`uuid(int=1)`)。下一步 Wave C:T1.4(@backend 立项+写章 SSE+自动保存,依赖 C4)。
+- [2026-06-18] @orchestrator — **Wave A 完成**(T1.1✅ + T1.2✅,契约 C1/C5 稳定)。全仓 compose 门禁绿:ruff 干净、mypy 45 文件、pytest **27 passed**(9 基线 +10 网关 +8 记忆)。T1.1 网关由 orchestrator 内联实现(原 fork 因 stream idle timeout 早夭、零产出);T1.2 记忆由 fork 完成。下一步 Wave B:T1.3(@llm 写章节点+SSE,依赖 C1+C5) + T1.7(@backend 凭据,依赖 C1)。
+- [2026-06-18] @orchestrator — **进入 M1**:把 Phase 1 九任务拉入台账并归档 Phase 0;新增 workspace 成员 `packages/llm_gateway`(ww-llm-gateway) + `packages/core`(ww-core),装 openai/instructor/langgraph/langgraph-checkpoint-postgres/cryptography/tenacity,根 `pyproject.toml` members+mypy_path 已更,`uv sync` 通过,基线门禁全绿(ruff/mypy 27 文件/pytest 9 passed)。记录 decision:`assemble` 返回中性文本以解耦 T1.1‖T1.2。启动 Wave A(T1.1 @llm + T1.2 @backend)。
+- [2026-06-18] @devops/@db/@backend/@frontend — **Phase 0 全部完成**:monorepo(uv workspace + pnpm)、16 MVP 表迁移(无漂移)、FastAPI(structlog+request_id+错误信封+/jobs/:id)、Next.js(纸感 token + OpenAPI→TS gen:api)、CI。门禁全绿,API 实跑 200。下一步 M1/T1.1(网关接口)。
+- [2026-06-18] @docs — DEV_PLAN 与规格对齐修订:T0.3 加可观测性底座(structlog+request_id)、T1.1 加 `usage_ledger` 记账(从第一天)、T0.2/T2.3 统一建表口径(全 MVP 表一次建齐, T2.3 仅 Repository)、T4.2 把文风漂移并入第四审、新增 `PUT .../draft` 自动保存端点并回写 ARCH §7.2 + contracts C3。
+- [2026-06-17] @docs — 四份规格 + CLAUDE.md + 协同系统(PROGRESS/memory)就绪;可进 Phase 0 / T0.1。
diff --git a/apps/api/tests/test_review_accept.py b/apps/api/tests/test_review_accept.py
new file mode 100644
index 0000000..fc8dc5c
--- /dev/null
+++ b/apps/api/tests/test_review_accept.py
@@ -0,0 +1,286 @@
+"""T2.5 review API + 历史 / T2.4 验收事务 + 冲突 gate(内存替身,无 DB/无网络)。
+
+覆盖:review SSE 帧序列(section/conflict/done)、reviews 历史、accept happy path
+(晋升 + digest + 裁决)、冲突 gate 拦截(缺判 → CONFLICT_UNRESOLVED 不写库)、
+事务回滚(某步失败全回滚不 commit)。审稿/digest 网关均注 mock(产 parsed),绝不联网。
+"""
+
+from __future__ import annotations
+
+import uuid
+
+import httpx
+import pytest
+from fakes_projects import (
+ FakeChapterRepo,
+ FakeDigestAppendRepo,
+ FakeReviewGateway,
+ FakeReviewRepo,
+ FakeSession,
+)
+from test_projects import _empty_memory_repos
+from ww_agents import ContinuityReview
+from ww_agents.schemas import Conflict
+from ww_shared import ErrorCode
+
+
+def _make_client(
+ *,
+ chapter_repo: FakeChapterRepo | None = None,
+ review_repo: FakeReviewRepo | None = None,
+ digest_repo: FakeDigestAppendRepo | None = None,
+ review_gateway: FakeReviewGateway | None = None,
+ digest_gateway: FakeReviewGateway | None = None,
+ session: FakeSession | None = None,
+) -> tuple[
+ httpx.AsyncClient,
+ FakeChapterRepo,
+ FakeReviewRepo,
+ FakeDigestAppendRepo,
+ FakeSession,
+]:
+ import os
+
+ os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
+ from ww_api.main import create_app
+ from ww_api.services.project_deps import (
+ get_chapter_repo,
+ get_digest_append_repo,
+ get_digest_gateway,
+ get_memory_repos,
+ get_review_gateway,
+ get_review_repo,
+ )
+ from ww_db import get_session
+
+ chapter_repo = chapter_repo or FakeChapterRepo()
+ review_repo = review_repo or FakeReviewRepo()
+ digest_repo = digest_repo or FakeDigestAppendRepo()
+ review_gateway = review_gateway or FakeReviewGateway(parsed=ContinuityReview())
+ digest_gateway = digest_gateway or FakeReviewGateway(parsed=ContinuityReview())
+ session = session or FakeSession()
+
+ app = create_app()
+ app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo
+ app.dependency_overrides[get_review_repo] = lambda: review_repo
+ app.dependency_overrides[get_digest_append_repo] = lambda: digest_repo
+ app.dependency_overrides[get_memory_repos] = _empty_memory_repos
+ app.dependency_overrides[get_review_gateway] = lambda: review_gateway
+ app.dependency_overrides[get_digest_gateway] = lambda: digest_gateway
+ app.dependency_overrides[get_session] = lambda: session
+ transport = httpx.ASGITransport(app=app)
+ client = httpx.AsyncClient(transport=transport, base_url="http://test")
+ return client, chapter_repo, review_repo, digest_repo, session
+
+
+# ---- T2.5 review SSE ----
+
+
+@pytest.mark.asyncio
+async def test_review_stream_yields_section_and_done() -> None:
+ gw = FakeReviewGateway(parsed=ContinuityReview())
+ client, _, review_repo, _, session = _make_client(review_gateway=gw)
+ pid = uuid.uuid4()
+ async with client:
+ resp = await client.post(
+ f"/projects/{pid}/chapters/1/review", json={"draft": "本章草稿正文。"}
+ )
+ assert resp.status_code == 200
+ assert resp.headers["content-type"].startswith("text/event-stream")
+ text = resp.text
+ assert "event: section" in text
+ assert '"name": "continuity"' in text
+ assert '"status": "done"' in text
+ assert "event: done" in text
+ # collect 落了一行留痕;端点流耗尽后 commit。
+ assert len(review_repo.rows) == 1
+ assert session.commits == 1
+
+
+@pytest.mark.asyncio
+async def test_review_stream_emits_conflict_events() -> None:
+ review = ContinuityReview(
+ conflicts=[
+ Conflict(type="性格漂移", where="第3段", refs=["第2章"], suggestion="改回冷静"),
+ ]
+ )
+ gw = FakeReviewGateway(parsed=review)
+ client, _, review_repo, _, _ = _make_client(review_gateway=gw)
+ pid = uuid.uuid4()
+ async with client:
+ resp = await client.post(f"/projects/{pid}/chapters/1/review", json={"draft": "草稿"})
+ text = resp.text
+ assert "event: conflict" in text
+ assert '"type": "性格漂移"' in text
+ # 冲突落进 chapter_reviews 留痕。
+ assert review_repo.rows[0].conflicts[0]["type"] == "性格漂移"
+
+
+@pytest.mark.asyncio
+async def test_review_without_draft_falls_back_to_saved() -> None:
+ chapter_repo = FakeChapterRepo()
+ pid = uuid.uuid4()
+ await chapter_repo.save_draft(pid, 1, text="已存草稿")
+ client, _, _, _, _ = _make_client(chapter_repo=chapter_repo)
+ async with client:
+ resp = await client.post(f"/projects/{pid}/chapters/1/review", json={})
+ assert resp.status_code == 200
+ assert "event: done" in resp.text
+
+
+@pytest.mark.asyncio
+async def test_review_without_any_draft_404() -> None:
+ client, _, _, _, _ = _make_client()
+ pid = uuid.uuid4()
+ async with client:
+ resp = await client.post(f"/projects/{pid}/chapters/9/review", json={})
+ assert resp.status_code == 404
+ assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
+
+
+# ---- T2.5 reviews 历史 ----
+
+
+@pytest.mark.asyncio
+async def test_list_reviews_returns_history_newest_first() -> None:
+ review_repo = FakeReviewRepo()
+ pid = uuid.uuid4()
+ await review_repo.record(pid, 1, conflicts=[{"type": "设定违例"}])
+ await review_repo.record(pid, 1, conflicts=[])
+ client, _, _, _, _ = _make_client(review_repo=review_repo)
+ async with client:
+ resp = await client.get(f"/projects/{pid}/chapters/1/reviews")
+ assert resp.status_code == 200
+ rows = resp.json()["reviews"]
+ assert len(rows) == 2
+ # 新→旧:最近 record 的(空冲突)在前。
+ assert rows[0]["conflicts"] == []
+
+
+# ---- T2.4 accept happy path ----
+
+
+async def _seed_review(
+ review_repo: FakeReviewRepo, pid: uuid.UUID, conflicts: list[dict[str, object]]
+) -> None:
+ await review_repo.record(pid, 1, conflicts=conflicts)
+
+
+@pytest.mark.asyncio
+async def test_accept_promotes_appends_digest_records_decisions() -> None:
+ review_repo = FakeReviewRepo()
+ pid = uuid.uuid4()
+ await _seed_review(
+ review_repo, pid, [{"type": "性格漂移", "where": "x", "refs": [], "suggestion": "y"}]
+ )
+ from ww_api.services.digest_extraction import ChapterDigestFacts
+
+ digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="本章主线", events=["开战"]))
+ client, chapter_repo, _, digest_repo, session = _make_client(
+ review_repo=review_repo, digest_gateway=digest_gw
+ )
+ async with client:
+ resp = await client.post(
+ f"/projects/{pid}/chapters/1/accept",
+ json={
+ "final_text": "作者改稿后的终稿正文。",
+ "decisions": [{"conflict_index": 0, "verdict": "accept"}],
+ },
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["accepted_version"] == 1
+ assert body["digest_added"] is True
+ assert body["decisions_recorded"] == 1
+ # 晋升落 accepted v1,终稿正文。
+ accepted = await chapter_repo.latest_accepted(pid, 1)
+ assert accepted is not None
+ assert accepted.content == "作者改稿后的终稿正文。"
+ # digest 从终稿提炼(不变量 #4)。
+ assert digest_repo.rows[0][2]["summary"] == "本章主线"
+ # 裁决写回留痕。
+ assert review_repo.rows[0].decisions is not None
+ assert review_repo.rows[0].decisions["items"][0]["verdict"] == "accept"
+ # 单事务一次 commit。
+ assert session.commits == 1
+
+
+@pytest.mark.asyncio
+async def test_accept_with_no_review_succeeds() -> None:
+ """无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。"""
+ from ww_api.services.digest_extraction import ChapterDigestFacts
+
+ digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s"))
+ client, chapter_repo, _, digest_repo, session = _make_client(digest_gateway=digest_gw)
+ pid = uuid.uuid4()
+ async with client:
+ resp = await client.post(
+ f"/projects/{pid}/chapters/1/accept",
+ json={"final_text": "终稿", "decisions": []},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["accepted_version"] == 1
+ assert session.commits == 1
+ assert len(digest_repo.rows) == 1
+
+
+# ---- T2.4 冲突 gate 拦截 ----
+
+
+@pytest.mark.asyncio
+async def test_accept_blocks_when_conflict_unresolved() -> None:
+ review_repo = FakeReviewRepo()
+ pid = uuid.uuid4()
+ await _seed_review(
+ review_repo,
+ pid,
+ [
+ {"type": "性格漂移", "where": "a", "refs": [], "suggestion": "b"},
+ {"type": "设定违例", "where": "c", "refs": [], "suggestion": "d"},
+ ],
+ )
+ digest_gw = FakeReviewGateway(parsed=ContinuityReview())
+ client, chapter_repo, _, digest_repo, session = _make_client(
+ review_repo=review_repo, digest_gateway=digest_gw
+ )
+ async with client:
+ resp = await client.post(
+ f"/projects/{pid}/chapters/1/accept",
+ json={
+ "final_text": "终稿",
+ # 只裁决了 0 号冲突,1 号缺判 → 拦截。
+ "decisions": [{"conflict_index": 0, "verdict": "ignore"}],
+ },
+ )
+ assert resp.status_code == 409
+ assert resp.json()["error"]["code"] == ErrorCode.CONFLICT_UNRESOLVED
+ assert resp.json()["error"]["details"]["missing_conflict_indices"] == [1]
+ # 不写库:未晋升、未提炼 digest、未提交、未调 digest 网关。
+ assert await chapter_repo.latest_accepted(pid, 1) is None
+ assert len(digest_repo.rows) == 0
+ assert session.commits == 0
+ assert len(digest_gw.requests) == 0
+
+
+# ---- T2.4 事务回滚 ----
+
+
+@pytest.mark.asyncio
+async def test_accept_rolls_back_when_a_step_fails() -> None:
+ """裁决留痕步骤抛错 → 整体回滚(不 commit)。"""
+ review_repo = FakeReviewRepo(fail_set_decisions=True)
+ pid = uuid.uuid4()
+ await _seed_review(review_repo, pid, [])
+ from ww_api.services.digest_extraction import ChapterDigestFacts
+
+ digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s"))
+ client, _, _, _, session = _make_client(review_repo=review_repo, digest_gateway=digest_gw)
+ # set_decisions 在事务中途抛错;ASGITransport 默认上抛未处理异常。
+ with pytest.raises(RuntimeError, match="rollback test"):
+ async with client:
+ await client.post(
+ f"/projects/{pid}/chapters/1/accept",
+ json={"final_text": "终稿", "decisions": []},
+ )
+ # 关键:事务未 commit(半态不落库,整体回滚)。
+ assert session.commits == 0
diff --git a/apps/api/ww_api/services/accept_service.py b/apps/api/ww_api/services/accept_service.py
new file mode 100644
index 0000000..5129b5b
--- /dev/null
+++ b/apps/api/ww_api/services/accept_service.py
@@ -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,
+ )
diff --git a/apps/api/ww_api/services/digest_extraction.py b/apps/api/ww_api/services/digest_extraction.py
new file mode 100644
index 0000000..97753bb
--- /dev/null
+++ b/apps/api/ww_api/services/digest_extraction.py
@@ -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
diff --git a/apps/web/app/projects/[id]/review/page.tsx b/apps/web/app/projects/[id]/review/page.tsx
new file mode 100644
index 0000000..ff19a8e
--- /dev/null
+++ b/apps/web/app/projects/[id]/review/page.tsx
@@ -0,0 +1,40 @@
+import { notFound } from "next/navigation";
+
+import { ReviewReport } from "@/components/review/ReviewReport";
+import { fetchProject, fetchReviews } from "@/lib/api/server";
+import { latestReview } from "@/lib/review/history";
+
+interface PageProps {
+ params: Promise<{ id: string }>;
+ searchParams: Promise<{ chapter?: string }>;
+}
+
+const DEFAULT_CHAPTER_NO = 1;
+
+// 审稿报告页(UX §6.4)。Server Component 取项目 + 审稿留痕历史(新→旧);
+// ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。
+export default async function ReviewPage({ params, searchParams }: PageProps) {
+ const { id } = await params;
+ const { chapter } = await searchParams;
+ const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO;
+ try {
+ const project = await fetchProject(id);
+ const history = await fetchReviews(id, chapterNo);
+ return (
+
+ );
+ } catch {
+ notFound();
+ }
+}
+
+function parsePositiveInt(raw: string | undefined): number | null {
+ if (!raw) return null;
+ const n = Number.parseInt(raw, 10);
+ return Number.isInteger(n) && n > 0 ? n : null;
+}
diff --git a/apps/web/components/review/AcceptPanel.tsx b/apps/web/components/review/AcceptPanel.tsx
new file mode 100644
index 0000000..410540f
--- /dev/null
+++ b/apps/web/components/review/AcceptPanel.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import type { AcceptResponse } from "@/lib/api/types";
+
+interface AcceptPanelProps {
+ conflictCount: number;
+ unresolvedCount: number;
+ accepting: boolean;
+ result: AcceptResponse | null;
+ onAccept: () => void;
+}
+
+// 验收 gate(UX §9 / §8.4):未决禁验收(置灰 + 文案),验收后呈现「本次将更新」清单。
+export function AcceptPanel({
+ conflictCount,
+ unresolvedCount,
+ accepting,
+ result,
+ onAccept,
+}: AcceptPanelProps) {
+ const blocked = unresolvedCount > 0;
+
+ if (result) {
+ return (
+
+
本章已验收
+
+ -
+ 晋升版次:
+ v{result.accepted_version}
+
+ -
+ 章节摘要:{result.digest_added ? "已新增一行" : "未变更"}
+
+ -
+ 裁决写回:
+
+ {result.decisions_recorded}
+ {" "}
+ 条
+
+ {result.review_id ? (
+ -
+ 留痕:{result.review_id}
+
+ ) : null}
+
+
+ );
+ }
+
+ return (
+
+ {blocked ? (
+
+ 尚有 {unresolvedCount} 项冲突未裁决,处理完才能验收。
+
+ ) : (
+
+ {conflictCount === 0
+ ? "无冲突,可直接验收。"
+ : "全部冲突已裁决,可验收本章。"}
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/components/review/AnnotatedText.tsx b/apps/web/components/review/AnnotatedText.tsx
new file mode 100644
index 0000000..ec4c3b8
--- /dev/null
+++ b/apps/web/components/review/AnnotatedText.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import type { ReviewConflict } from "@/lib/review/sse";
+
+interface AnnotatedTextProps {
+ text: string;
+ conflicts: ReviewConflict[];
+ // 当前聚焦的冲突下标(来自报告卡「跳转」),用于锚点联动。
+ focusedIndex: number | null;
+ onAnchorClick: (index: number) => void;
+}
+
+// 正文就地标注(UX §8.3 / §6.1)。
+// M2 占位(R6):`where` 是文字定位(如「第4段」),M2 不做精确字符 range —
+// 改为段级/锚点联动:每个冲突渲染为一枚朱砂波浪线锚点挂在正文上方,点击=回跳报告卡。
+// 精确 inline range 标注留 M3+(需后端给字符 offset)。
+export function AnnotatedText({
+ text,
+ conflicts,
+ focusedIndex,
+ onAnchorClick,
+}: AnnotatedTextProps) {
+ return (
+
+ {conflicts.length > 0 ? (
+
+ {conflicts.map((c, i) => (
+
+ ))}
+
+ ) : null}
+
+ {text || (
+ (无待审正文,先去写作页起草本章)
+ )}
+
+
+ );
+}
diff --git a/apps/web/components/review/ConflictCard.tsx b/apps/web/components/review/ConflictCard.tsx
new file mode 100644
index 0000000..0b28b27
--- /dev/null
+++ b/apps/web/components/review/ConflictCard.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import type { ReviewConflict } from "@/lib/review/sse";
+import type { DecisionDraft, Verdict } from "@/lib/review/decisions";
+
+interface ConflictCardProps {
+ index: number;
+ conflict: ReviewConflict;
+ draft: DecisionDraft;
+ missing: boolean;
+ onVerdict: (index: number, verdict: Verdict) => void;
+ onNote: (index: number, note: string) => void;
+ onJump: (index: number) => void;
+}
+
+const VERDICT_OPTIONS: { value: Verdict; label: string }[] = [
+ { value: "accept", label: "采纳改法" },
+ { value: "ignore", label: "忽略" },
+ { value: "manual", label: "手改" },
+];
+
+// 单个冲突报告卡(UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。
+// 冲突=赭红;未决/缺判时加图标+文案(不单靠色,a11y §10)。
+export function ConflictCard({
+ index,
+ conflict,
+ draft,
+ missing,
+ onVerdict,
+ onNote,
+ onJump,
+}: ConflictCardProps) {
+ const resolved = draft.verdict !== null;
+ return (
+
+
+
+ ⚠ {conflict.type}
+
+
{conflict.suggestion}
+
+
+
+ {conflict.where ? (
+
+ ) : null}
+ {conflict.refs.map((ref) => (
+
+ ▸ {ref}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/web/components/review/ReviewReport.tsx b/apps/web/components/review/ReviewReport.tsx
new file mode 100644
index 0000000..5481bdf
--- /dev/null
+++ b/apps/web/components/review/ReviewReport.tsx
@@ -0,0 +1,291 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+
+import { AppShell } from "@/components/AppShell";
+import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types";
+import {
+ allResolved,
+ emptyDecisions,
+ setNote,
+ setVerdict,
+ unresolvedIndices,
+ type DecisionDraft,
+ type Verdict,
+} from "@/lib/review/decisions";
+import { normalizeConflicts } from "@/lib/review/history";
+import { useAccept } from "@/lib/review/useAccept";
+import { useReviewStream } from "@/lib/review/useReviewStream";
+import type { ReviewConflict } from "@/lib/review/sse";
+import { AcceptPanel } from "./AcceptPanel";
+import { AnnotatedText } from "./AnnotatedText";
+import { ConflictCard } from "./ConflictCard";
+
+interface ReviewReportProps {
+ project: ProjectResponse;
+ chapterNo: number;
+ initialReview: ReviewHistoryItem | undefined;
+ initialDraft: string;
+}
+
+// 审稿报告页主体(UX §6.4 / §8.3 / §9)。
+// 数据源:进页用历史留痕 conflicts 种入;「重新审稿」用当前终稿跑 SSE 覆盖。
+// 裁决草稿随 conflicts 长度对齐;未决禁验收(gate)。
+export function ReviewReport({
+ project,
+ chapterNo,
+ initialReview,
+ initialDraft,
+}: ReviewReportProps) {
+ const review = useReviewStream();
+ const accept = useAccept();
+
+ const [finalText, setFinalText] = useState(initialDraft);
+ const [drafts, setDrafts] = useState(() =>
+ emptyDecisions(normalizeConflicts(initialReview).length),
+ );
+ const [focusedIndex, setFocusedIndex] = useState(null);
+ const [missing, setMissing] = useState>(new Set());
+ const seededRef = useRef(false);
+
+ // 进页一次性把历史 conflicts 种入流状态(无需重审即可裁决)。
+ const seededConflicts = useMemo(
+ () => normalizeConflicts(initialReview),
+ [initialReview],
+ );
+ useEffect(() => {
+ if (seededRef.current) return;
+ seededRef.current = true;
+ if (seededConflicts.length > 0) review.seed(seededConflicts);
+ }, [review, seededConflicts]);
+
+ // 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
+ const conflicts: ReviewConflict[] = review.state.conflicts;
+
+ // 重审完成(done 边沿)→ 重置裁决草稿对齐新冲突集(冲突可能整组变化)。
+ const conflictCount = conflicts.length;
+ const wasReviewingRef = useRef(false);
+ useEffect(() => {
+ if (review.state.phase === "reviewing") {
+ wasReviewingRef.current = true;
+ return;
+ }
+ if (review.state.phase === "done" && wasReviewingRef.current) {
+ wasReviewingRef.current = false;
+ setDrafts(emptyDecisions(conflictCount));
+ setMissing(new Set());
+ }
+ }, [review.state.phase, conflictCount]);
+
+ const onReReview = (): void => {
+ setMissing(new Set());
+ void review.start(project.id, chapterNo, finalText);
+ };
+
+ const onVerdict = (index: number, verdict: Verdict): void => {
+ setDrafts((prev) => setVerdict(prev, index, verdict));
+ setMissing((prev) => {
+ if (!prev.has(index)) return prev;
+ const next = new Set(prev);
+ next.delete(index);
+ return next;
+ });
+ };
+ const onNote = (index: number, note: string): void =>
+ setDrafts((prev) => setNote(prev, index, note));
+
+ const jumpToCard = (index: number): void => {
+ setFocusedIndex(index);
+ document
+ .getElementById(`conflict-card-${index}`)
+ ?.scrollIntoView({ behavior: "smooth", block: "center" });
+ };
+ const jumpToAnchor = (index: number): void => {
+ setFocusedIndex(index);
+ document
+ .getElementById(`anchor-${index}`)
+ ?.scrollIntoView({ behavior: "smooth", block: "center" });
+ };
+
+ const onAccept = async (): Promise => {
+ const outcome = await accept.accept(
+ project.id,
+ chapterNo,
+ finalText,
+ drafts,
+ );
+ if (outcome.missingIndices.length > 0) {
+ setMissing(new Set(outcome.missingIndices));
+ }
+ };
+
+ const resolved = allResolved(drafts);
+ const unresolved = unresolvedIndices(drafts).length;
+ const reviewing = review.isReviewing;
+
+ return (
+
+
+ {/* 左:终稿正文 + 就地标注 */}
+
+
+
+ 第 {chapterNo} 章 审稿报告
+
+
+ {conflictCount} 冲突
+
+
+ {reviewing ? (
+
+ ) : (
+
+ )}
+
+
+
+ {review.state.error ? (
+
+ 审稿失败({review.state.error.code}):
+ {review.state.error.message}
+ {review.state.error.code === "LLM_UNAVAILABLE" ? (
+ <>
+ {" "}
+
+ 去设置提供商
+
+ >
+ ) : null}
+
+ ) : null}
+
+
+
+
+
+ 展开/编辑终稿(裁决时可改稿 — 摘要从终稿提炼)
+
+
+
+
+
+
+ {/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
+
+
+
+ );
+}
+
+interface SectionStatusLineProps {
+ reviewing: boolean;
+ done: boolean;
+ conflictCount: number;
+ sections: { name: string; status: string }[];
+}
+
+// 四审进行中骨架逐项点亮(UX §9);M2 仅 continuity 一项。
+function SectionStatusLine({
+ reviewing,
+ done,
+ conflictCount,
+ sections,
+}: SectionStatusLineProps) {
+ if (reviewing) {
+ return (
+
+ 审稿进行中…
+
+ );
+ }
+ if (done || sections.length > 0) {
+ return (
+
+ {conflictCount > 0 ? (
+ ⚠ {conflictCount} 冲突
+ ) : (
+ ✓ 通过
+ )}
+
+ );
+ }
+ return null;
+}
diff --git a/apps/web/lib/review/decisions.test.ts b/apps/web/lib/review/decisions.test.ts
new file mode 100644
index 0000000..fca9981
--- /dev/null
+++ b/apps/web/lib/review/decisions.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ allResolved,
+ buildAcceptRequest,
+ emptyDecisions,
+ missingConflictIndices,
+ setNote,
+ setVerdict,
+ unresolvedIndices,
+} from "./decisions";
+
+describe("emptyDecisions", () => {
+ it("creates N unresolved drafts", () => {
+ expect(emptyDecisions(2)).toEqual([
+ { verdict: null, note: "" },
+ { verdict: null, note: "" },
+ ]);
+ });
+});
+
+describe("setVerdict / setNote (immutable)", () => {
+ it("sets verdict at index without mutating others", () => {
+ const drafts = emptyDecisions(2);
+ const next = setVerdict(drafts, 1, "ignore");
+ expect(next[1]?.verdict).toBe("ignore");
+ expect(next[0]?.verdict).toBeNull();
+ expect(drafts[1]?.verdict).toBeNull(); // 原数组不变
+ });
+
+ it("sets note at index", () => {
+ const drafts = emptyDecisions(1);
+ const next = setNote(drafts, 0, "手改说明");
+ expect(next[0]?.note).toBe("手改说明");
+ expect(drafts[0]?.note).toBe("");
+ });
+});
+
+describe("allResolved / unresolvedIndices", () => {
+ it("zero conflicts pass through (allResolved true)", () => {
+ expect(allResolved([])).toBe(true);
+ expect(unresolvedIndices([])).toEqual([]);
+ });
+
+ it("requires every conflict to have a verdict", () => {
+ let drafts = emptyDecisions(3);
+ expect(allResolved(drafts)).toBe(false);
+ expect(unresolvedIndices(drafts)).toEqual([0, 1, 2]);
+ drafts = setVerdict(drafts, 0, "accept");
+ drafts = setVerdict(drafts, 2, "manual");
+ expect(allResolved(drafts)).toBe(false);
+ expect(unresolvedIndices(drafts)).toEqual([1]);
+ drafts = setVerdict(drafts, 1, "ignore");
+ expect(allResolved(drafts)).toBe(true);
+ expect(unresolvedIndices(drafts)).toEqual([]);
+ });
+});
+
+describe("buildAcceptRequest", () => {
+ it("includes only resolved decisions and trims notes", () => {
+ let drafts = emptyDecisions(3);
+ drafts = setVerdict(drafts, 0, "accept");
+ drafts = setVerdict(drafts, 2, "manual");
+ drafts = setNote(drafts, 2, " 改成后天淬炼 ");
+ // index 1 仍未决 → 不应进入请求
+ const req = buildAcceptRequest("终稿正文", drafts);
+ expect(req.final_text).toBe("终稿正文");
+ expect(req.decisions).toEqual([
+ { conflict_index: 0, verdict: "accept" },
+ { conflict_index: 2, verdict: "manual", note: "改成后天淬炼" },
+ ]);
+ });
+
+ it("omits empty/whitespace-only notes", () => {
+ let drafts = emptyDecisions(1);
+ drafts = setVerdict(drafts, 0, "ignore");
+ drafts = setNote(drafts, 0, " ");
+ const req = buildAcceptRequest("t", drafts);
+ expect(req.decisions).toEqual([{ conflict_index: 0, verdict: "ignore" }]);
+ });
+});
+
+describe("missingConflictIndices", () => {
+ it("extracts a set from 409 details", () => {
+ const set = missingConflictIndices({
+ missing_conflict_indices: [1, 3],
+ conflict_count: 4,
+ });
+ expect(set.has(1)).toBe(true);
+ expect(set.has(3)).toBe(true);
+ expect(set.has(0)).toBe(false);
+ });
+
+ it("returns empty set for malformed/missing details", () => {
+ expect(missingConflictIndices(null).size).toBe(0);
+ expect(missingConflictIndices({}).size).toBe(0);
+ expect(missingConflictIndices({ missing_conflict_indices: "x" }).size).toBe(
+ 0,
+ );
+ });
+});
diff --git a/apps/web/lib/review/decisions.ts b/apps/web/lib/review/decisions.ts
new file mode 100644
index 0000000..cf01a8c
--- /dev/null
+++ b/apps/web/lib/review/decisions.ts
@@ -0,0 +1,74 @@
+// 裁决纯逻辑:完整性判定(未决禁验收)、accept 请求体组装、缺判高亮映射。
+// 对齐 C3 accept 契约 + R5 冲突 gate(裁决 conflict_index 须覆盖 range(len(conflicts)))。
+
+import type { AcceptRequest, ConflictDecision } from "@/lib/api/types";
+
+export type Verdict = "accept" | "ignore" | "manual";
+
+// 单个冲突的本地裁决草稿(按下标定位)。
+export interface DecisionDraft {
+ verdict: Verdict | null;
+ note: string;
+}
+
+// 初始化 N 个冲突的裁决草稿(全部未决)。
+export function emptyDecisions(conflictCount: number): DecisionDraft[] {
+ return Array.from({ length: conflictCount }, () => ({
+ verdict: null,
+ note: "",
+ }));
+}
+
+// 不可变更新:设置某个冲突的 verdict。
+export function setVerdict(
+ drafts: readonly DecisionDraft[],
+ index: number,
+ verdict: Verdict,
+): DecisionDraft[] {
+ return drafts.map((d, i) => (i === index ? { ...d, verdict } : d));
+}
+
+// 不可变更新:设置某个冲突的 note。
+export function setNote(
+ drafts: readonly DecisionDraft[],
+ index: number,
+ note: string,
+): DecisionDraft[] {
+ return drafts.map((d, i) => (i === index ? { ...d, note } : d));
+}
+
+// 是否全部已决:每个冲突都有 verdict(覆盖 range(len(conflicts)))。零冲突 → 直通。
+export function allResolved(drafts: readonly DecisionDraft[]): boolean {
+ return drafts.every((d) => d.verdict !== null);
+}
+
+// 本地仍未决的冲突下标(用于禁用态提示)。
+export function unresolvedIndices(drafts: readonly DecisionDraft[]): number[] {
+ return drafts.flatMap((d, i) => (d.verdict === null ? [i] : []));
+}
+
+// 组装 accept 请求体(仅含已决项;note 去空白后非空才带)。
+export function buildAcceptRequest(
+ finalText: string,
+ drafts: readonly DecisionDraft[],
+): AcceptRequest {
+ const decisions: ConflictDecision[] = drafts.flatMap((d, i) => {
+ if (d.verdict === null) return [];
+ const trimmed = d.note.trim();
+ const decision: ConflictDecision = {
+ conflict_index: i,
+ verdict: d.verdict,
+ };
+ return [trimmed ? { ...decision, note: trimmed } : decision];
+ });
+ return { final_text: finalText, decisions };
+}
+
+// 从 409 CONFLICT_UNRESOLVED 的 details 提取缺判下标集合(用于报告卡高亮)。
+export function missingConflictIndices(details: unknown): Set {
+ if (typeof details !== "object" || details === null) return new Set();
+ const raw = (details as { missing_conflict_indices?: unknown })
+ .missing_conflict_indices;
+ if (!Array.isArray(raw)) return new Set();
+ return new Set(raw.filter((n): n is number => typeof n === "number"));
+}
diff --git a/apps/web/lib/review/history.test.ts b/apps/web/lib/review/history.test.ts
new file mode 100644
index 0000000..dd0feec
--- /dev/null
+++ b/apps/web/lib/review/history.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+
+import type { ReviewHistoryItem } from "@/lib/api/types";
+import { latestReview, normalizeConflicts } from "./history";
+
+const item = (
+ conflicts: Record[],
+): ReviewHistoryItem => ({
+ id: "00000000-0000-0000-0000-000000000001",
+ project_id: "00000000-0000-0000-0000-000000000002",
+ chapter_no: 128,
+ conflicts,
+});
+
+describe("normalizeConflicts", () => {
+ it("tightens loose dicts into ReviewConflict, preserving order", () => {
+ const out = normalizeConflicts(
+ item([
+ {
+ type: "设定违例",
+ where: "第4段",
+ refs: ["第30章"],
+ suggestion: "统一血脉",
+ },
+ { type: "时间线倒错", where: "第8段", refs: [], suggestion: "调整" },
+ ]),
+ );
+ expect(out).toEqual([
+ { type: "设定违例", where: "第4段", refs: ["第30章"], suggestion: "统一血脉" },
+ { type: "时间线倒错", where: "第8段", refs: [], suggestion: "调整" },
+ ]);
+ });
+
+ it("fills safe defaults for missing/wrong-typed fields", () => {
+ const out = normalizeConflicts(item([{ where: 42, refs: "x" }]));
+ expect(out).toEqual([
+ { type: "未分类", where: "", refs: [], suggestion: "" },
+ ]);
+ });
+
+ it("returns [] for undefined item or missing conflicts", () => {
+ expect(normalizeConflicts(undefined)).toEqual([]);
+ });
+});
+
+describe("latestReview", () => {
+ it("returns first item (history is newest-first)", () => {
+ const a = item([]);
+ const b = item([]);
+ expect(latestReview([a, b])).toBe(a);
+ expect(latestReview([])).toBeUndefined();
+ expect(latestReview(undefined)).toBeUndefined();
+ });
+});
diff --git a/apps/web/lib/review/history.ts b/apps/web/lib/review/history.ts
new file mode 100644
index 0000000..a50c5f9
--- /dev/null
+++ b/apps/web/lib/review/history.ts
@@ -0,0 +1,34 @@
+// 审稿历史归一:把后端 ReviewHistoryItem 的松散 conflicts(dict[])收紧成 ReviewConflict[]。
+// 纯逻辑(可单测);schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。
+
+import type { ReviewHistoryItem } from "@/lib/api/types";
+import type { ReviewConflict } from "./sse";
+
+function asString(v: unknown, fallback = ""): string {
+ return typeof v === "string" ? v : fallback;
+}
+
+function asStringArray(v: unknown): string[] {
+ if (!Array.isArray(v)) return [];
+ return v.filter((x): x is string => typeof x === "string");
+}
+
+// 把一条留痕的 conflicts 收紧(缺字段给安全默认;保持顺序=下标身份,对齐冲突 gate)。
+export function normalizeConflicts(
+ item: ReviewHistoryItem | undefined,
+): ReviewConflict[] {
+ const raw = item?.conflicts ?? [];
+ return raw.map((c) => ({
+ type: asString(c["type"], "未分类"),
+ where: asString(c["where"]),
+ refs: asStringArray(c["refs"]),
+ suggestion: asString(c["suggestion"]),
+ }));
+}
+
+// 最近一条留痕(GET .../reviews 已按新→旧排序)。
+export function latestReview(
+ items: readonly ReviewHistoryItem[] | undefined,
+): ReviewHistoryItem | undefined {
+ return items?.[0];
+}
diff --git a/apps/web/lib/review/sse.test.ts b/apps/web/lib/review/sse.test.ts
new file mode 100644
index 0000000..7ff0390
--- /dev/null
+++ b/apps/web/lib/review/sse.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ ReviewFrameBuffer,
+ initialReviewState,
+ parseReviewBlock,
+ reduceReview,
+ type ReviewSseEvent,
+} from "./sse";
+
+describe("parseReviewBlock", () => {
+ it("parses a section frame", () => {
+ const evt = parseReviewBlock(
+ 'event:section\ndata:{"name":"continuity","status":"done"}',
+ );
+ expect(evt).toEqual({
+ event: "section",
+ data: { name: "continuity", status: "done" },
+ });
+ });
+
+ it("parses a conflict frame", () => {
+ const evt = parseReviewBlock(
+ 'event:conflict\ndata:{"type":"设定违例","where":"第4段","refs":["第30章"],"suggestion":"统一血脉设定"}',
+ );
+ expect(evt).toEqual({
+ event: "conflict",
+ data: {
+ type: "设定违例",
+ where: "第4段",
+ refs: ["第30章"],
+ suggestion: "统一血脉设定",
+ },
+ });
+ });
+
+ it("parses done and error frames", () => {
+ expect(parseReviewBlock('event:done\ndata:{"length":1}')).toEqual({
+ event: "done",
+ data: { length: 1 },
+ });
+ expect(
+ parseReviewBlock(
+ 'event:error\ndata:{"code":"INTERNAL","message":"boom","request_id":"r1"}',
+ ),
+ ).toEqual({
+ event: "error",
+ data: { code: "INTERNAL", message: "boom", request_id: "r1" },
+ });
+ });
+
+ it("returns null for unknown event or malformed json", () => {
+ expect(parseReviewBlock('event:token\ndata:{"text":"x"}')).toBeNull();
+ expect(parseReviewBlock("event:conflict\ndata:{not json")).toBeNull();
+ });
+});
+
+describe("ReviewFrameBuffer", () => {
+ it("emits complete blocks and buffers the tail", () => {
+ const buf = new ReviewFrameBuffer();
+ const first = buf.push(
+ 'event:section\ndata:{"name":"continuity","status":"started"}\n\nevent:conf',
+ );
+ expect(first).toEqual([
+ { event: "section", data: { name: "continuity", status: "started" } },
+ ]);
+ const second = buf.push(
+ 'lict\ndata:{"type":"性格漂移","where":"第2段","refs":[],"suggestion":"x"}\n\n',
+ );
+ expect(second).toEqual([
+ {
+ event: "conflict",
+ data: { type: "性格漂移", where: "第2段", refs: [], suggestion: "x" },
+ },
+ ]);
+ });
+});
+
+describe("reduceReview", () => {
+ it("upserts sections by name (last status wins) and marks reviewing", () => {
+ const events: ReviewSseEvent[] = [
+ { event: "section", data: { name: "continuity", status: "started" } },
+ { event: "section", data: { name: "continuity", status: "done" } },
+ ];
+ const state = events.reduce(reduceReview, initialReviewState);
+ expect(state.sections).toEqual([
+ { name: "continuity", status: "done" },
+ ]);
+ expect(state.phase).toBe("reviewing");
+ });
+
+ it("accumulates conflicts in order", () => {
+ const events: ReviewSseEvent[] = [
+ {
+ event: "conflict",
+ data: { type: "设定违例", where: "a", refs: [], suggestion: "s1" },
+ },
+ {
+ event: "conflict",
+ data: { type: "时间线倒错", where: "b", refs: ["第3章"], suggestion: "s2" },
+ },
+ ];
+ const state = events.reduce(reduceReview, initialReviewState);
+ expect(state.conflicts.map((c) => c.type)).toEqual([
+ "设定违例",
+ "时间线倒错",
+ ]);
+ });
+
+ it("marks done and captures error preserving collected data", () => {
+ let s = reduceReview(initialReviewState, {
+ event: "section",
+ data: { name: "continuity", status: "done" },
+ });
+ s = reduceReview(s, { event: "done", data: { length: 1 } });
+ expect(s.phase).toBe("done");
+
+ let e = reduceReview(initialReviewState, {
+ event: "conflict",
+ data: { type: "能力不符", where: "x", refs: [], suggestion: "y" },
+ });
+ e = reduceReview(e, {
+ event: "error",
+ data: { code: "LLM_UNAVAILABLE", message: "no key" },
+ });
+ expect(e.phase).toBe("error");
+ expect(e.conflicts).toHaveLength(1);
+ expect(e.error?.code).toBe("LLM_UNAVAILABLE");
+ });
+});
diff --git a/apps/web/lib/review/sse.ts b/apps/web/lib/review/sse.ts
new file mode 100644
index 0000000..ce903eb
--- /dev/null
+++ b/apps/web/lib/review/sse.ts
@@ -0,0 +1,155 @@
+// 审稿 SSE 帧归一 + reducer(对齐 C3 / C4 扩 / ARCH §7.3)。
+// 帧:`section{name,status}` / `conflict{type,where,refs,suggestion}` / `done{length}` / `error{...}`。
+// 纯逻辑,便于 node 环境单测(不依赖 DOM)。
+
+// 五类冲突(C6 ConflictType / ARCH §6.1)。
+export type ConflictType =
+ | "性格漂移"
+ | "能力不符"
+ | "设定违例"
+ | "地理矛盾"
+ | "时间线倒错";
+
+export interface ReviewConflict {
+ type: string;
+ where: string;
+ refs: string[];
+ suggestion: string;
+}
+
+export type SectionStatus = "started" | "done" | "incomplete";
+
+export interface SectionEvent {
+ event: "section";
+ data: { name: string; status: SectionStatus };
+}
+export interface ConflictEvent {
+ event: "conflict";
+ data: ReviewConflict;
+}
+export interface DoneEvent {
+ event: "done";
+ data: { length: number };
+}
+export interface ErrorEvent {
+ event: "error";
+ data: { code: string; message: string; request_id?: string | null };
+}
+export type ReviewSseEvent =
+ | SectionEvent
+ | ConflictEvent
+ | DoneEvent
+ | ErrorEvent;
+
+const KNOWN_EVENTS = new Set(["section", "conflict", "done", "error"]);
+
+// 把一个完整 SSE 块(多行)解析成事件;无法解析则返回 null(跳过)。
+export function parseReviewBlock(block: string): ReviewSseEvent | null {
+ let event = "";
+ const dataLines: string[] = [];
+ for (const rawLine of block.split("\n")) {
+ const line = rawLine.replace(/\r$/, "");
+ if (line.startsWith(":")) continue; // 注释/心跳
+ const sep = line.indexOf(":");
+ if (sep === -1) continue;
+ const field = line.slice(0, sep);
+ const value = line.slice(sep + 1).replace(/^ /, "");
+ if (field === "event") event = value;
+ else if (field === "data") dataLines.push(value);
+ }
+ if (!KNOWN_EVENTS.has(event) || dataLines.length === 0) return null;
+ let data: unknown;
+ try {
+ data = JSON.parse(dataLines.join("\n"));
+ } catch {
+ return null;
+ }
+ return { event, data } as ReviewSseEvent;
+}
+
+// 增量缓冲:吃进一段文本,吐出已完成的事件块(空行分隔),保留未完成尾部。
+export class ReviewFrameBuffer {
+ private buf = "";
+
+ push(chunk: string): ReviewSseEvent[] {
+ this.buf += chunk;
+ const events: ReviewSseEvent[] = [];
+ let idx: number;
+ while ((idx = this.findBoundary(this.buf)) !== -1) {
+ const block = this.buf.slice(0, idx);
+ this.buf = this.buf.slice(this.boundaryEnd(this.buf, idx));
+ const evt = parseReviewBlock(block);
+ if (evt) events.push(evt);
+ }
+ return events;
+ }
+
+ private findBoundary(s: string): number {
+ const a = s.indexOf("\n\n");
+ const b = s.indexOf("\r\n\r\n");
+ if (a === -1) return b;
+ if (b === -1) return a;
+ return Math.min(a, b);
+ }
+
+ private boundaryEnd(s: string, idx: number): number {
+ return s.startsWith("\r\n\r\n", idx) ? idx + 4 : idx + 2;
+ }
+}
+
+export type ReviewPhase = "idle" | "reviewing" | "done" | "error" | "aborted";
+
+export interface ReviewSection {
+ name: string;
+ status: SectionStatus;
+}
+
+export interface ReviewStreamState {
+ phase: ReviewPhase;
+ sections: ReviewSection[];
+ conflicts: ReviewConflict[];
+ error: { code: string; message: string; request_id?: string | null } | null;
+}
+
+export const initialReviewState: ReviewStreamState = {
+ phase: "idle",
+ sections: [],
+ conflicts: [],
+ error: null,
+};
+
+// 纯 reducer:把单个事件折叠进状态。section 按 name upsert(最后状态生效)。
+export function reduceReview(
+ state: ReviewStreamState,
+ event: ReviewSseEvent,
+): ReviewStreamState {
+ switch (event.event) {
+ case "section":
+ return {
+ ...state,
+ phase: "reviewing",
+ sections: upsertSection(state.sections, event.data),
+ };
+ case "conflict":
+ return {
+ ...state,
+ phase: "reviewing",
+ conflicts: [...state.conflicts, event.data],
+ };
+ case "done":
+ return { ...state, phase: "done" };
+ case "error":
+ return { ...state, phase: "error", error: event.data };
+ default:
+ return state;
+ }
+}
+
+function upsertSection(
+ sections: ReviewSection[],
+ next: ReviewSection,
+): ReviewSection[] {
+ const idx = sections.findIndex((s) => s.name === next.name);
+ if (idx === -1) return [...sections, next];
+ return sections.map((s, i) => (i === idx ? next : s));
+}
diff --git a/apps/web/lib/review/useAccept.ts b/apps/web/lib/review/useAccept.ts
new file mode 100644
index 0000000..24cdb68
--- /dev/null
+++ b/apps/web/lib/review/useAccept.ts
@@ -0,0 +1,76 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+import { api } from "@/lib/api/client";
+import { useToast } from "@/components/Toast";
+import type { AcceptResponse } from "@/lib/api/types";
+import { buildAcceptRequest, type DecisionDraft } from "./decisions";
+
+export type AcceptStatus = "idle" | "accepting" | "accepted" | "error";
+
+export interface AcceptOutcome {
+ result: AcceptResponse | null;
+ // 409 CONFLICT_UNRESOLVED 缺判下标(高亮报告卡)。
+ missingIndices: number[];
+}
+
+export interface UseAccept {
+ status: AcceptStatus;
+ result: AcceptResponse | null;
+ accept: (
+ projectId: string,
+ chapterNo: number,
+ finalText: string,
+ drafts: readonly DecisionDraft[],
+ ) => Promise;
+}
+
+interface ApiErrorEnvelope {
+ error?: {
+ code?: string;
+ message?: string;
+ details?: { missing_conflict_indices?: number[] } | null;
+ };
+}
+
+// 验收:乐观置 accepting → 成功置 accepted(呈现「本次将更新」清单);
+// 失败回滚状态 + toast;409 CONFLICT_UNRESOLVED 解析缺判下标供高亮。
+export function useAccept(): UseAccept {
+ const [status, setStatus] = useState("idle");
+ const [result, setResult] = useState(null);
+ const toast = useToast();
+
+ const accept = useCallback(
+ async (projectId, chapterNo, finalText, drafts) => {
+ setStatus("accepting");
+ const body = buildAcceptRequest(finalText, drafts);
+ const { data, error } = await api.POST(
+ "/projects/{project_id}/chapters/{chapter_no}/accept",
+ {
+ params: { path: { project_id: projectId, chapter_no: chapterNo } },
+ body,
+ },
+ );
+ if (error || !data) {
+ setStatus("error");
+ const env = error as ApiErrorEnvelope | undefined;
+ const code = env?.error?.code;
+ const missing = env?.error?.details?.missing_conflict_indices ?? [];
+ if (code === "CONFLICT_UNRESOLVED") {
+ toast("尚有冲突未裁决,请先处理高亮项", "error");
+ return { result: null, missingIndices: missing };
+ }
+ toast("验收失败,请重试(正文未丢失)", "error");
+ return { result: null, missingIndices: [] };
+ }
+ setResult(data);
+ setStatus("accepted");
+ toast("本章已验收", "success");
+ return { result: data, missingIndices: [] };
+ },
+ [toast],
+ );
+
+ return { status, result, accept };
+}
diff --git a/apps/web/lib/review/useReviewStream.ts b/apps/web/lib/review/useReviewStream.ts
new file mode 100644
index 0000000..e9d8be9
--- /dev/null
+++ b/apps/web/lib/review/useReviewStream.ts
@@ -0,0 +1,135 @@
+"use client";
+
+import { useCallback, useReducer, useRef } from "react";
+
+import { API_BASE_PUBLIC } from "@/lib/api/config";
+import {
+ ReviewFrameBuffer,
+ initialReviewState,
+ reduceReview,
+ type ReviewConflict,
+ type ReviewStreamState,
+} from "./sse";
+
+type Action =
+ | { type: "start" }
+ | { type: "events"; events: ReturnType }
+ | { type: "abort" }
+ | { type: "fail"; code: string; message: string }
+ | { type: "seed"; conflicts: ReviewConflict[] };
+
+function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
+ switch (action.type) {
+ case "start":
+ return { ...initialReviewState, phase: "reviewing" };
+ case "events":
+ return action.events.reduce(reduceReview, state);
+ case "abort":
+ return { ...state, phase: "aborted" };
+ case "fail":
+ return {
+ ...state,
+ phase: "error",
+ error: { code: action.code, message: action.message },
+ };
+ case "seed":
+ return { ...initialReviewState, conflicts: action.conflicts };
+ default:
+ return state;
+ }
+}
+
+export interface UseReviewStream {
+ state: ReviewStreamState;
+ isReviewing: boolean;
+ // 用当前编辑器草稿重新审稿。
+ start: (projectId: string, chapterNo: number, draft: string) => Promise;
+ stop: () => void;
+ // 进页用历史留痕的冲突种入(无需重审即可裁决)。
+ seed: (conflicts: ReviewConflict[]) => void;
+}
+
+// 消费 POST .../review 的 SSE 流:fetch+ReadableStream(EventSource 不支持 POST)。
+// "停" = abort(已收 section/conflict 留在 state,裁决草稿不丢)。
+// 流前 503 LLM_UNAVAILABLE 是 JSON 信封(非帧)→ 经 !res.ok 检出。
+export function useReviewStream(): UseReviewStream {
+ const [state, dispatch] = useReducer(reducer, initialReviewState);
+ const controllerRef = useRef(null);
+
+ const stop = useCallback(() => {
+ controllerRef.current?.abort();
+ controllerRef.current = null;
+ dispatch({ type: "abort" });
+ }, []);
+
+ const seed = useCallback((conflicts: ReviewConflict[]) => {
+ dispatch({ type: "seed", conflicts });
+ }, []);
+
+ const start = useCallback(
+ async (
+ projectId: string,
+ chapterNo: number,
+ draft: string,
+ ): Promise => {
+ const controller = new AbortController();
+ controllerRef.current = controller;
+ dispatch({ type: "start" });
+ try {
+ const res = await fetch(
+ `${API_BASE_PUBLIC}/projects/${projectId}/chapters/${chapterNo}/review`,
+ {
+ method: "POST",
+ headers: {
+ Accept: "text/event-stream",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ draft }),
+ signal: controller.signal,
+ },
+ );
+ if (!res.ok || !res.body) {
+ let code = "REVIEW_FAILED";
+ let message = `审稿请求失败(${res.status})`;
+ try {
+ const body = (await res.json()) as {
+ error?: { code?: string; message?: string };
+ };
+ if (body.error?.code) code = body.error.code;
+ if (body.error?.message) message = body.error.message;
+ } catch {
+ // 非 JSON 信封,沿用默认文案。
+ }
+ dispatch({ type: "fail", code, message });
+ return;
+ }
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ const buffer = new ReviewFrameBuffer();
+ for (;;) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ const events = buffer.push(decoder.decode(value, { stream: true }));
+ if (events.length > 0) dispatch({ type: "events", events });
+ }
+ } catch (err: unknown) {
+ if (err instanceof DOMException && err.name === "AbortError") {
+ return; // 用户主动停止:state 已置 aborted。
+ }
+ const message = err instanceof Error ? err.message : "未知网络错误";
+ dispatch({ type: "fail", code: "NETWORK", message });
+ } finally {
+ controllerRef.current = null;
+ }
+ },
+ [],
+ );
+
+ return {
+ state,
+ isReviewing: state.phase === "reviewing",
+ start,
+ stop,
+ seed,
+ };
+}
diff --git a/memory/contracts.md b/memory/contracts.md
new file mode 100644
index 0000000..d52cbda
--- /dev/null
+++ b/memory/contracts.md
@@ -0,0 +1,98 @@
+# 契约登记(解耦缝)
+
+> 跨 agent 的契约——多 agent 靠它解耦并行。**契约先行**:定义方先在此登记并标 `稳定`,依赖方才动工;改契约必须在此记一笔 + 在 `PROGRESS.md` 通知依赖任务(前端要重生成 TS 客户端、依赖模块要重同步)。
+> 状态:`待定义` → `草拟(@skill)` → `稳定` → `已变更(见日志)`。
+
+---
+
+## C1 · LLM 网关接口 owner @llm 状态: 稳定(T1.1, 2026-06-18)
+- 来源:`ARCHITECTURE.md §4.1`(`LlmRequest` / `LlmResponse` / `Block` / `Usage`,**snake_case**)。
+- 消费方:编排器、所有 Agent(经 `Gateway.run` / `Gateway.stream`)。
+- 关键不变量:agent 只传 `tier`(writer/analyst/light),不传具体 model。
+- **已实现(`packages/llm_gateway/ww_llm_gateway`)**:
+ - 类型 `types.py`:`Block(text,cache=False)`、`Scope(user_id,project_id?)`、`LlmRequest(tier,input:str|list[Block],system:list[Block],stream,output_schema?,thinking,max_tokens?,scope)`、`Usage(provider,model,input_tokens,output_tokens,cache_read_tokens,cost_minor,currency)`、`ServedBy(provider,model,fell_back)`、`LlmResponse(text,parsed?,usage,served_by)`、`Delta(text)`、`Tier`。
+ - `Gateway(adapters:dict[str,ProviderAdapter], ledger:LedgerSink, resolver=resolve_route)`:`async run(req)->LlmResponse`、`stream(req)->AsyncIterator[Delta]`。每次调用落 **1 条** `usage_ledger`(经 `LedgerSink`,可注入内存替身)。
+ - 适配器 `ProviderAdapter`(Protocol):`provider`、`capabilities()->Capabilities`、`async complete(req,model)->ProviderResult`、`stream(req,model)->AsyncIterator[StreamChunk]`。M1 实现 `OpenAICompatAdapter(provider, client:AsyncOpenAI)`(DeepSeek,注入 client 便于测试)。
+ - 档位路由 `resolve_route(tier)->Route(provider,model)` 读 `config.tier_defaults`(M1 仅全局默认;回退/熔断属 M5/T5.4,**未实现**)。
+ - 记账 `SqlAlchemyLedgerSink(session)` 写 `UsageLedger`(owner_id=scope.user_id 单用户 stub;列名 `cache_read`)。成本表 `pricing.py`(未知 provider/model→0)。
+
+## C2 · DB schema(SQLAlchemy 模型) owner @db 状态: 待定义
+- 来源:`ARCHITECTURE.md §3.1` DDL(11 创作表 + chapter_reviews + 运营表;无向量列;users stub)。
+- 消费方:`@backend`(Repository/记忆服务/验收)、`@llm`(Agent reads/writes)。
+
+## C3 · API / OpenAPI 端点 owner @backend 状态: 稳定(M1 端点全落:T1.7 settings + T1.4 projects/chapters, 2026-06-18)
+- 来源:`ARCHITECTURE.md §7.2` 端点清单(章节端点统一 `/projects/:id/chapters/:no/...`;含 `PUT .../draft`(自动保存)、`/refine`、`/jobs/:id`、`/reviews`)。
+- 消费方:`@frontend`(经 OpenAPI→TS 客户端)。**改端点/字段 → 前端必须 `pnpm gen:api` 重生成客户端。**
+- **已落(T1.7, 2026-06-18,snake_case,响应仅脱敏 key)**:
+ - `GET /settings/providers` → `ProvidersResponse{ providers:[ProviderView{provider, masked_key}], tier_routing:[TierRoutingView{tier, provider, model, fallback:list[str]}] }`
+ - `PUT /settings/providers` ← `ProvidersUpsertRequest{ credentials:[ProviderCredentialInput{provider, api_key}], tier_routing:[TierRoutingInput{tier, provider, model, fallback}] }` → `ProvidersResponse`(脱敏)
+ - `POST /settings/providers/test` ← `TestConnectionRequest{provider}` → `TestConnectionResponse{provider, ok, capabilities:CapabilitiesView{structured_output, prefix_cache, thinking}}`
+ - **明文 key 永不过边界**;probe 经可注入 `ProviderProbe`(测试用 fake,不联网)。
+- **已落(T1.4, 2026-06-18,snake_case)**:
+ - `POST /projects` ← `ProjectCreateRequest{title, genre?, logline?, premise?, theme?, selling_points:list, structure?}` → 201 `ProjectResponse{id, title, genre?, logline?, premise?, theme?, selling_points, structure?}`
+ - `GET /projects` → `ProjectListResponse{projects:[ProjectResponse]}`;`GET /projects/{id}` → `ProjectResponse`(404 `NOT_FOUND` 信封)
+ - `POST /projects/{id}/chapters/{no}/draft` → **SSE** `text/event-stream`,帧 `event:\ndata:\n\n`(`token{text}`/`done{length}`/`error{code,message,request_id}`);无凭据 → 流前 `LLM_UNAVAILABLE`(503) JSON 信封(非帧)。
+ - `PUT /projects/{id}/chapters/{no}/draft` ← `DraftSaveRequest{text}` → 200 `DraftResponse{project_id, chapter_no, volume, status, version, length}`(幂等:version 固定 1、status='draft')。
+ - 网关注入缝 `get_writer_gateway`(从凭据构 `OpenAICompatAdapter`+`SqlAlchemyLedgerSink`);测试经 `app.dependency_overrides` 注 mock 网关(只需 `.stream(req)`)。
+- **@frontend 行动**:M1 端点已全在 OpenAPI → Wave D 前先 `cd apps/web && pnpm gen:api` 重生成 `lib/api/schema.d.ts`。
+- **已落(T2.4+T2.5, 2026-06-18,snake_case,全部挂 `projects.router`、已在 OpenAPI)— M2 审/裁/验收三端点**:
+ - `POST /projects/{id}/chapters/{no}/review` ← `ReviewRequest{draft?}`(空/缺→回退已存草稿;无草稿→404 `NOT_FOUND`)→ **SSE** `text/event-stream`,帧:`section{name,status:"done"|"incomplete"}`(每审一条,M2 仅 `continuity`) / `conflict{type,where,refs:list,suggestion}`(每冲突一条,形=C6 `Conflict` 五类) / `done{length=审项数}` / `error{code,message,request_id}`。无凭据→流前 `LLM_UNAVAILABLE`(503 JSON 信封)。续审网关 tier=analyst(`get_review_gateway`)。**端点流耗尽后 `session.commit()`**(网关 ledger + collect 均只 flush)。
+ - `GET /projects/{id}/chapters/{no}/reviews` → `ReviewHistoryResponse{reviews:[ReviewHistoryItem{id,project_id,chapter_no,chapter_version?,conflicts:list,foreshadow_sug:list,style?,pace?,health_score?,decisions?}]}`(新→旧)。
+ - `POST /projects/{id}/chapters/{no}/accept` ← `AcceptRequest{final_text(min1), decisions:[ConflictDecision{conflict_index:int>=0, verdict:"accept"|"ignore"|"manual", note?}]}` → `AcceptResponse{project_id,chapter_no,accepted_version:int,digest_added:bool,decisions_recorded:int,review_id?:uuid}`。**冲突 gate**:裁决的 `conflict_index` 集合须覆盖 `range(len(最近一条 review.conflicts))`,缺判→409 `CONFLICT_UNRESOLVED` + `details.missing_conflict_indices`/`conflict_count`;无留痕或零冲突→直通。digest 提炼 tier=light(`get_digest_gateway`)在事务外(R2),单事务 promote(R4)+digest.append(#4)+set_decisions 末尾一次 commit(R3)。
+ - 注入缝:`build_gateway_for_tier(session, store, tier)`(原 `build_writer_gateway` 退化为 writer 特例) + `get_review_gateway`/`get_digest_gateway`/`get_review_repo`/`get_digest_append_repo`;测试经 `app.dependency_overrides` 注 mock。
+ - **@frontend 行动**:`cd apps/web && pnpm gen:api` 重生成客户端含此三端点(T2.6)。
+
+## C4 · 编排器接口(LangGraph 写章图) owner @llm 状态: 稳定(M1/T1.3, 2026-06-18;M2 扩四审/验收)
+- 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3(SSE)。**M1 仅单 `write` 节点**;并行四审/collect/interrupt(accept) 属 M2。
+- 位置:`packages/core/ww_core/orchestrator/`。
+- 图状态 `ChapterState`(TypedDict, snake_case):`{project_id:UUID, chapter_no:int, user_id:UUID, stable_core:str, volatile:str, draft:str}`——仅控制流+组装上下文+累积草稿(不变量#5);resume 从领域表重读。
+- 节点缝:
+ - `build_write_request(*, stable_core, volatile, user_id, project_id) -> LlmRequest`(纯函数;`tier="writer"`,`system=[Block(stable_core,cache=True)]`,`input=volatile`,`stream=True`,`scope=Scope(user_id,project_id)`)。
+ - `async stream_chapter_draft(gateway, *, stable_core, volatile, user_id, project_id) -> AsyncIterator[Delta]`——**T1.4 拿来喂 `normalize_deltas` 的底层流缝**。
+ - `async write_node(state, *, gateway) -> {"draft":str}`——可直接单测(注入 mock 网关)。`GatewayStream` Protocol = 节点对网关最小依赖(只需 `.stream(req)`)。
+- **SSE 归一缝(T1.4 消费)**:`async normalize_deltas(deltas, *, request_id=None) -> AsyncIterator[SseEvent]`;`SseEvent{event:str, data:dict}`;事件 `token{text}`/`done{length}`/`error{code,message,request_id}`(ARCH §7.3 子集,M2 加 section/conflict)。底层异常→发 `error` 事件后收尾(不上抛);`AppError.code` 透传,未知→`INTERNAL`。HTTP event-stream 编码归 T1.4。
+- 图工厂:`build_write_graph(gateway, *, checkpointer=None) -> CompiledStateGraph`(START→write→END);单测传 `MemorySaver`。
+- checkpointer setup 入口:`async setup_checkpointer(conn_string) -> None`——**只在 migrations/CI 调**(内部 `AsyncPostgresSaver.from_conn_string` 跑一次 `setup()` DDL;懒 import,绝不在 app-runtime 跑)。
+- 不变量:agent 只传 tier;DB 唯一 agent 间通道;不可变更新;瞬时重试在网关不在节点。
+
+### C4 扩展(T2.2, 2026-06-18)· 审稿子图 + review SSE
+- 图工厂 `build_review_graph(gateway, review_repo, *, review_specs=(continuity_spec,), checkpointer=None) -> CompiledStateGraph`:`START →`各 review spec 并行节点`→ collect → END`。M2 默认仅 continuity,`review_specs` 可扩(M3/M4 加 foreshadow/style/pace)。**`build_write_graph` 保留不动**(draft 端点仍用它)。
+- `ChapterState`(state.py) 新增 `review_context:str`、`reviews:Annotated[dict, merge_reviews]`(并行分支浅合并 reducer,返回新 dict);TypedDict 改 `total=False`(按节点逐步填充);仍仅控制流+组装上下文+产物句柄(不变量#5)。
+- 节点缝:`run_review(spec, state, *, gateway)->{"reviews":{spec.name:{status,result}}}`(裸函数可单测);`make_review_node(spec, gateway)`(绑 gateway 的公共缝,供 T2.5 跑单审);审稿用 `gateway.run()`(非 stream),**只读不写库**(不变量#3);任一审网关失败被隔离为 `{status:"incomplete",result:None}`(§5.2),不上抛、不毁图。`GatewayRun` Protocol=节点对网关最小依赖(`.run(req)->LlmResponse`)。
+- collect 缝:`collect_reviews(state, *, review_repo)->{}`:抽 continuity 冲突 → `review_repo.record(project_id, chapter_no, chapter_version=None, conflicts=[...])` 落 `chapter_reviews` 留痕;**只 flush 不 commit**(提交归端点/T2.4 事务)。`ReviewRecorder` Protocol 形对齐 `domain.review_repo.ReviewRepo.record`。
+- SSE 新增事件:`section{name,status}`(status ∈ started/done/incomplete)、`conflict{type,where,refs,suggestion}`(对齐 C6 `Conflict`);新归一缝 `normalize_review(reviews, *, request_id=None)->AsyncIterator[SseEvent]`(每审一条 section + 每冲突一条 conflict + `done{length=审项数}`;异常→`error` 不上抛,同 `normalize_deltas` 纪律)。HTTP event-stream 编码归 T2.5。
+- **accept 不在本图**:`interrupt_before=["accept"]`/accept 节点属 T2.4(确定性事务代码,从领域表重读,R3/不变量#5)。
+- 消费方行动:T2.5 跑 review 子图 + `normalize_review` + 端点**流耗尽后 `await session.commit()`**(网关 ledger + collect 均只 flush,不提交则记账/留痕静默丢失,同 M1 draft 坑);`GET .../reviews` 用 `review_repo.list_for_chapter`(新→旧)。T2.4 从 `review_repo.list_for_chapter` 读 collect 落的行(`decisions=None`)裁决。
+
+## C5 · 记忆服务 `assemble` / `select_relevant_entities` owner @backend 状态: 稳定(T1.2, 2026-06-18)
+- 来源:`ARCHITECTURE.md §3.4 / §5.3`(确定性选择:显式+主角+近况;渲染卡片;缓存断点)。
+- 位置:`packages/core/ww_core/memory/`(+ `domain/repositories.py`)。
+- 输出(决策:中性文本,非 `LlmRequest`,见 decisions 2026-06-18):
+ - `AssembledContext{ stable_core:str, volatile:str, selection:SelectionTrace }`
+ - `stable_core`:断点前(世界硬规则+定型主角(无 latest_state)+文风指纹+合并规则),已排序/无时间戳/无 UUID。
+ - `volatile`:断点后(注入卡片(含 latest_state)+伏笔窗口+近况摘要+本章 beats)。
+ - `SelectionTrace{ selected:list[SelectedEntity] }`;`SelectedEntity{ kind:"character"|"world_entity", name:str, reasons:list[SelectionReason] }`;`SelectionReason="explicit_beat"|"main_character"|"recent_digest"|"foreshadow_window"`。
+- 入口:`async assemble(repos:MemoryRepos, project_id, chapter_no, recent_k=5)->AssembledContext`;纯函数 `select_relevant_entities(*, outline, characters, world_entities, recent_digests)->SelectionTrace`;`render_cards(selection, characters, world_entities)->str`;`merge_rules(rules)->list[RuleView]`(global→genre→style→project)。
+- 依赖注入:`MemoryRepos` 捆绑 7 个 Protocol(Outline/Character/WorldEntity/Digest/Foreshadow/Style/Rules);单测注入内存 fake,运行时 `sql_memory_repos(AsyncSession)`(**T1.4 注入点**)。
+- 不变量:确定性选择(无向量 #6);统一 project_id 过滤(§3.5);latest_state 严格归 volatile(#9)。
+- 消费方:**T1.3 write 节点** → 构造 `LlmRequest(system=[Block(stable_core, cache=True)], input=volatile)`。
+
+## C6 · Agent 声明(AgentSpec)+ 续审 I/O schema owner @llm 状态: 稳定(T2.1, 2026-06-18)
+- 来源:`ARCHITECTURE.md §5.1 / §5.4 / §6.1`。位置:`packages/agents/ww_agents/`(`specs.py` / `schemas.py`,经 `ww_agents` 导出)。
+- **`AgentSpec`**(frozen Pydantic,不可变):`name:str`、`tier:Tier`(writer/analyst/light,复用 `ww_llm_gateway.types.Tier`——只声明档位不写 model,不变量#2)、`system_prompt:str`、`input_schema:type[BaseModel]|None`、`output_schema:type[BaseModel]|None`(writer 为 None=纯文本)、`reads:list[str]`、`writes:list[str]`、`genre:str|None=None`、`scope:str="builtin"`。
+- **`continuity_spec`**:`tier="analyst"`、`reads=["chapter_digests","characters","world_entities"]`、`writes=[]`(只读,不变量#3)、`input_schema=None`(注入材料为序列化文本)、`output_schema=ContinuityReview`。
+- **`ContinuityReview{ conflicts: list[Conflict] }`**(仅冲突;digest 不在审稿期产,不变量#4)。
+- **`Conflict{ type: ConflictType, where:str, refs:list[str]=[], suggestion:str }`**;`ConflictType = Literal["性格漂移","能力不符","设定违例","地理矛盾","时间线倒错"]`(ARCH §6.1 五类)。
+- 续审节点调法:`req = LlmRequest(tier="analyst", system=[Block(system_prompt, cache=True)], input=审稿上下文文本, output_schema=ContinuityReview, scope=...)` → `resp = await gateway.run(req)` → `resp.parsed` 为 `ContinuityReview` 实例(带 schema 时必非 None)。续审用 `run()` 非 `stream()`;只读+产冲突,不写库(写在 T2.4 验收事务)。
+- 消费方:编排器(T2.2 续审/collect 节点)、技能运行时(M5)、前端审稿页(经 C3)。foreshadow/style/pace 三审 spec 待 M3/M4 补入本契约。
+
+## C7 · 前端 ↔ 后端类型契约 owner @backend(产出 OpenAPI) / @frontend(生成) 状态: 待定义
+- 机制:FastAPI OpenAPI → `apps/web/lib/api` TS 类型(openapi-typescript/orval)。
+- 规则:后端 schema 任何变更 → 跑 `gen:api` 重生成;不手写共享类型。
+
+---
+
+## 契约变更日志(append-only)
+> 格式:`- [date] @skill 改 Cx:<改了什么> → 影响 <依赖方/任务>`
+
+- [2026-06-18] @llm 改 C1:`Gateway.run()` 现消费 `LlmRequest.output_schema`——`OpenAICompatAdapter.complete` 在 schema 非空时经 **instructor**(`create_with_completion(response_model=...)`) 取已校验 Pydantic 实例并填 `LlmResponse.parsed`;无 schema 时 `parsed is None`、纯文本路径不变;记账仍 **1 条 usage_ledger**/调用(usage 从 raw completion 提取)。`ProviderResult` 新增 `parsed` 字段。结构化路径经可注入 `StructuredClient` Protocol 注 fake(测试不联网)。→ 影响 T2.2(续审节点可直接 `gateway.run(req).parsed`)、未来所有结构化输出 Agent。
diff --git a/memory/decisions.md b/memory/decisions.md
new file mode 100644
index 0000000..ca7b604
--- /dev/null
+++ b/memory/decisions.md
@@ -0,0 +1,49 @@
+# 实现决策记录(append-only)
+
+> 实现期做出、而规格未覆盖的决策 + 理由。一条一决策,最新在最上。
+> 与规格冲突的不要写这里——去改规格(见 CLAUDE.md「Conventions」)。重大架构选择见 `ARCHITECTURE.md §1.2 ADR`。
+
+格式:
+```
+## [YYYY-MM-DD] <决策标题> — @skill
+- 背景:为什么要决策
+- 选择:选了什么
+- 理由 / 取舍:
+- 影响:动到哪些契约/模块/任务
+```
+
+---
+
+## [2026-06-18] 网关结构化输出经可注入 `StructuredClient` 缝(instructor)— @llm
+- 背景:`OpenAICompatAdapter` 需接 instructor 产结构化输出(C1 类型预留 `output_schema`/`parsed` 但 M1 未接线),但测试绝不可联网/碰真实 LLM。
+- 选择:定义 `StructuredClient` Protocol(`create_with_completion(*, messages, response_model, **kw) -> (parsed, raw)`,即 `instructor.AsyncInstructor` 的形);adapter 构造可选注入 `structured_client`,未注入时懒构建 `instructor.from_openai(self._client)`。`run()` 透传 `result.parsed → LlmResponse.parsed`。
+- 理由 / 取舍:保留既有「注入 client 便于测试」风格,结构化路径同样可注 fake。`ProviderResult.text` 对结构化路径置为 `parsed.model_dump_json()`(便于日志/留痕),程序消费走 `parsed`。
+- 影响:契约 C1(`run()` 填 `parsed`;见 contracts 变更日志)、`ProviderResult` 加 `parsed` 字段;T2.2 续审节点据此消费。
+
+## [2026-06-18] 审稿页终稿来源:页内可编辑 textarea(无 GET draft 端点)— @frontend (T2.6)
+- 背景:审稿/验收都需「终稿」文本,但当前无 `GET .../draft` 端点拉取已存草稿。
+- 选择:审稿页用可折叠 textarea 编辑终稿(`initialDraft=''`),重审传它为 `{draft}`(空→后端回退已存草稿),验收 `final_text`=它。
+- 理由 / 取舍:对齐不变量#4(摘要从作者裁决/改稿后的终稿提炼);作者可在审稿页改稿。代价:刷新页不自动回填草稿正文(M2 可接受,后续可加 GET draft)。
+- 影响:仅前端;T2.7 E2E 用 `#final-text` 注入终稿。
+
+## [2026-06-18] 验收事务边界:digest 提炼在事务外、单事务三写一次提交 — @backend (T2.4)
+- 背景:§5.5 要求验收「单事务」含「从终稿提炼 digest(LLM 调用)」,但不能在持开 DB 事务里跨网络调 LLM(占锁)。
+- 选择:digest 提炼 `extract_digest_facts`(tier=light,`ChapterDigestFacts` schema,经网关 `run().parsed`) 在 `run_accept_transaction` **之前**调用(R2),结果作 `digest_facts:dict` 传入;事务内只三次纯 DB 写(`promote_to_accepted`→`digest.append`→`set_decisions`)+ 末尾一次 `session.commit()`,任一步失败整体回滚。
+- 理由 / 取舍:兼顾「digest 从终稿(不变量#4)」与「事务不跨网络」;提炼那次网关调用的 usage 随事务提交落 1 条 usage_ledger。digest 用 light、续审用 analyst 档位,统一经新 `build_gateway_for_tier(session, store, tier)`(`build_writer_gateway` 退化为 writer 特例)。§5.5 步骤 3(人物 latest_state/伏笔更新)留 `TODO(M3)` 占位,不引入 M3 表逻辑。
+- 影响:契约 C3(accept/review/reviews 三端点);T2.6 消费 `AcceptResponse`/`CONFLICT_UNRESOLVED`;T2.7 E2E 验证事务原子性+digest 来源+ledger。
+
+## [2026-06-18] 验收-side 写侧 Repository 命名与提交边界 — @backend (T2.3)
+- 背景:`chapter_digests` 已有读侧 repo(`memory/` 的 `DigestRepo.recent`,供 assemble);M2 需写侧 append。同名易混。
+- 选择:写侧命名 `DigestAppendRepo`/`SqlDigestAppendRepo`,落 `domain/digest_repo.py`,**共用 `DigestView`**;读侧仍在 `memory/`。新增 `review_repo.py`(`record`/`list_for_chapter` 新→旧/`set_decisions`) 与 `chapter_repo` 扩 `max_version`/`promote_to_accepted`/`latest_accepted`。
+- 理由 / 取舍:读写分离避免同名歧义;写侧三 repo 的写方法**只 `flush()` 不 `commit()`**,提交归 T2.4 验收事务单次完成(对齐「写库副作用在编排/事务层」不变量);`save_draft` 仍自 commit(M1 自动保存语义)。
+- 影响:T2.4 在单事务里组合 `promote_to_accepted`→`digest.append`→`review.set_decisions` 后 `commit()`;R2/R3/R4/R5 决策落在这些接口点。
+- 背景:ARCH §5.3 伪码里 `assemble` 直接返回 `LlmRequest`,会让 `packages/core/memory` 反向依赖 `packages/llm_gateway` 的类型,且使 M1 的 T1.2 强依赖 T1.1(无法并行)。
+- 选择:`assemble(project_id, chapter_no) -> AssembledContext(stable_core: str, volatile: str, selection: SelectionTrace)`。稳定内核/易变各为**已确定性排序、无时间戳/UUID 的字符串**;write 节点(T1.3)再据此构造 `LlmRequest(system=[Block(text=stable_core, cache=True)], input=volatile)`。`SelectionTrace` 记录每个实体的入选理由,供 UX 注入透明面板(T1.6)。
+- 理由 / 取舍:记忆服务只经 DB 通信、不应知道网关请求类型(对齐不变量①②);解耦后 T1.1‖T1.2 可并行。§5.3 伪码视为示意,真正的缝是「确定性选择 → 序列化的 stable/volatile 文本」。
+- 影响:契约 C5 输出形 = `AssembledContext`(非 `LlmRequest`);C1 的 `Block`/`LlmRequest` 仅 gateway+orchestrator 使用。回写 ARCH §5.3 待 M1 落地后由 @docs 注一句。
+
+## [2026-06-18] CLAUDE.md 不复制 spec 的枚举值,只重述跨文档规则 — @all
+- 背景:CLAUDE.md 原先内联了 SSE 事件名、LLM 调用日志字段、错误码/envelope 形状等"会变的具体值",ARCHITECTURE 一改即静默过期,使 CLAUDE.md 自身成为最大漂移源。
+- 选择:CLAUDE.md 只保留跨文档、易错的**规则**(含 9 条架构不变量);所有枚举型/具体值改为指向 ARCHITECTURE/PRODUCT_SPEC 对应 §(唯一真源)。新增"冲突裁决"段:ARCHITECTURE > UX_SPEC/DEV_PLAN > PRODUCT_SPEC,矛盾时回写上游。
+- 理由 / 取舍:消除文档间漂移面、降低每 session 上下文成本;代价是查具体值需多跳一层到 ARCHITECTURE(可接受)。
+- 影响:编辑 CLAUDE.md 的任何 agent 遵循此纪律——契约/枚举值落在 spec 与 `memory/contracts.md`,不在 CLAUDE.md。
diff --git a/memory/gotchas.md b/memory/gotchas.md
new file mode 100644
index 0000000..93d01d2
--- /dev/null
+++ b/memory/gotchas.md
@@ -0,0 +1,38 @@
+# 踩坑与约定(append-only)
+
+> 实现中发现的坑、易错点、约定俗成——让兄弟 agent 不重复踩。一条一项,最新在最上。
+> 只记**非显而易见**的;规格/CLAUDE.md 已写的别重复。
+
+格式:`- [date] @skill <坑/约定> — 缘由 + 怎么做`
+
+---
+
+- [2026-06-18] @qa **M2 E2E 多档位假适配器**:`config.tier_defaults` writer/analyst/light 默认同 provider(deepseek)→单个假适配器(`provider="deepseek"`)即覆盖三档位;据 `req.output_schema is ContinuityReview`(续审)/否则 digest facts schema 分支返回 `parsed`;三档位用不同 `input_tokens` 区分以断言各自落 `usage_ledger`。三端点记账闭环 = review 端点流末 commit + accept 验收事务末 commit 都把网关 ledger flush 真正提交(M1 ledger bug 在 M2 无复发)。
+- [2026-06-18] @qa **E2E 验证「digest 从终稿非草稿」(#4) 手法**:final_text 注入草稿没有的标记串,假 light 适配器把它放进 digest facts 的 `summary`,断言 `chapter_digests.facts["summary"]==标记` 且 `标记 not in draft_text`。accept 409 gate 经 ASGITransport 正常返回(`AppError` 不上抛),断言 `resp.json()["error"]["details"]["missing_conflict_indices"]`(`ErrorCode` StrEnum → `"CONFLICT_UNRESOLVED"`)。
+- [2026-06-18] @frontend **审稿历史 `conflicts` 在 OpenAPI 被标松散 `{[k]:unknown}[]`**(后端用 dict/JSONB 列)→ 前端 `lib/review/history.ts` 安全收窄成 `ReviewConflict{type,where,refs,suggestion}`,缺字段给默认、**保序**(顺序=冲突 gate 的 `conflict_index` 身份,不可重排,否则裁决错位)。
+- [2026-06-18] @frontend **审稿页重审完成重置裁决草稿用 streaming→done 边沿判定**(`wasReviewingRef`):不能用 conflicts 长度变化判(同数不同组会漏重置),也不能在 seed(phase=idle,进页种历史留痕)时误触发。
+- [2026-06-18] @backend **冲突 gate 判据(accept)**:冲突身份 = 「最近一条 `chapter_reviews.conflicts` 列表的下标」;裁决 `ConflictDecision{conflict_index, verdict:accept|ignore|manual, note?}`;gate 通过 = 裁决的 `conflict_index` 集合**覆盖** `range(len(conflicts))`,缺判→409 `CONFLICT_UNRESOLVED` + `details.missing_conflict_indices`/`conflict_count`;无审稿留痕或零冲突→直通验收。
+- [2026-06-18] @backend/@qa **ASGITransport 默认 `raise_app_exceptions=True`**:accept 事务回滚测试里,repo 抛的非-`AppError`(如 `RuntimeError`)会**上抛到 client 调用方**而非返回 500——测试用 `pytest.raises(RuntimeError)` 包住请求调用、再断言 `session.commits == 0`(证明未部分提交)。DB 级原子回滚由 T2.7 真 pg 覆盖。
+- [2026-06-18] @llm **langgraph 并行节点同写一个 state key 必须配 reducer**:并行四审同 superstep 各写 `{spec.name: ...}` 到 `reviews`,无 reducer → LangGraph 抛 `InvalidUpdateError`。用 `Annotated[dict, merge_reviews]`(浅合并、返回新 dict、不可变)。`ChapterState` 因逐节点填充改 `total=False`。
+- [2026-06-18] @llm **langgraph `add_node` 重载拒收显式 `Callable` 类型别名**:`make_review_node` 返回的具名 `BoundReviewNode` 别名会让 mypy 报 incompatible arg-type。图工厂里改用 **inline `async def` 闭包**(spec 经默认参 `_spec=spec` 绑定,避开循环晚绑定),mypy 才推出精确函数类型匹配重载。`make_review_node` 仍作公共缝(供 T2.5 跑单审)单独保留+单测。
+- [2026-06-18] @llm **审稿失败隔离两层**:审级失败在 `run_review` 内标 `incomplete`(§5.2 任一审不阻塞其余);归一级意外(畸形 entry)→ `normalize_review` 发一条 `error` 事件后收尾(同 `normalize_deltas` 纪律)。
+- [2026-06-18] @llm **instructor 1.15.3 结构化输出接线**:用 `AsyncInstructor.create_with_completion(messages=..., response_model=..., model=..., max_tokens=...)` 同时拿 `(parsed, raw_completion)`——`raw.usage` 用于记账,避免结构化路径丢 usage;usage 提取统一走 `_usage_from(raw_usage)`(文本/结构化/流共用)。adapter 经可注入 `StructuredClient` Protocol 注 fake(测试不联网)。结构化路径 `ProviderResult.text = parsed.model_dump_json()`(日志/留痕),消费走 `parsed`。**带 `output_schema` 时 `gateway.run(req).parsed` 必非 None**。
+- [2026-06-18] @backend **frozen View 的运行时不可变断言因类型分两种**:dataclass frozen(`ChapterView`)→赋值抛 `FrozenInstanceError` 且 **mypy 会静态报错**(测试里故意赋值需 `# type: ignore[misc]`);Pydantic frozen(`DigestView`/`ReviewView`)→抛 `ValidationError` 但 **mypy 不静态校验**(**勿加** `# type: ignore`,否则被判 unused-ignore,ruff/mypy 红)。
+- [2026-06-18] @backend **M2 验收-side repos 只 flush 不 commit**:`promote_to_accepted`/`digest.append`/`review.record`/`set_decisions` 均只 `flush()`,交由 T2.4 验收事务单次 `commit()`(对齐「写库副作用在事务/编排层」);唯独 draft `save_draft` 仍自 commit(M1 自动保存语义)。`(project_id,chapter_no,version)` 唯一性是 **DB 级**(T0.2 models 定义),纯 fake 单测不断言它(不引 pg 依赖以免 pytest 门禁需起库)→ 由 T2.7 E2E 真实 DB 覆盖。
+- [2026-06-18] @backend/@llm **网关 ledger 只 flush、调用方必须 commit**(不变量「写库副作用在编排层不在网关」的代价):`SqlAlchemyLedgerSink.record` 只 `flush()` 不 `commit()`;`get_session` 退出时不提交 → 隐式回滚。draft SSE 端点曾因此把 `usage_ledger` 行丢掉(T1.9 暴露)。修复:端点在 SSE 流**耗尽后** `await session.commit()`(FastAPI 缓存 `Depends(get_session)`,网关 ledger 与端点同一 session)。**M2 起凡用网关产 usage 的路径(四审/accept)都要确保所在事务最终提交**,否则记账静默丢失。
+- [2026-06-18] @frontend **Next 里消费 SSE 用 `fetch`+`ReadableStream` reader,不用 `EventSource`**(EventSource 不能 POST、不能干净 abort)。"停"=`AbortController.abort()`,吞掉 `AbortError`、已收 token 留在 state 并被自动保存。流前错误(无凭据→503 `LLM_UNAVAILABLE` 是 **JSON 信封非帧**)经 `!res.ok` 检出、从 `{error:{code,message}}` 解析。
+- [2026-06-18] @frontend apps/web 测试环境坑:`server-only` 包未装→别 import(Server Component 仅靠约定);vitest 是 **2.x**(无 `toHaveBeenCalledExactlyOnceWith`,用 `toHaveBeenCalledTimes`+`toHaveBeenCalledWith`);**未装 jsdom/testing-library**→单测走 node env 测纯逻辑(SSE reducer/帧缓冲、debounce、向导状态机),组件 DOM 渲染留给 T1.9 Playwright。
+- [2026-06-18] @backend **stub user 未 seed → FK 风险**:`projects.owner_id` / `usage_ledger.owner_id` / `provider_credentials.owner_id` 全 FK→`users.id`,但仓库无 seeded stub user。约定 `STUB_OWNER_ID = uuid.UUID(int=1)`(对齐网关 `Scope.user_id` stub)。任何写这些表的路径(写章记账、立项、存凭据)跑前必须存在该 user 行——T1.4 幂等 seed(startup/lifespan);auth 落地后替换为真实 principal。
+- [2026-06-18] @backend 含 nullable 列的唯一约束别用 PG `ON CONFLICT`:`provider_credentials(owner_id,project_id,provider)` / `tier_routing(project_id,tier)` 的 `project_id` 可空,PG 默认 NULLS DISTINCT → 全局行(`project_id=NULL`)的 `ON CONFLICT` 不去重、会插重复。T1.7 用显式 read-modify-write(`project_id IS NULL`)。若 @db 后续给约束加 `NULLS NOT DISTINCT` 可改回原生 upsert。
+- [2026-06-18] @llm langgraph **1.2.5** 实装(pyproject 写 `>=0.2.40` 但装了 1.x,用 1.x API):`from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver` → `AsyncPostgresSaver.from_conn_string(...)`(async ctx) → `await saver.setup()`(只在 migrations/CI)。mypy strict 下:`add_node` 不收 `functools.partial`(用 `async def` 闭包绑定依赖);`StateGraph[...]`/`CompiledStateGraph[...]`/`BaseCheckpointSaver[Any]` 需写全类型参;测试 state dict 标注 `: ChapterState`、`ainvoke` config 标注 `: RunnableConfig`。
+- [2026-06-18] @orchestrator **跨包测试同名碰撞**:每包 `tests/` 无 __init__(避免与顶层 `tests` 包撞),但多包并存时 ① pytest 全跑:同名顶层模块 `fakes.py` 撞("import file mismatch")→ 测试替身用**全局唯一**名(`fakes_gateway`/`fakes_providers`/`fakes_orchestrator`);② 聚合 `mypy packages apps`:多个 rootless `conftest.py` 撞成同名模块 → root pyproject `[tool.mypy] exclude=["(^|/)conftest\\.py$"]`(conftest 仅 fixtures,按包仍受检)。test_*.py 保持全局唯一名。
+- [2026-06-18] @backend Fernet 凭据 key 取 `settings.credential_enc_key`(env `CREDENTIAL_ENC_KEY`);`get_settings` 是 lru_cache,测试改 env 后须 `get_settings.cache_clear()`(在 client fixture 里)。list/GET 不解密(只回脱敏占位),仅 probe 按需解密——缩小明文暴露面。
+- [2026-06-18] @orchestrator 后台 fork 子代理会因 "stream idle timeout" 早夭(T1.1 网关 fork 跑了 8.5 分钟、0 产出、0 文件)。坑:别盲等/盲重启重型 fork。做法:fork 完成后先核对**产物文件 + 自跑门禁**再认其结果;早夭则编排者内联实现该任务(已有完整上下文),不再二次 fork 同一关键路径任务。
+- [2026-06-18] @backend 记忆选择走**确定性子串名匹配**(在 flatten+sorted 的 beats/facts 文本上 + 显式 entities 列表),非 pg_trgm/向量;§3.4 的 pg_trgm/作者 pin 兜底属后续。`selection` 与 `render_cards` 均按 `(kind, name)` 排序→输出与 repo 返回顺序无关;CJK 名按 **codepoint** 排(乙 U+4E59 < 甲 U+7532),测试断言按 codepoint 而非甲乙丙语义。`latest_state` 只进 `volatile`(卡片),`stable_core` 故意不含它以保缓存前缀字节稳定。
+- [2026-06-18] @qa/@llm 包内单测放 `packages//tests/`(**无 __init__.py**,避免与顶层 `tests` 包同名冲突);测试替身放独立 `fakes.py` 用绝对导入 `from fakes import ...`(不能 `from .conftest import`,相对导入在无包目录下报 no known parent package);`conftest.py` 只放 fixtures。门禁按包跑:`uv run {ruff check|mypy|pytest} packages/`。
+- [2026-06-18] @frontend pnpm 11 配置已迁出 package.json/.npmrc → 只读 `apps/web/pnpm-workspace.yaml`。坑:`pnpm run