diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..c3c526d --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,72 @@ +# PROGRESS — 多 Agent 协同任务台账 + +> 多 agent 共享的**唯一协同入口**。开工前读这里 + `memory/`;认领任务、尊重依赖、完成即更新。 +> 完整任务定义见 `DEV_PLAN.md`;本台账只展开**当前阶段**,进入新阶段时再把该阶段任务拉进来。 +> 协同规则见 `CLAUDE.md` →「多 Agent 协同」。 + +**状态图例**:⬜ 待办 · 🔵 进行中(注明 @skill + 日期) · ✅ 完成 · ⛔ 阻塞(注明等什么) + +--- + +## 当前阶段:Phase 2 (M2) · 一致性 + 验收 + +> 目标:写 → 审(一致性) → 裁决 → 验收(事务写回);**未决冲突禁验收**。**契约先行**:C6 续审 AgentSpec + 结构化输出 schema → C4 扩(并行审图/SSE) → C3 扩(review/accept/reviews)。 +> 执行波次(依赖驱动):A(T2.1‖T2.3) → B(T2.2) → C(T2.4+T2.5) → D(T2.6) → E(T2.7)。 +> 计划已定的设计决策(见变更日志 + `memory/decisions.md` 待 Wave A 落地后回写): +> **R2** digest 提炼在事务外做、原子事务只写库(不在持开事务里跨网络调 LLM); +> **R3** accept 为确定性事务代码、读领域表(`chapters`/`chapter_reviews`)重建真相,checkpoint 仅控制流句柄(不变量 #5); +> **R4** accept 取该章 `max(version)+1` 晋升 `status='accepted'`,草稿行保留; +> **R5** 裁决清单每个 conflict 必须 采纳/忽略/手改 之一,缺判决 → `CONFLICT_UNRESOLVED`。 + +| 任务 | 状态 | 负责 | 依赖 | 契约 | 备注 | +|---|---|---|---|---|---| +| T2.1 续审 AgentSpec + 结构化输出契约 + 网关 instructor 接线 | ✅ @llm 2026-06-18 | @llm | T1.1✅ | C6✅稳定 | `packages/agents/`+`packages/llm_gateway/`;`ContinuityReview/Conflict`(五类枚举)+`continuity_spec`(只读);`Gateway.run` 经 instructor 填 `parsed`(改 C1);25 tests 绿 | +| T2.2 LangGraph 并行审 + collect 留痕 + review SSE | ✅ @llm 2026-06-18 | @llm | T1.3✅, T2.1✅ | C4✅扩稳定 | `orchestrator/`:`build_review_graph`(continuity 并行,可扩四审)+`collect_reviews`落 `chapter_reviews`+`normalize_review`(section/conflict);accept 接线留 T2.4;15 新测;只 flush 不 commit | +| T2.3 摘要/审稿留痕 Repository + 章节 version | ✅ @backend 2026-06-18 | @backend | T0.2✅ | — | `core/ww_core/domain/`:`chapter_repo`(promote_to_accepted/max_version/latest_accepted)+`digest_repo`(append-only)+`review_repo`(record/list/set_decisions);纯 fake 17 新测;只 flush 不 commit(交 T2.4 事务);唯一约束 DB 级→T2.7 E2E 覆盖 | +| T2.4 验收事务 + 冲突 gate | ✅ @backend 2026-06-18 | @backend | T2.2✅, T2.3✅ | C3✅扩 | `accept_service`(冲突 gate+R3/R4 单事务)+`digest_extraction`(R2 事务外提炼,light);`POST .../accept`;缺判→409 `CONFLICT_UNRESOLVED`+missing_indices | +| T2.5 review API + 历史 | ✅ @backend 2026-06-18 | @backend | T2.2✅ | C3✅扩 | `POST .../review`(SSE section/conflict/done)+`GET .../reviews`(新→旧);analyst 网关;流耗尽 commit;+9 测全仓 112 passed | +| T2.6 审稿报告页 + 冲突裁决 + 验收 gate | ✅ @frontend 2026-06-18 | @frontend | T2.4✅, T2.5✅ | — | `app/projects/[id]/review`+`components/review/*`+`lib/review/*`;gen:api 纳三端点;就地标注(R6 段级锚点)+裁决+「将更新」清单+未决禁验收;vitest 45(+21) | +| T2.7 M2 E2E | ✅ @qa 2026-06-18 | @qa | T2.6✅ | — | `tests/test_m2_e2e.py` 2 用例真跑过(非 skip);闭环 + 零冲突直通;DB 真源断言(accepted v2 / digest 来自终稿#4 / 裁决留痕 / 三档位 ledger);无新 bug | + +**M2 出口(DoD)✅ 全部达成**:写→审(一致性)→裁决→验收(事务);未决冲突禁验收。后端门禁绿(ruff / mypy **90 文件** / pytest **114 passed** / alembic **无漂移**);前端门禁绿(gen:api / lint / tsc / vitest **45** / build);E2E 真实 DB + mock 网关零 token 走通 写→审→裁决→验收→摘要入库,DB 真源逐项断言。**M2 完成。** 下一阶段 M3(伏笔 + 节奏)见 DEV_PLAN,进入时拉入本表。 + +--- + +## 已归档阶段 + +
Phase 1 · 写章闭环骨架 (M1) — ✅ 全部完成(2026-06-18) + +T1.1 LLM 网关接口+DeepSeek 适配器+usage_ledger 记账(C1✅稳定)@llm · T1.2 记忆 assemble 确定性选择(C5✅稳定)@backend · T1.3 LangGraph 写章节点+Postgres checkpointer+SSE(C4✅稳定)@llm · T1.4 立项+写章 draft(SSE)+PUT 自动保存(C3✅稳定)@backend · T1.5 AppShell+作品库+5 步立项向导 @frontend · T1.6 写作工作台(三栏+流式打字机+注入透明面板+自动保存)@frontend · T1.7 提供商凭据管理(Fernet 加密+测试连接)@backend · T1.8 设置页(档位路由+凭据行)@frontend · T1.9 M1 E2E(真实 DB+mock 网关零 token;暴露并修复 ledger 提交 bug)@qa。 +出口达成:后端门禁绿(ruff / mypy 68 / pytest 57 / alembic 无漂移);前端门禁绿(gen:api / lint / tsc / vitest 24 / build);E2E 闭环走通。 +M2 起可补的 M1 占位:本章助手/注入透明面板、向导主角/世界观字段、档位路由可写。 + +
+ +
Phase 0 · 基建与栈修订 — ✅ 全部完成(2026-06-18) + +T0.1 monorepo 骨架 ✅ @devops · T0.2 16 MVP 表迁移(无漂移,users stub)✅ @db · T0.3 FastAPI 骨架 + config + 错误信封(带 request_id) + OpenAPI + `GET /jobs/:id` + structlog ✅ @backend · T0.4 前端骨架 + 纸感 token + OpenAPI→TS gen:api ✅ @frontend · T0.5 CI(含 pg service + alembic 漂移校验)✅ @devops · T0.6 架构栈修订回写规格 ✅ @docs。 +出口达成:backend 门禁绿、frontend 门禁绿、API 实跑 200 带 `x-request-id`。 + +
+ +--- + +## 变更日志(append-only,最新在最上) + +> 格式:`- [YYYY-MM-DD] @skill 完成/进展 Txx — 一句话结果 + 影响的契约/文件` + +- [2026-06-18] @orchestrator — **🎉 M2 完成**(Wave E:T2.7✅)。`tests/test_m2_e2e.py` 真实 pg + 多档位 mock 网关(零 token)真跑通过 2 用例:主用例串 立项→写章 SSE→自动保存→**审稿 SSE(section/conflict)**→GET 历史→**残缺裁决触发 409 `CONFLICT_UNRESOLVED`+missing_indices(gate 事务前拦截未写库)**→全量裁决+终稿验收(200, accepted v2)→**DB 真源逐项断言**:chapters draft v1 留存 + accepted v2(content==final_text)、`chapter_digests.facts` 追溯终稿标记(证 #4 digest 从终稿非草稿)、`chapter_reviews.decisions` 写回、`usage_ledger` 三档位(writer/analyst/light)各落一条且事务已 commit;补充用例验证零冲突直通。**无新 bug**(M1 曾暴露的 ledger 提交 bug 在 M2 三档位无复发——review 流末 + accept 事务末均 commit)。最终全收口门禁绿:ruff/format 干净、mypy **90 文件**、pytest **114 passed**、**alembic 无漂移**(M2 全程零建表迁移)。M2 出口 DoD 全达成。下一阶段 **M3(伏笔+节奏)**。 +- [2026-06-18] @orchestrator — **Wave D 完成**(T2.6✅)。审稿报告页落地:`app/projects/[id]/review`(RSC 取历史)+ `components/review/*`(报告卡/就地标注/AcceptPanel)+ `lib/review/*`(SSE reducer/裁决完整性/请求体组装,纯逻辑单测)。`pnpm gen:api` 纳入 review/accept/reviews 三端点类型(不手写);SSE 沿用 `useDraftStream` 的 fetch+ReadableStream 范式;五类冲突就地朱砂锚点(R6 段级降级)+裁决(采纳/忽略/手改)+**未决禁验收 gate**+409 missing_indices 高亮+「本次将更新」清单。前端门禁绿:gen:api/lint/tsc/**vitest 45(+21)**/build。memory +1 decision +2 gotchas。下一步 **Wave E:T2.7 M2 E2E**(@qa 真实 DB + mock 网关零 token)。 +- [2026-06-18] @orchestrator — **Wave C 完成**(T2.4✅ + T2.5✅,C3 扩稳定,单 @backend agent 顺序做)。三端点入 OpenAPI:`POST .../review`(SSE section/conflict/done) + `GET .../reviews`(新→旧) + `POST .../accept`。验收:冲突 gate(裁决 `conflict_index` 须覆盖 review.conflicts 全下标,缺判→409 `CONFLICT_UNRESOLVED`+missing_indices)→ 终稿 digest 提炼在事务外(R2,light 档)→ 单原子事务 promote(R4)+digest.append(#4)+set_decisions、末尾一次 commit、失败全回滚(R3)。`build_gateway_for_tier` 统一多档位网关注入。全仓门禁绿:ruff/format 干净、mypy **83 文件**、pytest **112 passed**(+9)。memory 登记 C3 扩 + 1 decision + 2 gotchas。下一步 **Wave D:T2.6**(@frontend 审稿页+裁决+验收 gate,先 `pnpm gen:api`)。 +- [2026-06-18] @orchestrator — **Wave B 完成**(T2.2✅,C4 扩稳定)。审稿子图落地:`build_review_graph`(START→continuity 并行审→collect→END,可扩四审) + `collect_reviews` 落 `chapter_reviews` 留痕 + `normalize_review`(SSE 加 `section`/`conflict`);M1 `build_write_graph` 不动。划清边界:本任务到 collect+归一缝为止,`interrupt`/accept 事务接线交 T2.4(对齐 R3:accept 从领域表重读、checkpoint 仅控制流)。全仓门禁绿:ruff/format 干净、mypy **80 文件**、pytest **103 passed**。memory 登记 C4 扩 + 3 gotchas(reducer/add_node 重载/失败隔离两层)。下一步 **Wave C:T2.4+T2.5**(单 @backend agent 顺序做:验收事务+冲突 gate / review API+历史,避免 apps/api 路由+OpenAPI 写冲突)。 +- [2026-06-18] @orchestrator — **Wave A 完成**(T2.1✅ C6 稳定 + T2.3✅)。两个 fresh agent 并行(@llm packages/agents+llm_gateway ‖ @backend core/domain,目录不相交无冲突)。**补上 M1 遗留网关缺口**:`Gateway.run()` 经 instructor 接通 `output_schema→LlmResponse.parsed`(改 C1,见 contracts 变更日志)。T2.3 写侧三 repo(章节 accepted 晋升/digest append/审稿留痕+裁决)只 flush 不 commit、交 T2.4 事务。全仓 compose 门禁绿:ruff 干净、format 干净、mypy **77 文件**、pytest **89 passed**。memory 已登记 C6+C1 变更+2 decisions+3 gotchas。下一步 **Wave B:T2.2**(@llm 并行审图+collect 留痕+review SSE,依赖 C6+C4)。 +- [2026-06-18] @orchestrator — **进入 M2**:归档 Phase 1(M1) 至已归档;Phase 2 七任务拉入台账。计划核对两处偏差并据实修正:(1) **T2.3「Repository 逻辑」落 `packages/core/ww_core/domain/`(@backend owned)而非 `packages/db`**——DB 全 MVP 表 + 唯一约束 `(project_id,chapter_no,version)` 已在 T0.2(@db) 一次建齐、M2 无新建表迁移,故 T2.3 由 @backend 承接 Repository 逻辑;(2) **网关结构化输出缺口**:`Gateway.run()` 当前不消费 `output_schema`/不填 `LlmResponse.parsed`(instructor 未接线),续审依赖此,归入 T2.1(@llm)。启动 **Wave A:T2.1(@llm·packages/agents+llm_gateway) ‖ T2.3(@backend·core/domain)**(不同目录,无写冲突)。 +- [2026-06-18] @orchestrator — **🎉 M1 完成**(Wave E:T1.9 E2E✅)。真实 DB + mock 网关 E2E 走通 立项→写章 SSE→自动保存→DB 真源断言(projects/chapters/usage_ledger)。**E2E 暴露并修复一个生产 bug**:draft SSE 端点未提交注入 ledger 的请求 session(`SqlAlchemyLedgerSink` 按设计只 flush、不 commit——写库事务归编排层),导致真实写章的 `usage_ledger` 行被回滚丢失(违背「成本账本从第一天」)。修复:draft 端点流耗尽后 `await session.commit()`;E2E 改用请求 session 做 ledger、去掉手动 commit 以回归校验该提交。最终全门禁绿:ruff / mypy 68 / pytest **57** / alembic 无漂移;前端 gen:api/lint/tsc/vitest 24/build 绿。 +- [2026-06-18] @orchestrator — **Wave D 完成**(T1.5+T1.6+T1.8✅,单前端 agent)。`pnpm gen:api` 重生成客户端含全 M1 端点;路由 `/`(作品库)、`/projects/new`(5 步向导)、`/projects/[id]/write`(工作台 SSE 打字机+停+自动保存)、`/settings/providers`(脱敏+测试连接+能力徽标)。前端门禁全绿:gen:api/lint/tsc/**test 24**/build。M1 占位(无 API 支撑、未造假端点):单章导航、本章助手/注入透明面板(M2)、向导主角/世界观字段折叠进 premise、档位路由只读。下一步 Wave E:T1.9 E2E(@qa, mock 网关)。 +- [2026-06-18] @orchestrator — **Wave C 完成**(T1.4✅ 立项+写章 API,**C3 稳定**:M1 端点全落)。记忆→网关→编排器串成 HTTP:5 端点入 OpenAPI,写章 SSE(token/done/error) + 幂等自动保存,lifespan 幂等 seed stub user(修 owner_id FK 缺口)。网关从凭据构建、测试经 dependency_overrides 注 mock。全仓 compose 绿:ruff、mypy **68 文件**、pytest **56 passed**。后端/编排全部完成。下一步 Wave D:单个前端 agent 做 T1.5+T1.6+T1.8(同一 Next 应用合并做,先 `pnpm gen:api`),避免 apps/web 写冲突。 +- [2026-06-18] @orchestrator — **Wave B 完成**(T1.3✅ 编排器/C4稳定 + T1.7✅ 凭据/C3部分)。两个 fresh agent 并行(各 ~90–98k tokens,比 fork 省)。全仓 compose 门禁绿:ruff、mypy **61 文件**、pytest **48 passed**。修了跨包测试碰撞(apps/api `fakes.py`→`fakes_providers.py`;root mypy 排除 `conftest.py` 解决多 rootless conftest 同名)。⚠️ **Wave C 前置**:DB 无 stub user,而 projects/usage_ledger/provider_credentials.owner_id 均 FK→users.id;T1.4 须先幂等 seed stub user(`uuid(int=1)`)。下一步 Wave C:T1.4(@backend 立项+写章 SSE+自动保存,依赖 C4)。 +- [2026-06-18] @orchestrator — **Wave A 完成**(T1.1✅ + T1.2✅,契约 C1/C5 稳定)。全仓 compose 门禁绿:ruff 干净、mypy 45 文件、pytest **27 passed**(9 基线 +10 网关 +8 记忆)。T1.1 网关由 orchestrator 内联实现(原 fork 因 stream idle timeout 早夭、零产出);T1.2 记忆由 fork 完成。下一步 Wave B:T1.3(@llm 写章节点+SSE,依赖 C1+C5) + T1.7(@backend 凭据,依赖 C1)。 +- [2026-06-18] @orchestrator — **进入 M1**:把 Phase 1 九任务拉入台账并归档 Phase 0;新增 workspace 成员 `packages/llm_gateway`(ww-llm-gateway) + `packages/core`(ww-core),装 openai/instructor/langgraph/langgraph-checkpoint-postgres/cryptography/tenacity,根 `pyproject.toml` members+mypy_path 已更,`uv sync` 通过,基线门禁全绿(ruff/mypy 27 文件/pytest 9 passed)。记录 decision:`assemble` 返回中性文本以解耦 T1.1‖T1.2。启动 Wave A(T1.1 @llm + T1.2 @backend)。 +- [2026-06-18] @devops/@db/@backend/@frontend — **Phase 0 全部完成**:monorepo(uv workspace + pnpm)、16 MVP 表迁移(无漂移)、FastAPI(structlog+request_id+错误信封+/jobs/:id)、Next.js(纸感 token + OpenAPI→TS gen:api)、CI。门禁全绿,API 实跑 200。下一步 M1/T1.1(网关接口)。 +- [2026-06-18] @docs — DEV_PLAN 与规格对齐修订:T0.3 加可观测性底座(structlog+request_id)、T1.1 加 `usage_ledger` 记账(从第一天)、T0.2/T2.3 统一建表口径(全 MVP 表一次建齐, T2.3 仅 Repository)、T4.2 把文风漂移并入第四审、新增 `PUT .../draft` 自动保存端点并回写 ARCH §7.2 + contracts C3。 +- [2026-06-17] @docs — 四份规格 + CLAUDE.md + 协同系统(PROGRESS/memory)就绪;可进 Phase 0 / T0.1。 diff --git a/apps/api/tests/test_review_accept.py b/apps/api/tests/test_review_accept.py new file mode 100644 index 0000000..fc8dc5c --- /dev/null +++ b/apps/api/tests/test_review_accept.py @@ -0,0 +1,286 @@ +"""T2.5 review API + 历史 / T2.4 验收事务 + 冲突 gate(内存替身,无 DB/无网络)。 + +覆盖:review SSE 帧序列(section/conflict/done)、reviews 历史、accept happy path +(晋升 + digest + 裁决)、冲突 gate 拦截(缺判 → CONFLICT_UNRESOLVED 不写库)、 +事务回滚(某步失败全回滚不 commit)。审稿/digest 网关均注 mock(产 parsed),绝不联网。 +""" + +from __future__ import annotations + +import uuid + +import httpx +import pytest +from fakes_projects import ( + FakeChapterRepo, + FakeDigestAppendRepo, + FakeReviewGateway, + FakeReviewRepo, + FakeSession, +) +from test_projects import _empty_memory_repos +from ww_agents import ContinuityReview +from ww_agents.schemas import Conflict +from ww_shared import ErrorCode + + +def _make_client( + *, + chapter_repo: FakeChapterRepo | None = None, + review_repo: FakeReviewRepo | None = None, + digest_repo: FakeDigestAppendRepo | None = None, + review_gateway: FakeReviewGateway | None = None, + digest_gateway: FakeReviewGateway | None = None, + session: FakeSession | None = None, +) -> tuple[ + httpx.AsyncClient, + FakeChapterRepo, + FakeReviewRepo, + FakeDigestAppendRepo, + FakeSession, +]: + import os + + os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44) + from ww_api.main import create_app + from ww_api.services.project_deps import ( + get_chapter_repo, + get_digest_append_repo, + get_digest_gateway, + get_memory_repos, + get_review_gateway, + get_review_repo, + ) + from ww_db import get_session + + chapter_repo = chapter_repo or FakeChapterRepo() + review_repo = review_repo or FakeReviewRepo() + digest_repo = digest_repo or FakeDigestAppendRepo() + review_gateway = review_gateway or FakeReviewGateway(parsed=ContinuityReview()) + digest_gateway = digest_gateway or FakeReviewGateway(parsed=ContinuityReview()) + session = session or FakeSession() + + app = create_app() + app.dependency_overrides[get_chapter_repo] = lambda: chapter_repo + app.dependency_overrides[get_review_repo] = lambda: review_repo + app.dependency_overrides[get_digest_append_repo] = lambda: digest_repo + app.dependency_overrides[get_memory_repos] = _empty_memory_repos + app.dependency_overrides[get_review_gateway] = lambda: review_gateway + app.dependency_overrides[get_digest_gateway] = lambda: digest_gateway + app.dependency_overrides[get_session] = lambda: session + transport = httpx.ASGITransport(app=app) + client = httpx.AsyncClient(transport=transport, base_url="http://test") + return client, chapter_repo, review_repo, digest_repo, session + + +# ---- T2.5 review SSE ---- + + +@pytest.mark.asyncio +async def test_review_stream_yields_section_and_done() -> None: + gw = FakeReviewGateway(parsed=ContinuityReview()) + client, _, review_repo, _, session = _make_client(review_gateway=gw) + pid = uuid.uuid4() + async with client: + resp = await client.post( + f"/projects/{pid}/chapters/1/review", json={"draft": "本章草稿正文。"} + ) + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + text = resp.text + assert "event: section" in text + assert '"name": "continuity"' in text + assert '"status": "done"' in text + assert "event: done" in text + # collect 落了一行留痕;端点流耗尽后 commit。 + assert len(review_repo.rows) == 1 + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_review_stream_emits_conflict_events() -> None: + review = ContinuityReview( + conflicts=[ + Conflict(type="性格漂移", where="第3段", refs=["第2章"], suggestion="改回冷静"), + ] + ) + gw = FakeReviewGateway(parsed=review) + client, _, review_repo, _, _ = _make_client(review_gateway=gw) + pid = uuid.uuid4() + async with client: + resp = await client.post(f"/projects/{pid}/chapters/1/review", json={"draft": "草稿"}) + text = resp.text + assert "event: conflict" in text + assert '"type": "性格漂移"' in text + # 冲突落进 chapter_reviews 留痕。 + assert review_repo.rows[0].conflicts[0]["type"] == "性格漂移" + + +@pytest.mark.asyncio +async def test_review_without_draft_falls_back_to_saved() -> None: + chapter_repo = FakeChapterRepo() + pid = uuid.uuid4() + await chapter_repo.save_draft(pid, 1, text="已存草稿") + client, _, _, _, _ = _make_client(chapter_repo=chapter_repo) + async with client: + resp = await client.post(f"/projects/{pid}/chapters/1/review", json={}) + assert resp.status_code == 200 + assert "event: done" in resp.text + + +@pytest.mark.asyncio +async def test_review_without_any_draft_404() -> None: + client, _, _, _, _ = _make_client() + pid = uuid.uuid4() + async with client: + resp = await client.post(f"/projects/{pid}/chapters/9/review", json={}) + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND + + +# ---- T2.5 reviews 历史 ---- + + +@pytest.mark.asyncio +async def test_list_reviews_returns_history_newest_first() -> None: + review_repo = FakeReviewRepo() + pid = uuid.uuid4() + await review_repo.record(pid, 1, conflicts=[{"type": "设定违例"}]) + await review_repo.record(pid, 1, conflicts=[]) + client, _, _, _, _ = _make_client(review_repo=review_repo) + async with client: + resp = await client.get(f"/projects/{pid}/chapters/1/reviews") + assert resp.status_code == 200 + rows = resp.json()["reviews"] + assert len(rows) == 2 + # 新→旧:最近 record 的(空冲突)在前。 + assert rows[0]["conflicts"] == [] + + +# ---- T2.4 accept happy path ---- + + +async def _seed_review( + review_repo: FakeReviewRepo, pid: uuid.UUID, conflicts: list[dict[str, object]] +) -> None: + await review_repo.record(pid, 1, conflicts=conflicts) + + +@pytest.mark.asyncio +async def test_accept_promotes_appends_digest_records_decisions() -> None: + review_repo = FakeReviewRepo() + pid = uuid.uuid4() + await _seed_review( + review_repo, pid, [{"type": "性格漂移", "where": "x", "refs": [], "suggestion": "y"}] + ) + from ww_api.services.digest_extraction import ChapterDigestFacts + + digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="本章主线", events=["开战"])) + client, chapter_repo, _, digest_repo, session = _make_client( + review_repo=review_repo, digest_gateway=digest_gw + ) + async with client: + resp = await client.post( + f"/projects/{pid}/chapters/1/accept", + json={ + "final_text": "作者改稿后的终稿正文。", + "decisions": [{"conflict_index": 0, "verdict": "accept"}], + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["accepted_version"] == 1 + assert body["digest_added"] is True + assert body["decisions_recorded"] == 1 + # 晋升落 accepted v1,终稿正文。 + accepted = await chapter_repo.latest_accepted(pid, 1) + assert accepted is not None + assert accepted.content == "作者改稿后的终稿正文。" + # digest 从终稿提炼(不变量 #4)。 + assert digest_repo.rows[0][2]["summary"] == "本章主线" + # 裁决写回留痕。 + assert review_repo.rows[0].decisions is not None + assert review_repo.rows[0].decisions["items"][0]["verdict"] == "accept" + # 单事务一次 commit。 + assert session.commits == 1 + + +@pytest.mark.asyncio +async def test_accept_with_no_review_succeeds() -> None: + """无审稿留痕(零冲突)→ gate 直通,仍晋升 + 提炼 digest。""" + from ww_api.services.digest_extraction import ChapterDigestFacts + + digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s")) + client, chapter_repo, _, digest_repo, session = _make_client(digest_gateway=digest_gw) + pid = uuid.uuid4() + async with client: + resp = await client.post( + f"/projects/{pid}/chapters/1/accept", + json={"final_text": "终稿", "decisions": []}, + ) + assert resp.status_code == 200 + assert resp.json()["accepted_version"] == 1 + assert session.commits == 1 + assert len(digest_repo.rows) == 1 + + +# ---- T2.4 冲突 gate 拦截 ---- + + +@pytest.mark.asyncio +async def test_accept_blocks_when_conflict_unresolved() -> None: + review_repo = FakeReviewRepo() + pid = uuid.uuid4() + await _seed_review( + review_repo, + pid, + [ + {"type": "性格漂移", "where": "a", "refs": [], "suggestion": "b"}, + {"type": "设定违例", "where": "c", "refs": [], "suggestion": "d"}, + ], + ) + digest_gw = FakeReviewGateway(parsed=ContinuityReview()) + client, chapter_repo, _, digest_repo, session = _make_client( + review_repo=review_repo, digest_gateway=digest_gw + ) + async with client: + resp = await client.post( + f"/projects/{pid}/chapters/1/accept", + json={ + "final_text": "终稿", + # 只裁决了 0 号冲突,1 号缺判 → 拦截。 + "decisions": [{"conflict_index": 0, "verdict": "ignore"}], + }, + ) + assert resp.status_code == 409 + assert resp.json()["error"]["code"] == ErrorCode.CONFLICT_UNRESOLVED + assert resp.json()["error"]["details"]["missing_conflict_indices"] == [1] + # 不写库:未晋升、未提炼 digest、未提交、未调 digest 网关。 + assert await chapter_repo.latest_accepted(pid, 1) is None + assert len(digest_repo.rows) == 0 + assert session.commits == 0 + assert len(digest_gw.requests) == 0 + + +# ---- T2.4 事务回滚 ---- + + +@pytest.mark.asyncio +async def test_accept_rolls_back_when_a_step_fails() -> None: + """裁决留痕步骤抛错 → 整体回滚(不 commit)。""" + review_repo = FakeReviewRepo(fail_set_decisions=True) + pid = uuid.uuid4() + await _seed_review(review_repo, pid, []) + from ww_api.services.digest_extraction import ChapterDigestFacts + + digest_gw = FakeReviewGateway(parsed=ChapterDigestFacts(summary="s")) + client, _, _, _, session = _make_client(review_repo=review_repo, digest_gateway=digest_gw) + # set_decisions 在事务中途抛错;ASGITransport 默认上抛未处理异常。 + with pytest.raises(RuntimeError, match="rollback test"): + async with client: + await client.post( + f"/projects/{pid}/chapters/1/accept", + json={"final_text": "终稿", "decisions": []}, + ) + # 关键:事务未 commit(半态不落库,整体回滚)。 + assert session.commits == 0 diff --git a/apps/api/ww_api/services/accept_service.py b/apps/api/ww_api/services/accept_service.py new file mode 100644 index 0000000..5129b5b --- /dev/null +++ b/apps/api/ww_api/services/accept_service.py @@ -0,0 +1,136 @@ +"""验收事务 + 冲突 gate(T2.4;ARCH §5.5 四步,不变量 #3/#4/#5)。 + +把作者裁决后的变更**单原子事务**落库(R3/R4):晋升终稿 → 终稿提炼 digest → +裁决留痕 →(占位)人物/伏笔状态。digest 提炼在**事务外**先做(R2)。 + +冲突 gate(R5,事务前拦截):核对最近一次审稿留痕的**每个冲突**在裁决清单里都有 +采纳/忽略/手改之一;缺判 → `AppError(CONFLICT_UNRESOLVED)`,不写库。 + +accept 是**确定性事务代码**,不跑 graph 正文/审稿节点;真相从领域表(`chapters` / +`chapter_reviews`)重读(R3,不变量 #5)。提交边界:全部只 flush → 末尾一次 +`await session.commit()`;任一步失败 → 整体回滚。 +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass + +import structlog +from ww_core.domain.chapter_repo import ChapterRepo +from ww_core.domain.digest_repo import DigestAppendRepo +from ww_core.domain.review_repo import ReviewRepo, ReviewView +from ww_shared import AppError, ErrorCode + +from ww_api.schemas.projects import ConflictDecision + +log = structlog.get_logger(__name__) + + +@dataclass(frozen=True) +class AcceptOutcome: + """验收事务结果(供端点组「本次将更新」清单)。""" + + accepted_version: int + digest_added: bool + decisions_recorded: int + review_id: uuid.UUID | None + + +def assert_conflicts_resolved( + latest_review: ReviewView | None, + decisions: list[ConflictDecision], +) -> None: + """冲突 gate(R5):最近审稿的每个冲突都须有裁决,否则 `CONFLICT_UNRESOLVED`。 + + 判据:以最近审稿留痕的 `conflicts` 列表下标为冲突身份;裁决清单里出现的 + `conflict_index` 集合必须**覆盖** `range(len(conflicts))`。缺判 → 拦截(不写库)。 + 无审稿留痕或零冲突 → 直接通过(无需裁决)。纯函数:只判定、不副作用。 + """ + if latest_review is None: + return + conflict_count = len(latest_review.conflicts) + if conflict_count == 0: + return + decided = {d.conflict_index for d in decisions} + missing = [i for i in range(conflict_count) if i not in decided] + if missing: + raise AppError( + ErrorCode.CONFLICT_UNRESOLVED, + "存在未裁决的冲突,无法验收:请对每个冲突选择 采纳/忽略/手改", + {"missing_conflict_indices": missing, "conflict_count": conflict_count}, + ) + + +def _serialize_decisions( + latest_review: ReviewView | None, + decisions: list[ConflictDecision], +) -> dict[str, object]: + """把裁决清单序列化为可落 `chapter_reviews.decisions` 的 JSON 形(确定性)。""" + return { + "items": [ + { + "conflict_index": d.conflict_index, + "verdict": d.verdict, + "note": d.note, + } + for d in sorted(decisions, key=lambda x: x.conflict_index) + ], + "conflict_count": len(latest_review.conflicts) if latest_review else 0, + } + + +async def run_accept_transaction( + *, + session: object, + chapter_repo: ChapterRepo, + digest_repo: DigestAppendRepo, + review_repo: ReviewRepo, + project_id: uuid.UUID, + chapter_no: int, + final_text: str, + digest_facts: dict[str, object], + latest_review: ReviewView | None, + decisions: list[ConflictDecision], +) -> AcceptOutcome: + """单原子事务落库(R3/R4,§5.5 步骤 1–4),末尾一次 commit。 + + `digest_facts` 已在事务外提炼好(R2)。各 repo 写方法只 flush;本函数统一在末尾 + `await session.commit()`,任一步抛错由调用方/上下文回滚(不显式半提交)。 + `session` 类型用 object 以免绑定 SQLAlchemy(测试注入 fake session 亦可)。 + """ + # 步骤 1:终稿晋升 accepted 新 version(max+1,草稿行保留,R4)。 + chapter = await chapter_repo.promote_to_accepted(project_id, chapter_no, content=final_text) + + # 步骤 2:终稿 digest 追加(append-only,不变量 #4)。 + await digest_repo.append(project_id, chapter_no, facts=digest_facts) + + # 步骤 4:裁决留痕(写到最近一次审稿行;无审稿行则跳过)。 + review_id: uuid.UUID | None = None + if latest_review is not None: + updated = await review_repo.set_decisions( + latest_review.id, + decisions=_serialize_decisions(latest_review, decisions), + ) + review_id = updated.id + + # 步骤 3(占位):人物 latest_state / 伏笔状态更新——M3 才正式接伏笔表, + # 这里按 §5.5 步骤 3 留占位,不引入 M3 表逻辑(避免越界写未就绪的状态机)。 + # TODO(M3): 按裁决应用 latest_state 变更 + 伏笔登记/到期扫描(§6.2)。 + + await session.commit() # type: ignore[attr-defined] # AsyncSession.commit()(fake 同形) + + log.info( + "chapter_accepted", + project_id=str(project_id), + chapter_no=chapter_no, + accepted_version=chapter.version, + decisions_recorded=len(decisions), + review_id=str(review_id) if review_id else None, + ) + return AcceptOutcome( + accepted_version=chapter.version, + digest_added=True, + decisions_recorded=len(decisions), + review_id=review_id, + ) diff --git a/apps/api/ww_api/services/digest_extraction.py b/apps/api/ww_api/services/digest_extraction.py new file mode 100644 index 0000000..97753bb --- /dev/null +++ b/apps/api/ww_api/services/digest_extraction.py @@ -0,0 +1,99 @@ +"""终稿 digest 提炼(验收事务的 R2 步骤;ARCH §5.5 / §6.1,不变量 #4)。 + +验收时用**终稿**(作者裁决/改稿后的最终文本)经网关跑一次轻量结构化提炼,得 digest +(结构化事实)。**在开原子事务之前**完成——别在持开事务里跨网络调 LLM(R2)。 + +`ChapterDigestFacts` 是 digest 的结构化形:本章关键事实清单,供后续章节注入近况摘要 +(assemble 的 `recent_digests`)+ 一致性比对。轻量档位(tier=light),只读终稿、产事实。 + +记账:digest 提炼这次网关调用产 usage,经 `SqlAlchemyLedgerSink` flush 进**同一请求 +session**——由验收事务在末尾一次 commit 落 `usage_ledger`(见 ledger gotcha)。 +""" + +from __future__ import annotations + +import uuid +from typing import Any + +import structlog +from pydantic import BaseModel, Field +from ww_llm_gateway import Gateway +from ww_llm_gateway.types import Block, LlmRequest, Scope + +log = structlog.get_logger(__name__) + +DIGEST_SYSTEM_PROMPT = """你是长篇连载小说的「章节摘要提炼」。读入本章**终稿**,抽取后续\ +章节一致性比对所需的结构化事实,产出结构化摘要。 + +只提炼**终稿明确写出**的事实,不臆造、不推断未写明的内容: +- summary:本章一句话主线(≤60 字); +- events:本章发生的关键事件(按时序); +- characters:登场人物及其本章状态变化(姓名 + 状态/变化); +- locations:出现的地点; +- foreshadow:本章埋下或回收的伏笔线索。 + +纪律:只读终稿、只产事实,不评价、不改稿、不报冲突(冲突在审稿期产)。""" + + +class CharacterStateFact(BaseModel): + """单个人物的本章状态事实。""" + + name: str = Field(description="人物姓名") + state: str = Field(description="本章该人物的状态/变化") + + +class ChapterDigestFacts(BaseModel): + """终稿提炼的结构化事实(落 `chapter_digests.facts`)。""" + + summary: str = Field(default="", description="本章一句话主线") + events: list[str] = Field(default_factory=list, description="关键事件(时序)") + characters: list[CharacterStateFact] = Field( + default_factory=list, description="登场人物及其本章状态变化" + ) + locations: list[str] = Field(default_factory=list, description="出现的地点") + foreshadow: list[str] = Field(default_factory=list, description="埋下/回收的伏笔线索") + + +def build_digest_request( + *, final_text: str, user_id: uuid.UUID, project_id: uuid.UUID +) -> LlmRequest: + """据终稿构造 digest 提炼请求(纯函数)。 + + `system_prompt` 进缓存断点前块;终稿进 `input`(断点后)。tier=light(不变量 #2)。 + `output_schema=ChapterDigestFacts` → 网关经 instructor 保证产结构化实例(C1)。 + """ + return LlmRequest( + tier="light", + system=[Block(text=DIGEST_SYSTEM_PROMPT, cache=True)], + input=f"## 本章终稿\n{final_text}", + output_schema=ChapterDigestFacts, + scope=Scope(user_id=user_id, project_id=project_id), + ) + + +async def extract_digest_facts( + gateway: Gateway, + *, + final_text: str, + user_id: uuid.UUID, + project_id: uuid.UUID, + chapter_no: int, +) -> dict[str, Any]: + """从终稿提炼结构化事实,返回可直接落 `facts` 列的 dict。 + + **在开原子事务之前**调用(R2:别在持开事务里跨网络调 LLM)。`gateway.run(req).parsed` + 带 schema 时必非 None(C1);防御性兜底:若 parsed 缺失则落空事实(不崩验收)。 + 日志脱敏:只记终稿长度,不记正文。 + """ + req = build_digest_request(final_text=final_text, user_id=user_id, project_id=project_id) + resp = await gateway.run(req) + parsed = resp.parsed + facts = parsed.model_dump() if parsed is not None else ChapterDigestFacts().model_dump() + log.info( + "digest_extracted", + project_id=str(project_id), + chapter_no=chapter_no, + final_text_len=len(final_text), + event_count=len(facts.get("events", [])), + ) + return facts diff --git a/apps/web/app/projects/[id]/review/page.tsx b/apps/web/app/projects/[id]/review/page.tsx new file mode 100644 index 0000000..ff19a8e --- /dev/null +++ b/apps/web/app/projects/[id]/review/page.tsx @@ -0,0 +1,40 @@ +import { notFound } from "next/navigation"; + +import { ReviewReport } from "@/components/review/ReviewReport"; +import { fetchProject, fetchReviews } from "@/lib/api/server"; +import { latestReview } from "@/lib/review/history"; + +interface PageProps { + params: Promise<{ id: string }>; + searchParams: Promise<{ chapter?: string }>; +} + +const DEFAULT_CHAPTER_NO = 1; + +// 审稿报告页(UX §6.4)。Server Component 取项目 + 审稿留痕历史(新→旧); +// ReviewReport(Client)承载 SSE 重审 / 裁决 / 验收交互。 +export default async function ReviewPage({ params, searchParams }: PageProps) { + const { id } = await params; + const { chapter } = await searchParams; + const chapterNo = parsePositiveInt(chapter) ?? DEFAULT_CHAPTER_NO; + try { + const project = await fetchProject(id); + const history = await fetchReviews(id, chapterNo); + return ( + + ); + } catch { + notFound(); + } +} + +function parsePositiveInt(raw: string | undefined): number | null { + if (!raw) return null; + const n = Number.parseInt(raw, 10); + return Number.isInteger(n) && n > 0 ? n : null; +} diff --git a/apps/web/components/review/AcceptPanel.tsx b/apps/web/components/review/AcceptPanel.tsx new file mode 100644 index 0000000..410540f --- /dev/null +++ b/apps/web/components/review/AcceptPanel.tsx @@ -0,0 +1,76 @@ +"use client"; + +import type { AcceptResponse } from "@/lib/api/types"; + +interface AcceptPanelProps { + conflictCount: number; + unresolvedCount: number; + accepting: boolean; + result: AcceptResponse | null; + onAccept: () => void; +} + +// 验收 gate(UX §9 / §8.4):未决禁验收(置灰 + 文案),验收后呈现「本次将更新」清单。 +export function AcceptPanel({ + conflictCount, + unresolvedCount, + accepting, + result, + onAccept, +}: AcceptPanelProps) { + const blocked = unresolvedCount > 0; + + if (result) { + return ( +
+

