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:
286
apps/api/tests/test_review_accept.py
Normal file
286
apps/api/tests/test_review_accept.py
Normal 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
|
||||
136
apps/api/ww_api/services/accept_service.py
Normal file
136
apps/api/ww_api/services/accept_service.py
Normal file
@@ -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,
|
||||
)
|
||||
99
apps/api/ww_api/services/digest_extraction.py
Normal file
99
apps/api/ww_api/services/digest_extraction.py
Normal file
@@ -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
|
||||
40
apps/web/app/projects/[id]/review/page.tsx
Normal file
40
apps/web/app/projects/[id]/review/page.tsx
Normal 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 取项目 + 审稿留痕历史(新→旧);
|
||||
// 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 (
|
||||
<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;
|
||||
}
|
||||
76
apps/web/components/review/AcceptPanel.tsx
Normal file
76
apps/web/components/review/AcceptPanel.tsx
Normal 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;
|
||||
}
|
||||
|
||||
// 验收 gate(UX §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>
|
||||
);
|
||||
}
|
||||
54
apps/web/components/review/AnnotatedText.tsx
Normal file
54
apps/web/components/review/AnnotatedText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
apps/web/components/review/ConflictCard.tsx
Normal file
119
apps/web/components/review/ConflictCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
291
apps/web/components/review/ReviewReport.tsx
Normal file
291
apps/web/components/review/ReviewReport.tsx
Normal 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 §9);M2 仅 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;
|
||||
}
|
||||
101
apps/web/lib/review/decisions.test.ts
Normal file
101
apps/web/lib/review/decisions.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
74
apps/web/lib/review/decisions.ts
Normal file
74
apps/web/lib/review/decisions.ts
Normal 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"));
|
||||
}
|
||||
54
apps/web/lib/review/history.test.ts
Normal file
54
apps/web/lib/review/history.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
34
apps/web/lib/review/history.ts
Normal file
34
apps/web/lib/review/history.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// 审稿历史归一:把后端 ReviewHistoryItem 的松散 conflicts(dict[])收紧成 ReviewConflict[]。
|
||||
// 纯逻辑(可单测);schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。
|
||||
|
||||
import type { ReviewHistoryItem } from "@/lib/api/types";
|
||||
import type { ReviewConflict } from "./sse";
|
||||
|
||||
function asString(v: unknown, fallback = ""): string {
|
||||
return typeof v === "string" ? v : fallback;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
// 把一条留痕的 conflicts 收紧(缺字段给安全默认;保持顺序=下标身份,对齐冲突 gate)。
|
||||
export function normalizeConflicts(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
): ReviewConflict[] {
|
||||
const raw = item?.conflicts ?? [];
|
||||
return raw.map((c) => ({
|
||||
type: asString(c["type"], "未分类"),
|
||||
where: asString(c["where"]),
|
||||
refs: asStringArray(c["refs"]),
|
||||
suggestion: asString(c["suggestion"]),
|
||||
}));
|
||||
}
|
||||
|
||||
// 最近一条留痕(GET .../reviews 已按新→旧排序)。
|
||||
export function latestReview(
|
||||
items: readonly ReviewHistoryItem[] | undefined,
|
||||
): ReviewHistoryItem | undefined {
|
||||
return items?.[0];
|
||||
}
|
||||
130
apps/web/lib/review/sse.test.ts
Normal file
130
apps/web/lib/review/sse.test.ts
Normal 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
155
apps/web/lib/review/sse.ts
Normal 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));
|
||||
}
|
||||
76
apps/web/lib/review/useAccept.ts
Normal file
76
apps/web/lib/review/useAccept.ts
Normal 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(呈现「本次将更新」清单);
|
||||
// 失败回滚状态 + toast;409 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 };
|
||||
}
|
||||
135
apps/web/lib/review/useReviewStream.ts
Normal file
135
apps/web/lib/review/useReviewStream.ts
Normal 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+ReadableStream(EventSource 不支持 POST)。
|
||||
// "停" = abort(已收 section/conflict 留在 state,裁决草稿不丢)。
|
||||
// 流前 503 LLM_UNAVAILABLE 是 JSON 信封(非帧)→ 经 !res.ok 检出。
|
||||
export function useReviewStream(): UseReviewStream {
|
||||
const [state, dispatch] = useReducer(reducer, initialReviewState);
|
||||
const controllerRef = useRef<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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user