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

72
PROGRESS.md Normal file
View File

@@ -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.415 新测;只 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** / buildE2E 真实 DB + mock 网关零 token 走通 写→审→裁决→验收→摘要入库DB 真源逐项断言。**M2 完成。** 下一阶段 M3伏笔 + 节奏)见 DEV_PLAN进入时拉入本表。
---
## 已归档阶段
<details><summary>Phase 1 · 写章闭环骨架 (M1) — ✅ 全部完成2026-06-18</summary>
T1.1 LLM 网关接口+DeepSeek 适配器+usage_ledger 记账C1✅稳定@llm · T1.2 记忆 assemble 确定性选择C5✅稳定@backend · T1.3 LangGraph 写章节点+Postgres checkpointer+SSEC4✅稳定@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 / buildE2E 闭环走通。
M2 起可补的 M1 占位:本章助手/注入透明面板、向导主角/世界观字段、档位路由可写。
</details>
<details><summary>Phase 0 · 基建与栈修订 — ✅ 全部完成2026-06-18</summary>
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`
</details>
---
## 变更日志append-only最新在最上
> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件`
- [2026-06-18] @orchestrator**🎉 M2 完成**Wave ET2.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 ET2.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 提炼在事务外(R2light 档)→ 单原子事务 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 DT2.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(对齐 R3accept 从领域表重读、checkpoint 仅控制流。全仓门禁绿ruff/format 干净、mypy **80 文件**、pytest **103 passed**。memory 登记 C4 扩 + 3 gotchasreducer/add_node 重载/失败隔离两层)。下一步 **Wave CT2.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 BT2.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 AT2.1(@llm·packages/agents+llm_gateway) ‖ T2.3(@backend·core/domain)**(不同目录,无写冲突)。
- [2026-06-18] @orchestrator**🎉 M1 完成**Wave ET1.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 ET1.9 E2E(@qa, mock 网关)。
- [2026-06-18] @orchestrator**Wave C 完成**T1.4✅ 立项+写章 API**C3 稳定**M1 端点全落)。记忆→网关→编排器串成 HTTP5 端点入 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 并行(各 ~9098k 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.idT1.4 须先幂等 seed stub user(`uuid(int=1)`)。下一步 Wave CT1.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 BT1.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 AT1.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。

View File

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

View File

@@ -0,0 +1,136 @@
"""验收事务 + 冲突 gateT2.4ARCH §5.5 四步,不变量 #3/#4/#5
把作者裁决后的变更**单原子事务**落库R3/R4晋升终稿 → 终稿提炼 digest →
裁决留痕 →(占位)人物/伏笔状态。digest 提炼在**事务外**先做R2
冲突 gateR5事务前拦截核对最近一次审稿留痕的**每个冲突**在裁决清单里都有
采纳/忽略/手改之一;缺判 → `AppError(CONFLICT_UNRESOLVED)`,不写库。
accept 是**确定性事务代码**,不跑 graph 正文/审稿节点;真相从领域表(`chapters` /
`chapter_reviews`重读R3不变量 #5。提交边界全部只 flush → 末尾一次
`await session.commit()`;任一步失败 → 整体回滚。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass
import structlog
from ww_core.domain.chapter_repo import ChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.review_repo import ReviewRepo, ReviewView
from ww_shared import AppError, ErrorCode
from ww_api.schemas.projects import ConflictDecision
log = structlog.get_logger(__name__)
@dataclass(frozen=True)
class AcceptOutcome:
"""验收事务结果(供端点组「本次将更新」清单)。"""
accepted_version: int
digest_added: bool
decisions_recorded: int
review_id: uuid.UUID | None
def assert_conflicts_resolved(
latest_review: ReviewView | None,
decisions: list[ConflictDecision],
) -> None:
"""冲突 gateR5最近审稿的每个冲突都须有裁决否则 `CONFLICT_UNRESOLVED`。
判据:以最近审稿留痕的 `conflicts` 列表下标为冲突身份;裁决清单里出现的
`conflict_index` 集合必须**覆盖** `range(len(conflicts))`。缺判 → 拦截(不写库)。
无审稿留痕或零冲突 → 直接通过(无需裁决)。纯函数:只判定、不副作用。
"""
if latest_review is None:
return
conflict_count = len(latest_review.conflicts)
if conflict_count == 0:
return
decided = {d.conflict_index for d in decisions}
missing = [i for i in range(conflict_count) if i not in decided]
if missing:
raise AppError(
ErrorCode.CONFLICT_UNRESOLVED,
"存在未裁决的冲突,无法验收:请对每个冲突选择 采纳/忽略/手改",
{"missing_conflict_indices": missing, "conflict_count": conflict_count},
)
def _serialize_decisions(
latest_review: ReviewView | None,
decisions: list[ConflictDecision],
) -> dict[str, object]:
"""把裁决清单序列化为可落 `chapter_reviews.decisions` 的 JSON 形(确定性)。"""
return {
"items": [
{
"conflict_index": d.conflict_index,
"verdict": d.verdict,
"note": d.note,
}
for d in sorted(decisions, key=lambda x: x.conflict_index)
],
"conflict_count": len(latest_review.conflicts) if latest_review else 0,
}
async def run_accept_transaction(
*,
session: object,
chapter_repo: ChapterRepo,
digest_repo: DigestAppendRepo,
review_repo: ReviewRepo,
project_id: uuid.UUID,
chapter_no: int,
final_text: str,
digest_facts: dict[str, object],
latest_review: ReviewView | None,
decisions: list[ConflictDecision],
) -> AcceptOutcome:
"""单原子事务落库R3/R4§5.5 步骤 14末尾一次 commit。
`digest_facts` 已在事务外提炼好R2。各 repo 写方法只 flush本函数统一在末尾
`await session.commit()`,任一步抛错由调用方/上下文回滚(不显式半提交)。
`session` 类型用 object 以免绑定 SQLAlchemy测试注入 fake session 亦可)。
"""
# 步骤 1终稿晋升 accepted 新 versionmax+1草稿行保留R4
chapter = await chapter_repo.promote_to_accepted(project_id, chapter_no, content=final_text)
# 步骤 2终稿 digest 追加append-only不变量 #4
await digest_repo.append(project_id, chapter_no, facts=digest_facts)
# 步骤 4裁决留痕写到最近一次审稿行无审稿行则跳过
review_id: uuid.UUID | None = None
if latest_review is not None:
updated = await review_repo.set_decisions(
latest_review.id,
decisions=_serialize_decisions(latest_review, decisions),
)
review_id = updated.id
# 步骤 3占位人物 latest_state / 伏笔状态更新——M3 才正式接伏笔表,
# 这里按 §5.5 步骤 3 留占位,不引入 M3 表逻辑(避免越界写未就绪的状态机)。
# TODO(M3): 按裁决应用 latest_state 变更 + 伏笔登记/到期扫描§6.2)。
await session.commit() # type: ignore[attr-defined] # AsyncSession.commit()fake 同形)
log.info(
"chapter_accepted",
project_id=str(project_id),
chapter_no=chapter_no,
accepted_version=chapter.version,
decisions_recorded=len(decisions),
review_id=str(review_id) if review_id else None,
)
return AcceptOutcome(
accepted_version=chapter.version,
digest_added=True,
decisions_recorded=len(decisions),
review_id=review_id,
)

View File

@@ -0,0 +1,99 @@
"""终稿 digest 提炼(验收事务的 R2 步骤ARCH §5.5 / §6.1,不变量 #4
验收时用**终稿**(作者裁决/改稿后的最终文本)经网关跑一次轻量结构化提炼,得 digest
(结构化事实)。**在开原子事务之前**完成——别在持开事务里跨网络调 LLMR2
`ChapterDigestFacts` 是 digest 的结构化形:本章关键事实清单,供后续章节注入近况摘要
assemble 的 `recent_digests`+ 一致性比对。轻量档位tier=light只读终稿、产事实。
记账digest 提炼这次网关调用产 usage经 `SqlAlchemyLedgerSink` flush 进**同一请求
session**——由验收事务在末尾一次 commit 落 `usage_ledger`(见 ledger gotcha
"""
from __future__ import annotations
import uuid
from typing import Any
import structlog
from pydantic import BaseModel, Field
from ww_llm_gateway import Gateway
from ww_llm_gateway.types import Block, LlmRequest, Scope
log = structlog.get_logger(__name__)
DIGEST_SYSTEM_PROMPT = """你是长篇连载小说的「章节摘要提炼」。读入本章**终稿**,抽取后续\
章节一致性比对所需的结构化事实,产出结构化摘要。
只提炼**终稿明确写出**的事实,不臆造、不推断未写明的内容:
- summary本章一句话主线≤60 字);
- events本章发生的关键事件按时序
- characters登场人物及其本章状态变化姓名 + 状态/变化);
- locations出现的地点
- foreshadow本章埋下或回收的伏笔线索。
纪律:只读终稿、只产事实,不评价、不改稿、不报冲突(冲突在审稿期产)。"""
class CharacterStateFact(BaseModel):
"""单个人物的本章状态事实。"""
name: str = Field(description="人物姓名")
state: str = Field(description="本章该人物的状态/变化")
class ChapterDigestFacts(BaseModel):
"""终稿提炼的结构化事实(落 `chapter_digests.facts`)。"""
summary: str = Field(default="", description="本章一句话主线")
events: list[str] = Field(default_factory=list, description="关键事件(时序)")
characters: list[CharacterStateFact] = Field(
default_factory=list, description="登场人物及其本章状态变化"
)
locations: list[str] = Field(default_factory=list, description="出现的地点")
foreshadow: list[str] = Field(default_factory=list, description="埋下/回收的伏笔线索")
def build_digest_request(
*, final_text: str, user_id: uuid.UUID, project_id: uuid.UUID
) -> LlmRequest:
"""据终稿构造 digest 提炼请求(纯函数)。
`system_prompt` 进缓存断点前块;终稿进 `input`断点后。tier=light不变量 #2
`output_schema=ChapterDigestFacts` → 网关经 instructor 保证产结构化实例C1
"""
return LlmRequest(
tier="light",
system=[Block(text=DIGEST_SYSTEM_PROMPT, cache=True)],
input=f"## 本章终稿\n{final_text}",
output_schema=ChapterDigestFacts,
scope=Scope(user_id=user_id, project_id=project_id),
)
async def extract_digest_facts(
gateway: Gateway,
*,
final_text: str,
user_id: uuid.UUID,
project_id: uuid.UUID,
chapter_no: int,
) -> dict[str, Any]:
"""从终稿提炼结构化事实,返回可直接落 `facts` 列的 dict。
**在开原子事务之前**调用R2别在持开事务里跨网络调 LLM。`gateway.run(req).parsed`
带 schema 时必非 NoneC1防御性兜底若 parsed 缺失则落空事实(不崩验收)。
日志脱敏:只记终稿长度,不记正文。
"""
req = build_digest_request(final_text=final_text, user_id=user_id, project_id=project_id)
resp = await gateway.run(req)
parsed = resp.parsed
facts = parsed.model_dump() if parsed is not None else ChapterDigestFacts().model_dump()
log.info(
"digest_extracted",
project_id=str(project_id),
chapter_no=chapter_no,
final_text_len=len(final_text),
event_count=len(facts.get("events", [])),
)
return facts

View File

@@ -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 取项目 + 审稿留痕历史(新→旧);
// ReviewReportClient承载 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 (
<ReviewReport
project={project}
chapterNo={chapterNo}
initialReview={latestReview(history.reviews)}
initialDraft=""
/>
);
} 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;
}

View File

@@ -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;
}
// 验收 gateUX §9 / §8.4):未决禁验收(置灰 + 文案),验收后呈现「本次将更新」清单。
export function AcceptPanel({
conflictCount,
unresolvedCount,
accepting,
result,
onAccept,
}: AcceptPanelProps) {
const blocked = unresolvedCount > 0;
if (result) {
return (
<div className="rounded border border-pass/50 bg-panel p-4">
<h3 className="mb-2 text-sm font-semibold text-pass"></h3>
<ul className="space-y-1 text-xs text-ink-soft">
<li>
<span className="font-mono text-ink">v{result.accepted_version}</span>
</li>
<li>
{result.digest_added ? "已新增一行" : "未变更"}
</li>
<li>
<span className="font-mono text-ink">
{result.decisions_recorded}
</span>{" "}
</li>
{result.review_id ? (
<li className="truncate">
<span className="font-mono">{result.review_id}</span>
</li>
) : null}
</ul>
</div>
);
}
return (
<div className="rounded border border-line bg-panel p-4">
{blocked ? (
<p className="mb-2 text-xs text-conflict" role="status">
{unresolvedCount}
</p>
) : (
<p className="mb-2 text-xs text-ink-soft">
{conflictCount === 0
? "无冲突,可直接验收。"
: "全部冲突已裁决,可验收本章。"}
</p>
)}
<button
type="button"
onClick={onAccept}
disabled={blocked || accepting}
aria-disabled={blocked || accepting}
className="w-full rounded bg-cinnabar px-4 py-2 text-sm text-panel hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{accepting ? "验收中…" : "全部处理完 → 验收本章"}
</button>
</div>
);
}

View File

@@ -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 (
<div className="flex h-full flex-col">
{conflicts.length > 0 ? (
<div className="mb-3 flex flex-wrap gap-2 border-b border-line pb-3">
{conflicts.map((c, i) => (
<button
key={i}
type="button"
id={`anchor-${i}`}
onClick={() => onAnchorClick(i)}
aria-current={focusedIndex === i ? "true" : undefined}
className={`conflict-anchor rounded px-2 py-0.5 text-xs ${
focusedIndex === i
? "bg-cinnabar text-panel"
: "text-conflict hover:bg-[var(--color-conflict)]/10"
}`}
title={`${c.type}${c.where || "正文"}`}
>
<span aria-hidden="true"></span> {c.type}
{c.where ? ` · ${c.where}` : ""}
</button>
))}
</div>
) : null}
<article className="flex-1 overflow-auto whitespace-pre-wrap font-serif text-[17px] leading-[1.9] text-ink">
{text || (
<span className="text-ink-soft/60"></span>
)}
</article>
</div>
);
}

View File

@@ -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 (
<li
id={`conflict-card-${index}`}
className={`rounded border bg-panel p-4 ${
missing
? "border-conflict ring-1 ring-conflict"
: resolved
? "border-pass/40"
: "border-line"
}`}
>
<div className="flex items-start gap-3">
<span
className="shrink-0 rounded bg-[var(--color-conflict)]/10 px-2 py-0.5 text-xs text-conflict"
aria-hidden="true"
>
{conflict.type}
</span>
<p className="flex-1 text-sm text-ink">{conflict.suggestion}</p>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 pl-1 text-xs text-ink-soft">
{conflict.where ? (
<button
type="button"
onClick={() => onJump(index)}
className="rounded border border-line px-2 py-0.5 hover:border-cinnabar hover:text-cinnabar"
>
{conflict.where}
</button>
) : null}
{conflict.refs.map((ref) => (
<span key={ref} className="font-mono">
{ref}
</span>
))}
</div>
<fieldset className="mt-3">
<legend className="sr-only"> {index + 1} </legend>
<div className="flex flex-wrap items-center gap-2">
{VERDICT_OPTIONS.map((opt) => {
const active = draft.verdict === opt.value;
return (
<button
key={opt.value}
type="button"
aria-pressed={active}
onClick={() => onVerdict(index, opt.value)}
className={`rounded px-3 py-1 text-xs ${
active
? "bg-cinnabar text-panel"
: "border border-line text-ink hover:border-cinnabar"
}`}
>
{opt.label}
</button>
);
})}
{resolved ? (
<span className="text-xs text-pass" aria-hidden="true">
</span>
) : (
<span className="text-xs text-conflict"></span>
)}
</div>
{draft.verdict === "manual" ? (
<div className="mt-2">
<label htmlFor={`note-${index}`} className="sr-only">
</label>
<input
id={`note-${index}`}
type="text"
value={draft.note}
onChange={(e) => onNote(index, e.target.value)}
placeholder="手改说明(可选)"
className="w-full rounded border border-line bg-bg px-3 py-1.5 text-sm text-ink focus:border-cinnabar focus:outline-none"
/>
</div>
) : null}
</fieldset>
</li>
);
}

View File

@@ -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<DecisionDraft[]>(() =>
emptyDecisions(normalizeConflicts(initialReview).length),
);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const [missing, setMissing] = useState<Set<number>>(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<void> => {
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 (
<AppShell
title={`${project.title}`}
subtitle={`${chapterNo} 章 · 审稿报告`}
>
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[1fr_24rem]">
{/* 左:终稿正文 + 就地标注 */}
<section className="flex min-w-0 flex-col bg-bg">
<div className="flex items-center gap-3 border-b border-line bg-panel px-6 py-3">
<h1 className="font-serif text-lg text-ink">
{chapterNo} 稿
</h1>
<span className="font-mono text-xs text-ink-soft">
{conflictCount}
</span>
<div className="ml-auto">
{reviewing ? (
<button
type="button"
onClick={review.stop}
className="rounded border border-conflict px-3 py-1.5 text-sm text-conflict"
>
</button>
) : (
<button
type="button"
onClick={onReReview}
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
>
稿
</button>
)}
</div>
</div>
{review.state.error ? (
<p className="border-b border-line bg-panel px-6 py-2 text-sm text-conflict">
稿{review.state.error.code}
{review.state.error.message}
{review.state.error.code === "LLM_UNAVAILABLE" ? (
<>
{" "}
<a
href="/settings/providers"
className="underline hover:text-cinnabar"
>
</a>
</>
) : null}
</p>
) : null}
<div className="flex-1 overflow-auto px-6 py-6">
<AnnotatedText
text={finalText}
conflicts={conflicts}
focusedIndex={focusedIndex}
onAnchorClick={jumpToCard}
/>
<details className="mt-4">
<summary className="cursor-pointer text-xs text-ink-soft hover:text-cinnabar">
/稿稿 稿
</summary>
<label htmlFor="final-text" className="sr-only">
稿
</label>
<textarea
id="final-text"
value={finalText}
onChange={(e) => setFinalText(e.target.value)}
className="mt-2 min-h-[20vh] w-full resize-y rounded border border-line bg-panel p-3 font-serif text-[16px] leading-[1.9] text-ink focus:border-cinnabar focus:outline-none"
/>
</details>
</div>
</section>
{/* 右:审稿分区 + 冲突裁决 + 验收 gate */}
<aside className="flex flex-col overflow-auto border-l border-line bg-panel px-4 py-4">
<section className="mb-4">
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
(continuity)
</h2>
<SectionStatusLine
reviewing={reviewing}
done={review.state.phase === "done"}
conflictCount={conflictCount}
sections={review.state.sections}
/>
</section>
<section className="flex-1">
{conflicts.length === 0 ? (
<p className="rounded border border-dashed border-line p-4 text-xs text-ink-soft">
{review.state.phase === "done" || seededConflicts.length === 0
? "未发现一致性冲突。"
: "进页未带审稿留痕,点「重新审稿」开始。"}
</p>
) : (
<ul className="space-y-3">
{conflicts.map((c, i) => (
<ConflictCard
key={i}
index={i}
conflict={c}
draft={drafts[i] ?? { verdict: null, note: "" }}
missing={missing.has(i)}
onVerdict={onVerdict}
onNote={onNote}
onJump={jumpToAnchor}
/>
))}
</ul>
)}
</section>
<section className="mt-4">
<AcceptPanel
conflictCount={conflictCount}
unresolvedCount={resolved ? 0 : unresolved}
accepting={accept.status === "accepting"}
result={accept.result}
onAccept={() => void onAccept()}
/>
</section>
</aside>
</div>
</AppShell>
);
}
interface SectionStatusLineProps {
reviewing: boolean;
done: boolean;
conflictCount: number;
sections: { name: string; status: string }[];
}
// 四审进行中骨架逐项点亮UX §9M2 仅 continuity 一项。
function SectionStatusLine({
reviewing,
done,
conflictCount,
sections,
}: SectionStatusLineProps) {
if (reviewing) {
return (
<p className="mt-1 text-xs text-info" aria-live="polite">
稿
</p>
);
}
if (done || sections.length > 0) {
return (
<p className="mt-1 text-xs">
{conflictCount > 0 ? (
<span className="text-conflict"> {conflictCount} </span>
) : (
<span className="text-pass"> </span>
)}
</p>
);
}
return null;
}

View File

@@ -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,
);
});
});

View File

@@ -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<number> {
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"));
}

View File

@@ -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<string, unknown>[],
): 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();
});
});

View File

@@ -0,0 +1,34 @@
// 审稿历史归一:把后端 ReviewHistoryItem 的松散 conflictsdict[])收紧成 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];
}

View File

@@ -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");
});
});

155
apps/web/lib/review/sse.ts Normal file
View File

@@ -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));
}

View File

@@ -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<AcceptOutcome>;
}
interface ApiErrorEnvelope {
error?: {
code?: string;
message?: string;
details?: { missing_conflict_indices?: number[] } | null;
};
}
// 验收:乐观置 accepting → 成功置 accepted呈现「本次将更新」清单
// 失败回滚状态 + toast409 CONFLICT_UNRESOLVED 解析缺判下标供高亮。
export function useAccept(): UseAccept {
const [status, setStatus] = useState<AcceptStatus>("idle");
const [result, setResult] = useState<AcceptResponse | null>(null);
const toast = useToast();
const accept = useCallback<UseAccept["accept"]>(
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 };
}

View File

@@ -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<ReviewFrameBuffer["push"]> }
| { 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<void>;
stop: () => void;
// 进页用历史留痕的冲突种入(无需重审即可裁决)。
seed: (conflicts: ReviewConflict[]) => void;
}
// 消费 POST .../review 的 SSE 流fetch+ReadableStreamEventSource 不支持 POST
// "停" = abort已收 section/conflict 留在 state裁决草稿不丢
// 流前 503 LLM_UNAVAILABLE 是 JSON 信封(非帧)→ 经 !res.ok 检出。
export function useReviewStream(): UseReviewStream {
const [state, dispatch] = useReducer(reducer, initialReviewState);
const controllerRef = useRef<AbortController | null>(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<void> => {
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,
};
}

98
memory/contracts.md Normal file
View File

@@ -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 schemaSQLAlchemy 模型 owner @db 状态: 待定义
- 来源:`ARCHITECTURE.md §3.1` DDL11 创作表 + 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-18snake_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-18snake_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:<token|done|error>\ndata:<json>\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-18snake_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 末尾一次 commitR3
- 注入缝:`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-18M2 扩四审/验收)
- 来源:`ARCHITECTURE.md §5.2`(图)/ §7.3SSE。**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}`——仅控制流+组装上下文+累积草稿(不变量#5resume 从领域表重读。
- 节点缝:
- `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 只传 tierDB 唯一 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返回新 dictTypedDict 改 `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 个 ProtocolOutline/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。

49
memory/decisions.md Normal file
View File

@@ -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 要求验收「单事务」含「从终稿提炼 digestLLM 调用)」,但不能在持开 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 表逻辑。
- 影响:契约 C3accept/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`,供 assembleM2 需写侧 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` 仍自 commitM1 自动保存语义)。
- 影响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。

38
memory/gotchas.md Normal file
View File

@@ -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 长度变化判(同数不同组会漏重置),也不能在 seedphase=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` 用于记账,避免结构化路径丢 usageusage 提取统一走 `_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-ignoreruff/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` 仍自 commitM1 自动保存语义)。`(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` 包未装→别 importServer 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 幂等 seedstartup/lifespanauth 落地后替换为真实 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/<pkg>/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/<pkg>`
- [2026-06-18] @frontend pnpm 11 配置已迁出 package.json/.npmrc 只读 `apps/web/pnpm-workspace.yaml``pnpm run <script>` 前会跑 verifyDepsBeforeRun 触发隐式 install `ERR_PNPM_IGNORED_BUILDS`(esbuild/sharp/unrs-resolver 被默认拦截)直接整条命令失败做法 `pnpm-workspace.yaml` `onlyBuiltDependencies:` 白名单 + `verifyDepsBeforeRun: false`
- [2026-06-18] @frontend gen:api 离线管线`scripts/gen-api.mjs` `execFileSync(uv, [...])` 取后端 OpenAPI(不靠运行中的服务) + 直接调 `node_modules/.bin/openapi-typescript`(别用 `pnpm exec`会再触发 deps 检查)。改后端 schema 后跑 `pnpm gen:api` 重生成 `lib/api/schema.d.ts`
- [2026-06-18] @backend async engine 跨事件循环坑`get_sessionmaker` `lru_cache`engine 绑定首个 looppytest-asyncio 每测试新 loop 复用会报 `Connection._cancel never awaited`/连接失败测试里每个 DB 测试 `get_sessionmaker.cache_clear()` 在当前 loop 重建并 `dispose()`
- [2026-06-18] @db mypy strict + 跨包 editable 安装模型里 `from ww_db.base import Base` 会被判成 Any( "cannot subclass Any")需在根 `pyproject.toml` `[tool.mypy] mypy_path=[...各包源码目录...]` + `namespace_packages=true`否则 editable 包解析不到源码
- [2026-06-17] @docs 命名契约后端 Python/Pydantic 一律 **snake_case**字段/schema/JSON前端经 OpenAPI 生成类型消费——别手写 camelCase 共享类型评审里曾因 TS 旧栈遗留 camelCase Pydantic 契约对不上)。
- [2026-06-17] @docs 数据写入只走**验收事务**四审 agent 只读不写任何 AI 产出入库必经 `accept`HITL gate)。别在 agent 节点里直接写库

View File

@@ -0,0 +1,2 @@
# core (@llm/@backend) — 占位骨架
领域核心(domain/状态机)、编排器(orchestrator/LangGraph 图)、记忆服务(memory)。Phase 1+ 由对应 owner 填充。

View File

@@ -0,0 +1,20 @@
[project]
name = "ww-agents"
version = "0.0.0"
requires-python = ">=3.12"
dependencies = [
"pydantic>=2.7",
"ww-shared",
"ww-llm-gateway",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["ww_agents"]
[tool.uv.sources]
ww-shared = { workspace = true }
ww-llm-gateway = { workspace = true }

View File

@@ -0,0 +1,95 @@
"""T2.1 续审契约测试ContinuityReview schema + continuity_spec 声明C6
契约测试——构造符合 schema 的 mock 响应,校验 type 枚举约束、conflicts 列表、
四审只读writes==[])。不联网、无 DB。
"""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from ww_agents import AgentSpec, Conflict, ContinuityReview, continuity_spec
def test_continuity_review_parses_mock_response() -> None:
# Arrange模拟网关 instructor 校验后的结构化产出
mock = {
"conflicts": [
{
"type": "性格漂移",
"where": "第 3 段,主角忽然示弱",
"refs": ["第 1 章人物卡:性格刚烈"],
"suggestion": "改为强硬回应,或补内心转折铺垫",
},
{
"type": "时间线倒错",
"where": "开篇提到三日后",
"refs": ["第 5 章 digest事件发生在次日"],
"suggestion": "统一为次日",
},
]
}
# Act
review = ContinuityReview.model_validate(mock)
# Assert
assert len(review.conflicts) == 2
assert review.conflicts[0].type == "性格漂移"
assert review.conflicts[1].refs == ["第 5 章 digest事件发生在次日"]
def test_continuity_review_allows_empty_conflicts() -> None:
review = ContinuityReview.model_validate({"conflicts": []})
assert review.conflicts == []
def test_continuity_review_defaults_to_empty_conflicts() -> None:
review = ContinuityReview()
assert review.conflicts == []
def test_conflict_type_rejects_unknown_value() -> None:
with pytest.raises(ValidationError):
Conflict.model_validate({"type": "排版错误", "where": "x", "refs": [], "suggestion": "y"})
def test_conflict_refs_default_empty() -> None:
conflict = Conflict.model_validate(
{"type": "设定违例", "where": "战斗场景", "suggestion": "删去违例能力"}
)
assert conflict.refs == []
def test_continuity_spec_is_analyst_tier() -> None:
assert continuity_spec.tier == "analyst"
assert continuity_spec.name == "continuity"
def test_continuity_spec_is_read_only() -> None:
# 不变量 #3四审只读
assert continuity_spec.writes == []
def test_continuity_spec_declares_expected_reads() -> None:
assert continuity_spec.reads == ["chapter_digests", "characters", "world_entities"]
def test_continuity_spec_output_schema_is_continuity_review() -> None:
assert continuity_spec.output_schema is ContinuityReview
def test_continuity_spec_has_nonempty_system_prompt() -> None:
assert continuity_spec.system_prompt.strip()
def test_agent_spec_is_immutable() -> None:
# frozen加载后不得改动
with pytest.raises(ValidationError):
continuity_spec.tier = "writer"
def test_agent_spec_writer_allows_none_output_schema() -> None:
writer = AgentSpec(name="writer", tier="writer", system_prompt="")
assert writer.output_schema is None
assert writer.scope == "builtin"

View File

@@ -0,0 +1,17 @@
"""ww-agents内置 Agent 声明AgentSpec+ 结构化输出 schemaC6
对齐 ARCH §5.1AgentSpec/ §5.4I/O 契约)/ §6.1(冲突分类)。
"""
from __future__ import annotations
from .schemas import Conflict, ConflictType, ContinuityReview
from .specs import AgentSpec, continuity_spec
__all__ = [
"AgentSpec",
"Conflict",
"ConflictType",
"ContinuityReview",
"continuity_spec",
]

View File

@@ -0,0 +1,46 @@
"""续审 Agent 的结构化输出 schemaARCH §5.4 continuity 行 / §6.1 冲突分类)。
snake_casePydantic v2。网关经 instructor 保证产出符合这些 schemaC1/§4.4)。
不变量 #3四审只读——审稿期只产**冲突清单**,不产 digestdigest 在验收时
从终稿另提,见不变量 #4 / ARCH §5.5)。
"""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
# ARCH §6.1 五类冲突;受限枚举,便于前端就地裁决分类与统计。
ConflictType = Literal[
"性格漂移",
"能力不符",
"设定违例",
"地理矛盾",
"时间线倒错",
]
class Conflict(BaseModel):
"""单条一致性冲突ARCH §6.1)。"""
type: ConflictType = Field(description="冲突分类(五类之一)")
where: str = Field(description="本章定位:哪一段/哪句触发冲突")
refs: list[str] = Field(
default_factory=list,
description="冲突来源引用:相关章节号、设定项或人物卡条目",
)
suggestion: str = Field(description="改法建议")
class ContinuityReview(BaseModel):
"""续审结构化产出:仅冲突清单(无 digest
digest 不在审稿期产——那是验收时从**终稿**另提(不变量 #4
"""
conflicts: list[Conflict] = Field(
default_factory=list,
description="检出的一致性冲突;无冲突时为空列表",
)

View File

@@ -0,0 +1,65 @@
"""Agent 声明式抽象ARCH §5.1+ 续审 Agent 实例。
`AgentSpec` 是内置 Agent 与用户 Skill 的同构声明:一份只读声明,由编排器加载、
经网关按 `tier` 执行。不变量 #2agent 只声明 `tier`**不**写具体 model。
不变量 #3四审 `writes=[]`(只读),任何 AI 产出入库必经验收事务。
不可变:`AgentSpec` 为 frozen Pydantic 模型——加载后不得改动。
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
from ww_llm_gateway.types import Tier
from .schemas import ContinuityReview
class AgentSpec(BaseModel):
"""单一 Agent / Skill 的声明ARCH §5.1)。"""
model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
name: str
tier: Tier # 能力档位(网关解析 provider+model不含具体 model
system_prompt: str
# 入/出参契约Pydantic 模型类型writer 纯文本 → output_schema 可为 None
input_schema: type[BaseModel] | None = None
output_schema: type[BaseModel] | None = None
reads: list[str] = Field(default_factory=list) # 声明式表读权限
writes: list[str] = Field(default_factory=list) # 声明式表写权限(经验收才生效)
genre: str | None = None # 题材适用Skill 用)
scope: str = "builtin" # builtin / custom / community
CONTINUITY_SYSTEM_PROMPT = """你是长篇连载小说的「一致性续审」。你的唯一职责:把本章草稿与\
作品的既有真相源逐项比对,找出一致性冲突,产出结构化冲突清单。
比对依据(注入材料):
- 近况摘要(最近若干章的 chapter_digests
- 相关人物卡(性格、能力、关系、最新状态 latest_state
- 世界观硬规则world_entities 的不可违背设定)。
按以下五类判定冲突,每条给出本章定位、来源引用与改法建议:
- 性格漂移:人物言行与其设定/既往表现不符;
- 能力不符:超出或低于已建立的能力/力量体系边界;
- 设定违例:违反世界观硬规则;
- 地理矛盾:地点/距离/空间关系与既有设定冲突;
- 时间线倒错:事件先后、时序与既有章节矛盾。
纪律:
- 你**只读、只报冲突**,不改稿、不写库、不产章节摘要(摘要在验收时从终稿另提)。
- 只报有据可依的真冲突;无冲突则返回空列表,不要臆造。
- 引用要具体(章节号、设定项、人物卡条目),便于作者就地裁决。"""
continuity_spec = AgentSpec(
name="continuity",
tier="analyst",
system_prompt=CONTINUITY_SYSTEM_PROMPT,
input_schema=None, # 注入材料为序列化文本(经记忆 assemble非结构化入参
output_schema=ContinuityReview,
reads=["chapter_digests", "characters", "world_entities"],
writes=[], # 只读(不变量 #3
scope="builtin",
)

View File

@@ -0,0 +1,160 @@
"""T2.3 章节 accepted 版本晋升单测ARCH §3.3 多版本行 / §5.5 step 1
纯内存 fake无 DB断言 max(version)+1 计算、草稿行保留、按 project_id 隔离、
返回不可变 frozen View。`(project_id, chapter_no, version)` 唯一约束的 **DB 级**强制
由 `chapters` 模型 UniqueConstraint 定义(见 packages/db/ww_db/models.py真实 DB
路径由 T2.7 E2E 覆盖;此处只测 repo 计算下一版次的逻辑。
"""
from __future__ import annotations
import uuid
from dataclasses import FrozenInstanceError, dataclass, field
import pytest
from ww_core.domain.chapter_repo import ChapterView
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
@dataclass
class _Row:
project_id: uuid.UUID
chapter_no: int
volume: int
content: str
status: str
version: int
@dataclass
class FakePromoteRepo:
"""内存替身——镜像 SqlChapterRepo 晋升语义max+1 新行,草稿保留)。"""
rows: list[_Row] = field(default_factory=list)
def _versions(self, project_id: uuid.UUID, chapter_no: int) -> list[int]:
return [
r.version
for r in self.rows
if r.project_id == project_id and r.chapter_no == chapter_no
]
async def max_version(self, project_id: uuid.UUID, chapter_no: int) -> int:
versions = self._versions(project_id, chapter_no)
return max(versions) if versions else 0
async def promote_to_accepted(
self, project_id: uuid.UUID, chapter_no: int, *, content: str, volume: int = 1
) -> ChapterView:
next_version = (await self.max_version(project_id, chapter_no)) + 1
row = _Row(
project_id=project_id,
chapter_no=chapter_no,
volume=volume,
content=content,
status="accepted",
version=next_version,
)
self.rows.append(row)
return ChapterView(
project_id=row.project_id,
chapter_no=row.chapter_no,
volume=row.volume,
content=row.content,
status=row.status,
version=row.version,
)
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> ChapterView | None:
accepted = [
r
for r in self.rows
if r.project_id == project_id and r.chapter_no == chapter_no and r.status == "accepted"
]
if not accepted:
return None
row = max(accepted, key=lambda r: r.version)
return ChapterView(
project_id=row.project_id,
chapter_no=row.chapter_no,
volume=row.volume,
content=row.content,
status=row.status,
version=row.version,
)
def _repo_with_draft() -> FakePromoteRepo:
repo = FakePromoteRepo()
repo.rows.append(
_Row(
project_id=PROJECT,
chapter_no=1,
volume=1,
content="draft text",
status="draft",
version=1,
)
)
return repo
async def test_promote_uses_max_version_plus_one() -> None:
fake = _repo_with_draft() # 已有 version=1 草稿
view = await fake.promote_to_accepted(PROJECT, 1, content="final")
assert view.version == 2 # max(1)+1
assert view.status == "accepted"
assert view.content == "final"
async def test_promote_keeps_draft_row() -> None:
fake = _repo_with_draft()
await fake.promote_to_accepted(PROJECT, 1, content="final")
draft = [r for r in fake.rows if r.status == "draft"]
assert len(draft) == 1 # 草稿行保留
assert draft[0].content == "draft text"
async def test_promote_second_accept_increments_again() -> None:
fake = _repo_with_draft()
await fake.promote_to_accepted(PROJECT, 1, content="v2")
again = await fake.promote_to_accepted(PROJECT, 1, content="v3")
assert again.version == 3 # 改稿再验收 → max(2)+1
async def test_promote_first_version_when_no_rows() -> None:
fake = FakePromoteRepo() # 空,无草稿
view = await fake.promote_to_accepted(PROJECT, 9, content="x")
assert view.version == 1 # 0+1
async def test_max_version_isolated_by_project() -> None:
fake = _repo_with_draft() # PROJECT/ch1 有 version=1
# OTHER 项目同章号互不影响
assert await fake.max_version(OTHER, 1) == 0
view = await fake.promote_to_accepted(OTHER, 1, content="other")
assert view.version == 1
async def test_latest_accepted_returns_highest_version() -> None:
fake = _repo_with_draft()
await fake.promote_to_accepted(PROJECT, 1, content="v2")
await fake.promote_to_accepted(PROJECT, 1, content="v3")
latest = await fake.latest_accepted(PROJECT, 1)
assert latest is not None
assert latest.version == 3
assert latest.content == "v3"
async def test_latest_accepted_none_when_only_draft() -> None:
fake = _repo_with_draft()
assert await fake.latest_accepted(PROJECT, 1) is None
async def test_chapter_view_is_frozen() -> None:
fake = FakePromoteRepo()
view = await fake.promote_to_accepted(PROJECT, 1, content="x")
with pytest.raises(FrozenInstanceError):
view.version = 99 # type: ignore[misc]

View File

@@ -0,0 +1,74 @@
"""T2.3 digest 追加 repo 单测ARCH §3.3 append-only / §5.5)。
纯内存 fake无 DB断言 append-only 语义(永不覆盖)、按 project_id 过滤、
返回不可变 frozen View。DB 级 append 行为同样由 `chapter_digests` 无唯一约束保证,
真实 DB 路径由 T2.7 E2E 覆盖。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
import pytest
from pydantic import ValidationError
from ww_core.domain.digest_repo import DigestAppendRepo
from ww_core.domain.repositories import DigestView
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
@dataclass
class _Row:
project_id: uuid.UUID
chapter_no: int
facts: dict[str, object]
@dataclass
class FakeDigestAppendRepo:
"""append-only 内存替身——镜像 SqlDigestAppendRepo 语义(每次 append 新增一行)。"""
rows: list[_Row] = field(default_factory=list)
async def append(
self, project_id: uuid.UUID, chapter_no: int, *, facts: dict[str, object]
) -> DigestView:
self.rows.append(_Row(project_id=project_id, chapter_no=chapter_no, facts=dict(facts)))
return DigestView(chapter_no=chapter_no, facts=dict(facts))
def _repo() -> FakeDigestAppendRepo:
return FakeDigestAppendRepo()
async def test_append_returns_frozen_view() -> None:
repo: DigestAppendRepo = _repo()
view = await repo.append(PROJECT, 1, facts={"entities": ["林动"]})
assert isinstance(view, DigestView)
assert view.chapter_no == 1
assert view.facts == {"entities": ["林动"]}
# frozen — 不可就地突变
with pytest.raises(ValidationError):
view.chapter_no = 9
async def test_append_is_append_only_never_overwrites() -> None:
fake = _repo()
repo: DigestAppendRepo = fake
await repo.append(PROJECT, 1, facts={"v": "first"})
await repo.append(PROJECT, 1, facts={"v": "second"})
# 同章两次 append → 两行都在,旧的不被覆盖
same_chapter = [r for r in fake.rows if r.chapter_no == 1]
assert len(same_chapter) == 2
assert [r.facts["v"] for r in same_chapter] == ["first", "second"]
async def test_append_does_not_mutate_input_facts() -> None:
fake = _repo()
repo: DigestAppendRepo = fake
facts: dict[str, object] = {"entities": ["林动"]}
await repo.append(PROJECT, 1, facts=facts)
facts["entities"] = ["改了"] # 改调用方的 dict 不应影响已存行
assert fake.rows[0].facts == {"entities": ["林动"]}

View File

@@ -0,0 +1,244 @@
"""T2.2 审稿子图单测——审稿节点 + collect 留痕 + review SSE 归一ARCH §5.2/§7.3)。
全部注入 mock 网关 / 内存 fake repo无真 LLM、无真 Postgres图用 MemorySaver
asyncio_mode=auto。
"""
from __future__ import annotations
import uuid
from typing import Any
from fakes_orchestrator import (
FailingRunGateway,
FakeReviewRepo,
FakeRunGateway,
)
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import MemorySaver
from ww_agents import Conflict, ContinuityReview, continuity_spec
from ww_core.orchestrator import (
CONTINUITY,
EVENT_CONFLICT,
EVENT_DONE,
EVENT_ERROR,
EVENT_SECTION,
REVIEW_INCOMPLETE,
REVIEW_OK,
SECTION_DONE,
SECTION_INCOMPLETE,
ChapterState,
build_review_context,
build_review_graph,
build_review_request,
collect_reviews,
extract_conflicts,
make_review_node,
normalize_review,
run_review,
)
from ww_shared import AppError, ErrorCode
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
USER = uuid.UUID("00000000-0000-0000-0000-0000000000aa")
STABLE = "## 世界观硬规则\n灵气可凝丹"
VOLATILE = "## 近况\n主角已破境"
DRAFT = "他一拳轰碎山岳。"
_CONFLICT = Conflict(
type="能力不符",
where="第3段「一拳轰碎山岳」",
refs=["人物卡:主角:力量上限=裂石"],
suggestion="改为震裂巨石",
)
def _state(reviews: dict[str, Any] | None = None) -> ChapterState:
state: ChapterState = {
"project_id": PROJECT,
"chapter_no": 7,
"user_id": USER,
"stable_core": STABLE,
"volatile": VOLATILE,
"draft": DRAFT,
"review_context": build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE),
}
if reviews is not None:
state["reviews"] = reviews
return state
# ---- build_review_context确定性、含草稿+材料 ----
def test_build_review_context_is_deterministic_and_contains_parts() -> None:
ctx1 = build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE)
ctx2 = build_review_context(draft=DRAFT, stable_core=STABLE, volatile=VOLATILE)
assert ctx1 == ctx2 # 无时间戳/随机 → 同输入同输出
assert DRAFT in ctx1
assert STABLE in ctx1
assert VOLATILE in ctx1
# ---- build_review_requesttier 不变量、缓存断点、output_schema、run 非 stream ----
def test_build_review_request_uses_spec_tier_and_cached_prompt() -> None:
req = build_review_request(continuity_spec, _state())
assert req.tier == "analyst" # 不变量②spec 档位透传,不传 model
assert req.stream is False # 续审用 run() 非 stream()
assert len(req.system) == 1
assert req.system[0].text == continuity_spec.system_prompt
assert req.system[0].cache is True # 不变量#9
assert req.output_schema is ContinuityReview
assert req.scope.user_id == USER
assert req.scope.project_id == PROJECT
# ---- run_review结构化结果回 state、只读不写库 ----
async def test_run_review_returns_ok_with_parsed_result() -> None:
gateway = FakeRunGateway(ContinuityReview(conflicts=[_CONFLICT]))
result = await run_review(continuity_spec, _state(), gateway=gateway)
assert gateway.call_count == 1
entry = result["reviews"][CONTINUITY]
assert entry["status"] == REVIEW_OK
assert entry["result"]["conflicts"][0]["type"] == "能力不符"
async def test_run_review_isolates_gateway_failure_as_incomplete() -> None:
gateway = FailingRunGateway(AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
result = await run_review(continuity_spec, _state(), gateway=gateway)
entry = result["reviews"][CONTINUITY]
assert entry["status"] == REVIEW_INCOMPLETE # §5.2:失败隔离、不上抛
assert entry["result"] is None
async def test_make_review_node_binds_gateway() -> None:
gateway = FakeRunGateway(ContinuityReview(conflicts=[]))
node = make_review_node(continuity_spec, gateway)
result = await node(_state())
assert result["reviews"][CONTINUITY]["status"] == REVIEW_OK
# ---- collect抽冲突 + 落 chapter_reviews 留痕(只 flush不在节点 commit ----
def test_extract_conflicts_only_from_ok_continuity() -> None:
ok = {CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}}
incomplete = {CONTINUITY: {"status": REVIEW_INCOMPLETE, "result": None}}
assert len(extract_conflicts(ok)) == 1
assert extract_conflicts(incomplete) == []
assert extract_conflicts({}) == []
async def test_collect_records_conflicts_to_review_repo() -> None:
repo = FakeReviewRepo()
reviews = {CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}}
out = await collect_reviews(_state(reviews), review_repo=repo)
assert out == {} # 不作真相源:留痕在表(不变量#5
assert len(repo.records) == 1
rec = repo.records[0]
assert rec["project_id"] == PROJECT
assert rec["chapter_no"] == 7
assert rec["chapter_version"] is None # version 绑定/裁决归 T2.4
assert rec["conflicts"][0]["type"] == "能力不符"
async def test_collect_records_empty_when_review_incomplete() -> None:
repo = FakeReviewRepo()
reviews = {CONTINUITY: {"status": REVIEW_INCOMPLETE, "result": None}}
await collect_reviews(_state(reviews), review_repo=repo)
assert repo.records[0]["conflicts"] == [] # 未完成 → 不臆造冲突
# ---- 图START→continuity→collect→END全程 mockMemorySaver ----
def test_build_review_graph_compiles_with_nodes() -> None:
graph = build_review_graph(
FakeRunGateway(ContinuityReview(conflicts=[])),
FakeReviewRepo(),
checkpointer=MemorySaver(),
)
nodes = graph.get_graph().nodes
assert CONTINUITY in nodes
assert "collect" in nodes
assert graph.checkpointer is not None
async def test_review_graph_runs_continuity_then_collect_end_to_end() -> None:
gateway = FakeRunGateway(ContinuityReview(conflicts=[_CONFLICT]))
repo = FakeReviewRepo()
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "rev-1"}}
final = await graph.ainvoke(_state(), config=config)
assert final["reviews"][CONTINUITY]["status"] == REVIEW_OK
assert len(repo.records) == 1 # collect 落了留痕
assert repo.records[0]["conflicts"][0]["type"] == "能力不符"
async def test_review_graph_does_not_block_on_review_failure() -> None:
gateway = FailingRunGateway(RuntimeError("down"))
repo = FakeReviewRepo()
graph = build_review_graph(gateway, repo, checkpointer=MemorySaver())
config: RunnableConfig = {"configurable": {"thread_id": "rev-2"}}
final = await graph.ainvoke(_state(), config=config)
# 审失败被隔离为 incomplete图照常走到 collect 并留痕(空冲突)
assert final["reviews"][CONTINUITY]["status"] == REVIEW_INCOMPLETE
assert repo.records[0]["conflicts"] == []
# ---- review SSE 归一section + conflict + doneincomplete 路径异常→error ----
async def test_normalize_review_emits_section_conflict_done() -> None:
reviews = {CONTINUITY: {"status": REVIEW_OK, "result": {"conflicts": [_CONFLICT.model_dump()]}}}
events = [e async for e in normalize_review(reviews)]
assert [e.event for e in events] == [EVENT_SECTION, EVENT_CONFLICT, EVENT_DONE]
assert events[0].data == {"name": CONTINUITY, "status": SECTION_DONE}
assert events[1].data["type"] == "能力不符"
assert events[1].data["refs"] == ["人物卡:主角:力量上限=裂石"]
assert events[-1].data == {"length": 1}
async def test_normalize_review_marks_incomplete_section_without_conflicts() -> None:
reviews = {CONTINUITY: {"status": REVIEW_INCOMPLETE, "result": None}}
events = [e async for e in normalize_review(reviews)]
assert [e.event for e in events] == [EVENT_SECTION, EVENT_DONE]
assert events[0].data == {"name": CONTINUITY, "status": SECTION_INCOMPLETE}
async def test_normalize_review_maps_unexpected_error_to_error_event() -> None:
# 畸形 entryresult 非 dict触发归一内部异常 → error 事件,不上抛
reviews = {CONTINUITY: {"status": REVIEW_OK, "result": 123}}
events = [e async for e in normalize_review(reviews, request_id="req-9")]
assert events[-1].event == EVENT_ERROR
assert events[-1].data["code"] == ErrorCode.INTERNAL
assert events[-1].data["request_id"] == "req-9"

View File

@@ -0,0 +1,130 @@
"""T2.3 审稿留痕 repo 单测ARCH §5.4/§5.5)。
纯内存 fake无 DB断言 record 写入字段、list 历史新→旧、set_decisions 裁决留痕,
均返回不可变 frozen View按 project_id 隔离。
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
import pytest
from pydantic import ValidationError
from ww_core.domain.review_repo import ReviewRepo, ReviewView
PROJECT = uuid.UUID("00000000-0000-0000-0000-000000000001")
OTHER = uuid.UUID("00000000-0000-0000-0000-000000000002")
@dataclass
class FakeReviewRepo:
"""内存替身——镜像 SqlReviewRepo 语义append 每次审稿;裁决就地补 decisions"""
rows: list[ReviewView] = field(default_factory=list)
_seq: int = 0
async def record(
self,
project_id: uuid.UUID,
chapter_no: int,
*,
chapter_version: int | None = None,
conflicts: list[dict[str, object]],
foreshadow_sug: list[dict[str, object]] | None = None,
style: dict[str, object] | None = None,
pace: dict[str, object] | None = None,
health_score: int | None = None,
) -> ReviewView:
self._seq += 1
view = ReviewView(
id=uuid.UUID(int=self._seq),
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.rows.append(view)
return view
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
matched = [
r for r in self.rows if r.project_id == project_id and r.chapter_no == chapter_no
]
return list(reversed(matched)) # 新→旧
async def set_decisions(
self, review_id: uuid.UUID, *, decisions: dict[str, object]
) -> ReviewView:
idx, old = next((i, r) for i, r in enumerate(self.rows) if r.id == review_id)
updated = old.model_copy(update={"decisions": dict(decisions)})
self.rows[idx] = updated
return updated
def _repo() -> FakeReviewRepo:
return FakeReviewRepo()
async def test_record_persists_review_fields() -> None:
repo: ReviewRepo = _repo()
view = await repo.record(
PROJECT,
5,
chapter_version=2,
conflicts=[{"type": "性格漂移", "where": "p3"}],
health_score=80,
)
assert isinstance(view, ReviewView)
assert view.chapter_no == 5
assert view.chapter_version == 2
assert view.conflicts == [{"type": "性格漂移", "where": "p3"}]
assert view.health_score == 80
assert view.decisions is None
async def test_record_defaults_empty_collections() -> None:
repo: ReviewRepo = _repo()
view = await repo.record(PROJECT, 1, conflicts=[])
assert view.foreshadow_sug == []
assert view.style is None
assert view.pace is None
assert view.chapter_version is None
async def test_list_for_chapter_newest_first() -> None:
repo: ReviewRepo = _repo()
await repo.record(PROJECT, 7, conflicts=[{"n": 1}])
await repo.record(PROJECT, 7, conflicts=[{"n": 2}])
history = await repo.list_for_chapter(PROJECT, 7)
assert [h.conflicts[0]["n"] for h in history] == [2, 1] # 新→旧
async def test_list_for_chapter_isolated_by_project() -> None:
repo: ReviewRepo = _repo()
await repo.record(PROJECT, 1, conflicts=[])
await repo.record(OTHER, 1, conflicts=[])
history = await repo.list_for_chapter(PROJECT, 1)
assert len(history) == 1
assert all(h.project_id == PROJECT for h in history)
async def test_set_decisions_records_adjudication() -> None:
repo: ReviewRepo = _repo()
rec = await repo.record(PROJECT, 3, conflicts=[{"type": "设定违例"}])
decisions = {"c0": "采纳", "c1": "忽略"}
updated = await repo.set_decisions(rec.id, decisions=decisions)
assert updated.id == rec.id
assert updated.decisions == decisions
async def test_view_is_frozen() -> None:
repo: ReviewRepo = _repo()
view = await repo.record(PROJECT, 1, conflicts=[])
with pytest.raises(ValidationError):
view.health_score = 50

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

473
tests/test_m2_e2e.py Normal file
View File

@@ -0,0 +1,473 @@
"""M2 端到端:写 → 审(一致性) → 裁决 → 验收(事务) → 摘要入库(真实 DB零 token
证明 M2 闭环(不变量 #3/#4/#5
`POST /projects` → `POST .../draft`(SSE) → `PUT .../draft`(自动保存)
→ `POST .../review`(SSE产 ≥1 冲突) → `GET .../reviews`(留痕)
→ `POST .../accept`(**残缺裁决 → 409 CONFLICT_UNRESOLVED未写库**)
→ `POST .../accept`(**全量裁决 + 终稿 → 200**)
且 DB 为唯一真源:`chapters`(草稿+accepted 多版本) / `chapter_digests`(从**终稿**提炼) /
`chapter_reviews`(裁决留痕) / `usage_ledger`(写/审/digest 三档位记账各落库)。
确定性 & 零成本(同 M1真实 `Gateway` + 假适配器(吐固定 `Delta`/`parsed`,绝不联网)
+ 真实 `SqlAlchemyLedgerSink`(用量记账闭环也被走通)。无 pg → skip。
M2 多档位 + 结构化输出:假适配器据 `req.output_schema` 分支——
- writer/stream → 固定 token
- analyst/run`ContinuityReview`)→ 故意产 ≥1 `Conflict`(测裁决 + gate
- light/run`ChapterDigestFacts`)→ facts 内容可追溯到**终稿**(验证 digest 从终稿非草稿,#4
坑(见 memory/gotchas
- `LifespanManager` 触发 lifespan → `seed_stub_user`owner_id FK 依赖它)。
- `get_sessionmaker` engine 绑定首个 loop → 每 DB 测试清缓存重建、结束 dispose。
- 网关 ledger / 验收-side repos 只 flush端点/验收事务 commit → usage_ledger/留痕方落库。
- ASGITransport `raise_app_exceptions=True`gate 409 是 `AppError` → 正常返回信封(不上抛)。
"""
from __future__ import annotations
import json
import uuid
from collections.abc import AsyncIterator, Callable
from typing import Annotated
import httpx
import pytest
from asgi_lifespan import LifespanManager
from fastapi import Depends
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from ww_agents import Conflict, ContinuityReview
from ww_api.services.digest_extraction import ChapterDigestFacts
from ww_db import get_session, get_sessionmaker
from ww_db.models import (
Chapter,
ChapterDigest,
ChapterReview,
Project,
UsageLedger,
)
from ww_llm_gateway import (
Gateway,
SqlAlchemyLedgerSink,
resolve_route,
)
from ww_llm_gateway.adapters.base import (
Capabilities,
ProviderResult,
ProviderUsage,
StreamChunk,
)
from ww_llm_gateway.types import LlmRequest
# 三档位writer/analyst/light默认都路由到 deepseekconfig.tier_defaults
_PROVIDER = "deepseek"
# 确定性流式写章 token。
_TOKENS = ["", "", "", "", "试炼之始。"]
# 续审故意产的固定冲突≥1 → 触发裁决 + gate
_CONFLICT = Conflict(
type="设定违例",
where="第3段主角施展禁术",
refs=["世界观·禁术条目", "第1章"],
suggestion="改为消耗灵力的普通法术,符合前文设定",
)
# 终稿里可被 digest 追溯的标记串(验证 digest 从终稿提炼,不变量 #4
_FINAL_MARKER = "终稿独有事件:主角在第十章觉醒血脉"
# 各档位假用量(喂记账;证明三档位用量闭环各自落库)。
_USAGE = {
"writer": ProviderUsage(input_tokens=11, output_tokens=5),
"analyst": ProviderUsage(input_tokens=23, output_tokens=7),
"light": ProviderUsage(input_tokens=17, output_tokens=9),
}
class _FakeMultiTierAdapter:
"""实现 `ProviderAdapter` Protocol按调用形态/`output_schema` 分支,绝不联网。
- `stream()`:吐固定写章 tokenwriter/draft
- `complete()`:据 `req.output_schema` 返回对应 `parsed`
`ContinuityReview`(续审,产固定冲突)/ `ChapterDigestFacts`digest追溯终稿
每路均带 `ProviderUsage` → 网关据此各落一条 usage_ledger。
"""
provider = _PROVIDER
def __init__(self, *, conflicts: list[Conflict] | None = None) -> None:
# 可配冲突数(主用例 ≥1零冲突直通用例传 [])。
self._conflicts = [_CONFLICT] if conflicts is None else conflicts
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True)
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
if req.output_schema is ContinuityReview:
review = ContinuityReview(conflicts=list(self._conflicts))
return ProviderResult(
text=review.model_dump_json(),
parsed=review,
usage=_USAGE["analyst"],
)
# 否则视为 digest 提炼ChapterDigestFacts把终稿标记塞进 summary →
# 断言 digest 来自终稿(非审稿草稿,不变量 #4
facts = ChapterDigestFacts(
summary=_FINAL_MARKER,
events=["主角觉醒"],
locations=["试炼之地"],
)
return ProviderResult(
text=facts.model_dump_json(),
parsed=facts,
usage=_USAGE["light"],
)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
for token in _TOKENS:
yield StreamChunk(text=token)
yield StreamChunk(usage=_USAGE["writer"])
@pytest.fixture
async def e2e_sm() -> AsyncIterator[async_sessionmaker[AsyncSession]]:
"""真实 DB session 工厂;无 pg 时跳过(每测试清缓存重建 engine、结束 dispose"""
get_sessionmaker.cache_clear()
maker = get_sessionmaker()
try:
async with maker() as probe:
await probe.execute(select(1))
except Exception:
pytest.skip("postgres not reachable")
yield maker
await maker.kw["bind"].dispose()
get_sessionmaker.cache_clear()
def _parse_sse(raw: str) -> list[tuple[str, str]]:
"""把 text/event-stream 原文解析为 `(event, data)` 帧列表。"""
frames: list[tuple[str, str]] = []
event: str | None = None
data: str | None = None
for line in raw.splitlines():
if line.startswith("event:"):
event = line[len("event:") :].strip()
elif line.startswith("data:"):
data = line[len("data:") :].strip()
elif line == "":
if event is not None and data is not None:
frames.append((event, data))
event, data = None, None
if event is not None and data is not None:
frames.append((event, data))
return frames
def _gateway_override_factory(
adapter: _FakeMultiTierAdapter,
) -> Callable[[AsyncSession], Gateway]:
"""构造一个依赖覆盖:真实 Gateway + 给定假适配器 + 真实 ledger请求 session
ledger 用**请求 session**`Depends(get_session)`FastAPI 按请求缓存,与端点/验收
事务同一实例)→ 由端点流末 / 验收事务的 `commit()` 落库(同 M1 回归校验)。
"""
def _override(session: Annotated[AsyncSession, Depends(get_session)]) -> Gateway:
return Gateway(
adapters={_PROVIDER: adapter},
ledger=SqlAlchemyLedgerSink(session),
resolver=resolve_route,
)
return _override
async def _cleanup(e2e_sm: async_sessionmaker[AsyncSession], project_uuid: uuid.UUID) -> None:
"""按 FK 顺序清理(无级联的先删,再删 projectchapters/digests/reviews 经 FK 级联)。"""
async with e2e_sm() as cleanup:
await cleanup.execute(delete(UsageLedger).where(UsageLedger.project_id == project_uuid))
await cleanup.execute(delete(ChapterDigest).where(ChapterDigest.project_id == project_uuid))
await cleanup.execute(delete(ChapterReview).where(ChapterReview.project_id == project_uuid))
await cleanup.execute(delete(Chapter).where(Chapter.project_id == project_uuid))
await cleanup.execute(delete(Project).where(Project.id == project_uuid))
await cleanup.commit()
async def test_m2_closed_loop_write_review_adjudicate_accept_digest(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_digest_gateway,
get_review_gateway,
get_writer_gateway,
)
adapter = _FakeMultiTierAdapter() # 默认产 1 个冲突
override = _gateway_override_factory(adapter)
app = create_app()
# 三档位网关均经 override 注入(真实 Gateway + 同一假适配器 + 真实 ledger
app.dependency_overrides[get_writer_gateway] = override
app.dependency_overrides[get_review_gateway] = override
app.dependency_overrides[get_digest_gateway] = override
transport = httpx.ASGITransport(app=app)
project_uuid: uuid.UUID | None = None
try:
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
# 1) 立项 → 201。
title = "M2 E2E 闭环验证作品"
create_resp = await client.post(
"/projects",
json={"title": title, "genre": "玄幻", "logline": "少年试炼"},
)
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
# 2) 流式写章草稿SSE→ 重组文本。
draft_resp = await client.post(f"/projects/{project_id}/chapters/1/draft")
assert draft_resp.status_code == 200
frames = _parse_sse(draft_resp.text)
token_frames = [d for (ev, d) in frames if ev == "token"]
assert [d for (ev, d) in frames if ev == "error"] == []
assert len([d for (ev, d) in frames if ev == "done"]) == 1
draft_text = "".join(json.loads(d)["text"] for d in token_frames)
assert draft_text == "".join(_TOKENS)
# 自动保存草稿version=1, status='draft')。
save_resp = await client.put(
f"/projects/{project_id}/chapters/1/draft",
json={"text": draft_text},
)
assert save_resp.status_code == 200
assert save_resp.json()["version"] == 1
assert save_resp.json()["status"] == "draft"
# 3) 续审SSEbody draft=当前草稿)→ ≥1 section(continuity,done) +
# ≥1 conflict + 1 done、无 error。
review_resp = await client.post(
f"/projects/{project_id}/chapters/1/review",
json={"draft": draft_text},
)
assert review_resp.status_code == 200
assert review_resp.headers["content-type"].startswith("text/event-stream")
rframes = _parse_sse(review_resp.text)
section_frames = [json.loads(d) for (ev, d) in rframes if ev == "section"]
conflict_frames = [json.loads(d) for (ev, d) in rframes if ev == "conflict"]
rdone = [d for (ev, d) in rframes if ev == "done"]
rerror = [d for (ev, d) in rframes if ev == "error"]
assert rerror == []
assert len(rdone) == 1
continuity_sections = [
s
for s in section_frames
if s.get("name") == "continuity" and s.get("status") == "done"
]
assert len(continuity_sections) >= 1
assert len(conflict_frames) >= 1
assert conflict_frames[0]["type"] == _CONFLICT.type
assert conflict_frames[0]["suggestion"] == _CONFLICT.suggestion
# 4) GET 历史含刚落的留痕conflicts 非空、decisions 为空)。
hist_resp = await client.get(f"/projects/{project_id}/chapters/1/reviews")
assert hist_resp.status_code == 200
reviews = hist_resp.json()["reviews"]
assert len(reviews) >= 1
latest = reviews[0]
assert len(latest["conflicts"]) >= 1
assert latest["decisions"] is None
# 5) 冲突 gate未决禁验收残缺裁决空 decisions缺 conflict_index=0
# → 409 CONFLICT_UNRESOLVEDdetails 命中缺项。
final_text = draft_text + "\n\n" + _FINAL_MARKER
gate_resp = await client.post(
f"/projects/{project_id}/chapters/1/accept",
json={"final_text": final_text, "decisions": []},
)
assert gate_resp.status_code == 409
gate_body = gate_resp.json()
assert gate_body["error"]["code"] == "CONFLICT_UNRESOLVED"
details = gate_body["error"]["details"]
assert 0 in details["missing_conflict_indices"]
assert details["conflict_count"] >= 1
# 6) 完整验收:全量裁决 + 终稿(与草稿不同,验证 #4→ 200。
accept_resp = await client.post(
f"/projects/{project_id}/chapters/1/accept",
json={
"final_text": final_text,
"decisions": [
{"conflict_index": 0, "verdict": "accept", "note": "采纳改法"}
],
},
)
assert accept_resp.status_code == 200
acc = accept_resp.json()
assert acc["accepted_version"] == 2 # 草稿 v1 + accepted v2
assert acc["digest_added"] is True
assert acc["decisions_recorded"] == 1
assert acc["review_id"] is not None
# 7) DB 真源断言(经 e2e session
assert project_uuid is not None
async with e2e_sm() as verify:
# chapters草稿 v1 仍在 + 新增 accepted v2content==final_text
chapter_rows = (
(
await verify.execute(
select(Chapter)
.where(Chapter.project_id == project_uuid)
.order_by(Chapter.version)
)
)
.scalars()
.all()
)
assert len(chapter_rows) == 2
draft_row, accepted_row = chapter_rows[0], chapter_rows[1]
assert draft_row.version == 1
assert draft_row.status == "draft"
assert draft_row.content == draft_text
assert accepted_row.version == 2
assert accepted_row.status == "accepted"
assert accepted_row.content == final_text
# chapter_digestsappend 一行facts 可追溯到**终稿**#4summary 含终稿标记)。
digest_rows = (
(
await verify.execute(
select(ChapterDigest).where(ChapterDigest.project_id == project_uuid)
)
)
.scalars()
.all()
)
assert len(digest_rows) == 1
digest_facts = digest_rows[0].facts
assert digest_facts["summary"] == _FINAL_MARKER
# 终稿标记不在草稿里 → 证明 digest 从终稿而非审稿草稿提炼。
assert _FINAL_MARKER not in draft_text
# chapter_reviews留痕行 decisions 已写(裁决留痕)。
review_rows = (
(
await verify.execute(
select(ChapterReview).where(ChapterReview.project_id == project_uuid)
)
)
.scalars()
.all()
)
assert len(review_rows) == 1
assert len(review_rows[0].conflicts) >= 1
decisions = review_rows[0].decisions
assert decisions is not None
assert decisions["items"][0]["conflict_index"] == 0
assert decisions["items"][0]["verdict"] == "accept"
# usage_ledger写章 + 续审 + digest 提炼三档位各记一条≥3
ledger_rows = (
(
await verify.execute(
select(UsageLedger).where(UsageLedger.project_id == project_uuid)
)
)
.scalars()
.all()
)
assert len(ledger_rows) >= 3
# 三档位 input_tokens 各异 → 验证各档位调用都真落库(事务已 commit
input_token_set = {r.input_tokens for r in ledger_rows}
assert _USAGE["writer"].input_tokens in input_token_set
assert _USAGE["analyst"].input_tokens in input_token_set
assert _USAGE["light"].input_tokens in input_token_set
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)
async def test_m2_zero_conflict_accept_passes_gate_directly(
e2e_sm: async_sessionmaker[AsyncSession],
) -> None:
"""零冲突直通:续审产 conflicts=[] → 空裁决 accept 直接 200gate 直通)。"""
from ww_api.main import create_app
from ww_api.services.project_deps import (
get_digest_gateway,
get_review_gateway,
get_writer_gateway,
)
adapter = _FakeMultiTierAdapter(conflicts=[]) # 零冲突路径
override = _gateway_override_factory(adapter)
app = create_app()
app.dependency_overrides[get_writer_gateway] = override
app.dependency_overrides[get_review_gateway] = override
app.dependency_overrides[get_digest_gateway] = override
transport = httpx.ASGITransport(app=app)
project_uuid: uuid.UUID | None = None
try:
async with LifespanManager(app):
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
create_resp = await client.post("/projects", json={"title": "M2 零冲突直通作品"})
assert create_resp.status_code == 201
project_id = create_resp.json()["id"]
project_uuid = uuid.UUID(project_id)
draft_resp = await client.post(f"/projects/{project_id}/chapters/1/draft")
draft_text = "".join(
json.loads(d)["text"]
for (ev, d) in _parse_sse(draft_resp.text)
if ev == "token"
)
await client.put(
f"/projects/{project_id}/chapters/1/draft",
json={"text": draft_text},
)
# 续审产零冲突 → 留痕 conflicts=[]。
review_resp = await client.post(
f"/projects/{project_id}/chapters/1/review",
json={"draft": draft_text},
)
assert review_resp.status_code == 200
rframes = _parse_sse(review_resp.text)
assert [d for (ev, d) in rframes if ev == "conflict"] == []
# 空裁决 accept → gate 直通 200。
final_text = draft_text + "\n" + _FINAL_MARKER
accept_resp = await client.post(
f"/projects/{project_id}/chapters/1/accept",
json={"final_text": final_text, "decisions": []},
)
assert accept_resp.status_code == 200
acc = accept_resp.json()
assert acc["accepted_version"] == 2
assert acc["digest_added"] is True
assert acc["decisions_recorded"] == 0
# DB 断言accepted 行落库、digest 从终稿。
assert project_uuid is not None
async with e2e_sm() as verify:
accepted = (
await verify.execute(
select(Chapter).where(
Chapter.project_id == project_uuid,
Chapter.status == "accepted",
)
)
).scalar_one()
assert accepted.content == final_text
digest = (
await verify.execute(
select(ChapterDigest).where(ChapterDigest.project_id == project_uuid)
)
).scalar_one()
assert digest.facts["summary"] == _FINAL_MARKER
finally:
if project_uuid is not None:
await _cleanup(e2e_sm, project_uuid)