本章已验收

+ +
+ ); + } + + return ( +
+ {blocked ? ( +

+ 尚有 {unresolvedCount} 项冲突未裁决,处理完才能验收。 +

+ ) : ( +

+ {conflictCount === 0 + ? "无冲突,可直接验收。" + : "全部冲突已裁决,可验收本章。"} +

+ )} + +
+ ); +} diff --git a/apps/web/components/review/AnnotatedText.tsx b/apps/web/components/review/AnnotatedText.tsx new file mode 100644 index 0000000..ec4c3b8 --- /dev/null +++ b/apps/web/components/review/AnnotatedText.tsx @@ -0,0 +1,54 @@ +"use client"; + +import type { ReviewConflict } from "@/lib/review/sse"; + +interface AnnotatedTextProps { + text: string; + conflicts: ReviewConflict[]; + // 当前聚焦的冲突下标(来自报告卡「跳转」),用于锚点联动。 + focusedIndex: number | null; + onAnchorClick: (index: number) => void; +} + +// 正文就地标注(UX §8.3 / §6.1)。 +// M2 占位(R6):`where` 是文字定位(如「第4段」),M2 不做精确字符 range — +// 改为段级/锚点联动:每个冲突渲染为一枚朱砂波浪线锚点挂在正文上方,点击=回跳报告卡。 +// 精确 inline range 标注留 M3+(需后端给字符 offset)。 +export function AnnotatedText({ + text, + conflicts, + focusedIndex, + onAnchorClick, +}: AnnotatedTextProps) { + return ( +
+ {conflicts.length > 0 ? ( +
+ {conflicts.map((c, i) => ( + + ))} +
+ ) : null} +
+ {text || ( + (无待审正文,先去写作页起草本章) + )} +
+
+ ); +} diff --git a/apps/web/components/review/ConflictCard.tsx b/apps/web/components/review/ConflictCard.tsx new file mode 100644 index 0000000..0b28b27 --- /dev/null +++ b/apps/web/components/review/ConflictCard.tsx @@ -0,0 +1,119 @@ +"use client"; + +import type { ReviewConflict } from "@/lib/review/sse"; +import type { DecisionDraft, Verdict } from "@/lib/review/decisions"; + +interface ConflictCardProps { + index: number; + conflict: ReviewConflict; + draft: DecisionDraft; + missing: boolean; + onVerdict: (index: number, verdict: Verdict) => void; + onNote: (index: number, note: string) => void; + onJump: (index: number) => void; +} + +const VERDICT_OPTIONS: { value: Verdict; label: string }[] = [ + { value: "accept", label: "采纳改法" }, + { value: "ignore", label: "忽略" }, + { value: "manual", label: "手改" }, +]; + +// 单个冲突报告卡(UX §6.4):五类徽标 + where + refs + suggestion + 裁决三态。 +// 冲突=赭红;未决/缺判时加图标+文案(不单靠色,a11y §10)。 +export function ConflictCard({ + index, + conflict, + draft, + missing, + onVerdict, + onNote, + onJump, +}: ConflictCardProps) { + const resolved = draft.verdict !== null; + return ( +
  • +
    + +

    {conflict.suggestion}

    +
    + +
    + {conflict.where ? ( + + ) : null} + {conflict.refs.map((ref) => ( + + ▸ {ref} + + ))} +
    + +
    + 冲突 {index + 1} 裁决 +
    + {VERDICT_OPTIONS.map((opt) => { + const active = draft.verdict === opt.value; + return ( + + ); + })} + {resolved ? ( + + ) : ( + 未裁决 + )} +
    + {draft.verdict === "manual" ? ( +
    + + 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" + /> +
    + ) : null} +
    +
  • + ); +} diff --git a/apps/web/components/review/ReviewReport.tsx b/apps/web/components/review/ReviewReport.tsx new file mode 100644 index 0000000..5481bdf --- /dev/null +++ b/apps/web/components/review/ReviewReport.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +import { AppShell } from "@/components/AppShell"; +import type { ProjectResponse, ReviewHistoryItem } from "@/lib/api/types"; +import { + allResolved, + emptyDecisions, + setNote, + setVerdict, + unresolvedIndices, + type DecisionDraft, + type Verdict, +} from "@/lib/review/decisions"; +import { normalizeConflicts } from "@/lib/review/history"; +import { useAccept } from "@/lib/review/useAccept"; +import { useReviewStream } from "@/lib/review/useReviewStream"; +import type { ReviewConflict } from "@/lib/review/sse"; +import { AcceptPanel } from "./AcceptPanel"; +import { AnnotatedText } from "./AnnotatedText"; +import { ConflictCard } from "./ConflictCard"; + +interface ReviewReportProps { + project: ProjectResponse; + chapterNo: number; + initialReview: ReviewHistoryItem | undefined; + initialDraft: string; +} + +// 审稿报告页主体(UX §6.4 / §8.3 / §9)。 +// 数据源:进页用历史留痕 conflicts 种入;「重新审稿」用当前终稿跑 SSE 覆盖。 +// 裁决草稿随 conflicts 长度对齐;未决禁验收(gate)。 +export function ReviewReport({ + project, + chapterNo, + initialReview, + initialDraft, +}: ReviewReportProps) { + const review = useReviewStream(); + const accept = useAccept(); + + const [finalText, setFinalText] = useState(initialDraft); + const [drafts, setDrafts] = useState(() => + emptyDecisions(normalizeConflicts(initialReview).length), + ); + const [focusedIndex, setFocusedIndex] = useState(null); + const [missing, setMissing] = useState>(new Set()); + const seededRef = useRef(false); + + // 进页一次性把历史 conflicts 种入流状态(无需重审即可裁决)。 + const seededConflicts = useMemo( + () => normalizeConflicts(initialReview), + [initialReview], + ); + useEffect(() => { + if (seededRef.current) return; + seededRef.current = true; + if (seededConflicts.length > 0) review.seed(seededConflicts); + }, [review, seededConflicts]); + + // 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。 + const conflicts: ReviewConflict[] = review.state.conflicts; + + // 重审完成(done 边沿)→ 重置裁决草稿对齐新冲突集(冲突可能整组变化)。 + const conflictCount = conflicts.length; + const wasReviewingRef = useRef(false); + useEffect(() => { + if (review.state.phase === "reviewing") { + wasReviewingRef.current = true; + return; + } + if (review.state.phase === "done" && wasReviewingRef.current) { + wasReviewingRef.current = false; + setDrafts(emptyDecisions(conflictCount)); + setMissing(new Set()); + } + }, [review.state.phase, conflictCount]); + + const onReReview = (): void => { + setMissing(new Set()); + void review.start(project.id, chapterNo, finalText); + }; + + const onVerdict = (index: number, verdict: Verdict): void => { + setDrafts((prev) => setVerdict(prev, index, verdict)); + setMissing((prev) => { + if (!prev.has(index)) return prev; + const next = new Set(prev); + next.delete(index); + return next; + }); + }; + const onNote = (index: number, note: string): void => + setDrafts((prev) => setNote(prev, index, note)); + + const jumpToCard = (index: number): void => { + setFocusedIndex(index); + document + .getElementById(`conflict-card-${index}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }; + const jumpToAnchor = (index: number): void => { + setFocusedIndex(index); + document + .getElementById(`anchor-${index}`) + ?.scrollIntoView({ behavior: "smooth", block: "center" }); + }; + + const onAccept = async (): Promise => { + const outcome = await accept.accept( + project.id, + chapterNo, + finalText, + drafts, + ); + if (outcome.missingIndices.length > 0) { + setMissing(new Set(outcome.missingIndices)); + } + }; + + const resolved = allResolved(drafts); + const unresolved = unresolvedIndices(drafts).length; + const reviewing = review.isReviewing; + + return ( + +
    + {/* 左:终稿正文 + 就地标注 */} +
    +
    +

    + 第 {chapterNo} 章 审稿报告 +

    + + {conflictCount} 冲突 + +
    + {reviewing ? ( + + ) : ( + + )} +
    +
    + + {review.state.error ? ( +

    + 审稿失败({review.state.error.code}): + {review.state.error.message} + {review.state.error.code === "LLM_UNAVAILABLE" ? ( + <> + {" "} + + 去设置提供商 + + + ) : null} +

    + ) : null} + +
    + +
    + + 展开/编辑终稿(裁决时可改稿 — 摘要从终稿提炼) + + +