feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)
- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点 - 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件 - 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板 - 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE - bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试 - M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug - 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
This commit is contained in:
@@ -8,10 +8,19 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView
|
||||
from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView
|
||||
from ww_core.domain.foreshadow_state import (
|
||||
ForeshadowStatus,
|
||||
apply_overdue_scan,
|
||||
)
|
||||
from ww_core.domain.foreshadow_state import (
|
||||
transition as apply_transition,
|
||||
)
|
||||
from ww_core.domain.project_repo import ProjectCreate, ProjectView
|
||||
from ww_core.domain.repositories import DigestView
|
||||
from ww_core.domain.review_repo import ReviewView
|
||||
@@ -172,14 +181,156 @@ class FakeDigestAppendRepo:
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""最小 fake:记录 commit 次数(验收事务边界断言)。"""
|
||||
"""最小 fake:记录 commit/rollback 次数(验收事务 + 端点提交边界断言)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.commits = 0
|
||||
self.rollbacks = 0
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
async def rollback(self) -> None:
|
||||
self.rollbacks += 1
|
||||
|
||||
|
||||
class FakeForeshadowRepo:
|
||||
"""实现 `ForeshadowLedgerRepo` Protocol(内存;状态机/进展/扫描复用纯函数)。
|
||||
|
||||
`raise_integrity_on` 模拟重复 code 的 DB 唯一约束冲突(register 抛 IntegrityError)。
|
||||
"""
|
||||
|
||||
def __init__(self, *, raise_integrity_on: set[str] | None = None) -> None:
|
||||
self.rows: dict[tuple[uuid.UUID, str], ForeshadowLedgerView] = {}
|
||||
self._raise_integrity_on = raise_integrity_on or set()
|
||||
|
||||
async def register(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
code: str,
|
||||
title: str,
|
||||
planted_at: int | None = None,
|
||||
content: str | None = None,
|
||||
expected_close_from: int | None = None,
|
||||
expected_close_to: int | None = None,
|
||||
importance: str | None = None,
|
||||
) -> ForeshadowLedgerView:
|
||||
if code in self._raise_integrity_on or (project_id, code) in self.rows:
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
raise IntegrityError("duplicate code", None, Exception("unique"))
|
||||
view = ForeshadowLedgerView(
|
||||
code=code,
|
||||
title=title,
|
||||
status=ForeshadowStatus.OPEN.value,
|
||||
planted_at=planted_at,
|
||||
content=content,
|
||||
expected_close_from=expected_close_from,
|
||||
expected_close_to=expected_close_to,
|
||||
importance=importance,
|
||||
)
|
||||
self.rows[(project_id, code)] = view
|
||||
return view
|
||||
|
||||
async def get(self, project_id: uuid.UUID, code: str) -> ForeshadowLedgerView | None:
|
||||
return self.rows.get((project_id, code))
|
||||
|
||||
async def list_by_status(
|
||||
self, project_id: uuid.UUID, status: str | None = None
|
||||
) -> list[ForeshadowLedgerView]:
|
||||
rows = [v for (p, _), v in self.rows.items() if p == project_id]
|
||||
if status is not None:
|
||||
rows = [v for v in rows if v.status == status]
|
||||
return sorted(rows, key=lambda v: v.code)
|
||||
|
||||
async def transition(
|
||||
self, project_id: uuid.UUID, code: str, *, to_status: str
|
||||
) -> ForeshadowLedgerView:
|
||||
row = self.rows.get((project_id, code))
|
||||
if row is None:
|
||||
raise LookupError(f"foreshadow not found: {code}")
|
||||
new_status = apply_transition(
|
||||
ForeshadowStatus(row.status), ForeshadowStatus(to_status)
|
||||
).value
|
||||
updated = row.model_copy(update={"status": new_status})
|
||||
self.rows[(project_id, code)] = updated
|
||||
return updated
|
||||
|
||||
async def record_progress(
|
||||
self, project_id: uuid.UUID, code: str, *, entry: dict[str, Any]
|
||||
) -> ForeshadowLedgerView:
|
||||
row = self.rows.get((project_id, code))
|
||||
if row is None:
|
||||
raise LookupError(f"foreshadow not found: {code}")
|
||||
updated = row.model_copy(update={"progress": [*row.progress, dict(entry)]})
|
||||
self.rows[(project_id, code)] = updated
|
||||
return updated
|
||||
|
||||
async def scan_overdue(
|
||||
self, project_id: uuid.UUID, *, current_chapter: int
|
||||
) -> list[ForeshadowLedgerView]:
|
||||
changed: list[ForeshadowLedgerView] = []
|
||||
for key, row in list(self.rows.items()):
|
||||
new_status = apply_overdue_scan(
|
||||
ForeshadowStatus(row.status),
|
||||
current_chapter=current_chapter,
|
||||
expected_close_to=row.expected_close_to,
|
||||
).value
|
||||
if new_status != row.status:
|
||||
updated = row.model_copy(update={"status": new_status})
|
||||
self.rows[key] = updated
|
||||
changed.append(updated)
|
||||
return changed
|
||||
|
||||
|
||||
class FakeOutlineWriteRepo:
|
||||
"""实现 `OutlineWriteRepo` Protocol(内存;幂等覆盖同 (project_id, chapter_no))。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# (project_id, chapter_no) -> (volume, beats, foreshadow_windows)
|
||||
self.rows: dict[tuple[uuid.UUID, int], tuple[int, list[str], list[dict[str, Any]]]] = {}
|
||||
self.upsert_calls = 0
|
||||
|
||||
async def upsert_chapter(
|
||||
self,
|
||||
project_id: uuid.UUID,
|
||||
*,
|
||||
volume: int,
|
||||
chapter_no: int,
|
||||
beats: list[str],
|
||||
foreshadow_windows: list[dict[str, Any]],
|
||||
) -> OutlineWriteView:
|
||||
self.upsert_calls += 1
|
||||
windows = [dict(w) for w in foreshadow_windows]
|
||||
self.rows[(project_id, chapter_no)] = (volume, list(beats), windows)
|
||||
return OutlineWriteView(
|
||||
chapter_no=chapter_no,
|
||||
volume=volume,
|
||||
beats=list(beats),
|
||||
foreshadow_windows=windows,
|
||||
)
|
||||
|
||||
|
||||
class FakeSessionFactory:
|
||||
"""到期扫描的独立 session 工厂替身:`()` → async-CM 产 `FakeSession`。
|
||||
|
||||
记录开了几次 session + 各次 commit,供「扫描自建 session 并提交」断言。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sessions: list[FakeSession] = []
|
||||
|
||||
def __call__(self) -> Any:
|
||||
session = FakeSession()
|
||||
self.sessions.append(session)
|
||||
|
||||
@asynccontextmanager
|
||||
async def _cm() -> AsyncIterator[FakeSession]:
|
||||
yield session
|
||||
|
||||
return _cm()
|
||||
|
||||
|
||||
class FakeReviewGateway:
|
||||
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""
|
||||
|
||||
203
apps/api/tests/test_foreshadow.py
Normal file
203
apps/api/tests/test_foreshadow.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""T3.2 伏笔登记/状态变更端点 + 验收后到期扫描(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- 登记 201 + 重复 code → 422 VALIDATION 信封;
|
||||
- 状态机转移 200 / 非法转移 → 422 VALIDATION / 不存在 → 404;
|
||||
- 进展 append;空更新 → 422;
|
||||
- 到期扫描纯函数 `run_overdue_scan` 直接 await:埋 expected_close_to < 验收章号的伏笔
|
||||
→ 置 OVERDUE、自建 session 提交(**绝不真起后台线程**)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import (
|
||||
FakeForeshadowRepo,
|
||||
FakeSession,
|
||||
FakeSessionFactory,
|
||||
)
|
||||
from ww_api.services.foreshadow_scan import run_overdue_scan
|
||||
from ww_core.domain.foreshadow_state import ForeshadowStatus
|
||||
from ww_shared import ErrorCode
|
||||
|
||||
|
||||
def _make_client(
|
||||
*, repo: FakeForeshadowRepo | None = None, session: FakeSession | None = None
|
||||
) -> tuple[httpx.AsyncClient, FakeForeshadowRepo, 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_foreshadow_repo
|
||||
from ww_db import get_session
|
||||
|
||||
repo = repo or FakeForeshadowRepo()
|
||||
session = session or FakeSession()
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
return client, repo, session
|
||||
|
||||
|
||||
# ---- 登记 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_foreshadow_returns_201_open() -> None:
|
||||
client, repo, session = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/foreshadow",
|
||||
json={"code": "F1", "title": "神秘信物", "planted_at": 3, "expected_close_to": 10},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
assert body["code"] == "F1"
|
||||
assert body["status"] == ForeshadowStatus.OPEN.value
|
||||
assert body["expected_close_to"] == 10
|
||||
assert session.commits == 1
|
||||
assert (pid, "F1") in repo.rows
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_duplicate_code_maps_to_422_validation() -> None:
|
||||
repo = FakeForeshadowRepo(raise_integrity_on={"F1"})
|
||||
client, _, session = _make_client(repo=repo)
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.post(
|
||||
f"/projects/{pid}/foreshadow", json={"code": "F1", "title": "重复"}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == ErrorCode.VALIDATION
|
||||
assert err["details"]["reason"] == "duplicate"
|
||||
# 冲突后回滚、不提交。
|
||||
assert session.commits == 0
|
||||
assert session.rollbacks == 1
|
||||
|
||||
|
||||
# ---- 状态变更 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transition_open_to_partial_200() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="t")
|
||||
client, _, session = _make_client(repo=repo)
|
||||
async with client:
|
||||
resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={"to_status": "PARTIAL"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "PARTIAL"
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_transition_maps_to_422_validation() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="t")
|
||||
await repo.transition(pid, "F1", to_status="CLOSED") # 先到终态
|
||||
client, _, session = _make_client(repo=repo)
|
||||
async with client:
|
||||
resp = await client.patch(
|
||||
f"/projects/{pid}/foreshadow/F1",
|
||||
json={"to_status": "PARTIAL"}, # CLOSED→PARTIAL 非法
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
err = resp.json()["error"]
|
||||
assert err["code"] == ErrorCode.VALIDATION
|
||||
assert err["details"]["reason"] == "invalid_transition"
|
||||
assert session.rollbacks == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transition_unknown_code_404() -> None:
|
||||
client, _, _ = _make_client()
|
||||
pid = uuid.uuid4()
|
||||
async with client:
|
||||
resp = await client.patch(f"/projects/{pid}/foreshadow/NOPE", json={"to_status": "PARTIAL"})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_record_progress_appends() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="t")
|
||||
client, _, session = _make_client(repo=repo)
|
||||
async with client:
|
||||
resp = await client.patch(
|
||||
f"/projects/{pid}/foreshadow/F1",
|
||||
json={"progress_entry": {"chapter_no": 5, "note": "首次照应"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["progress"][0]["note"] == "首次照应"
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_update_maps_to_422() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="t")
|
||||
client, _, _ = _make_client(repo=repo)
|
||||
async with client:
|
||||
resp = await client.patch(f"/projects/{pid}/foreshadow/F1", json={})
|
||||
assert resp.status_code == 422
|
||||
assert resp.json()["error"]["details"]["reason"] == "empty_update"
|
||||
|
||||
|
||||
# ---- 验收后到期扫描(纯函数直接 await,不起后台线程)----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_overdue_scan_marks_overdue_and_commits() -> None:
|
||||
# Arrange:一条 expected_close_to=5 的伏笔,验收章号 7 > 5 → 应置 OVERDUE。
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="逾期", expected_close_to=5)
|
||||
await repo.register(pid, code="F2", title="窗口内", expected_close_to=9)
|
||||
factory = FakeSessionFactory()
|
||||
|
||||
# Act:经 repo_factory 缝注入 fake 账本 repo(不联网/不真起后台线程)。
|
||||
count = await run_overdue_scan(
|
||||
factory,
|
||||
project_id=pid,
|
||||
chapter_no=7,
|
||||
request_id="req-1",
|
||||
repo_factory=lambda _session: repo,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert count == 1
|
||||
assert repo.rows[(pid, "F1")].status == ForeshadowStatus.OVERDUE.value
|
||||
assert repo.rows[(pid, "F2")].status == ForeshadowStatus.OPEN.value
|
||||
# 自建 session 且有变更 → 提交一次。
|
||||
assert len(factory.sessions) == 1
|
||||
assert factory.sessions[0].commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_overdue_scan_no_change_skips_commit() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="窗口内", expected_close_to=9)
|
||||
factory = FakeSessionFactory()
|
||||
|
||||
count = await run_overdue_scan(
|
||||
factory, project_id=pid, chapter_no=3, repo_factory=lambda _session: repo
|
||||
)
|
||||
|
||||
assert count == 0
|
||||
# 空扫描不提交。
|
||||
assert factory.sessions[0].commits == 0
|
||||
216
apps/api/tests/test_outline.py
Normal file
216
apps/api/tests/test_outline.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""T3.5 端点测试:大纲生成 + 持久化 + 伏笔看板(内存替身,无 DB/无网络)。
|
||||
|
||||
覆盖:
|
||||
- POST /outline:mock 网关产 `OutlineResult` → 逐章 upsert(含 foreshadow_windows)→ 端点 commit;
|
||||
- 幂等:同 (project_id, chapter_no) 再生成覆盖;
|
||||
- 项目不存在 → 404;无凭据 → LLM_UNAVAILABLE(503);
|
||||
- GET /foreshadow?status=:按状态过滤;非法 status → 422 VALIDATION。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fakes_projects import (
|
||||
FakeForeshadowRepo,
|
||||
FakeOutlineWriteRepo,
|
||||
FakeProjectRepo,
|
||||
FakeReviewGateway,
|
||||
FakeSession,
|
||||
)
|
||||
from test_projects import _empty_memory_repos
|
||||
from ww_agents import ForeshadowWindow, OutlineChapter, OutlineResult
|
||||
from ww_core.domain.project_repo import ProjectCreate
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
|
||||
def _outline_result() -> OutlineResult:
|
||||
return OutlineResult(
|
||||
chapters=[
|
||||
OutlineChapter(
|
||||
no=1,
|
||||
beats=["开篇引入主角", "埋下信物伏笔"],
|
||||
foreshadow_windows=[
|
||||
ForeshadowWindow(code="F1", plant_chapter=1, expected_close_to=10)
|
||||
],
|
||||
),
|
||||
OutlineChapter(no=2, beats=["冲突升级"], foreshadow_windows=[]),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _make_client(
|
||||
*,
|
||||
project_repo: FakeProjectRepo | None = None,
|
||||
foreshadow_repo: FakeForeshadowRepo | None = None,
|
||||
outline_repo: FakeOutlineWriteRepo | None = None,
|
||||
gateway: FakeReviewGateway | None = None,
|
||||
session: FakeSession | None = None,
|
||||
) -> tuple[httpx.AsyncClient, FakeOutlineWriteRepo, FakeSession]:
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_foreshadow_repo,
|
||||
get_memory_repos,
|
||||
get_outline_gateway,
|
||||
get_outline_write_repo,
|
||||
get_project_repo,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
project_repo = project_repo or FakeProjectRepo()
|
||||
foreshadow_repo = foreshadow_repo or FakeForeshadowRepo()
|
||||
outline_repo = outline_repo or FakeOutlineWriteRepo()
|
||||
gateway = gateway or FakeReviewGateway(parsed=_outline_result())
|
||||
session = session or FakeSession()
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_foreshadow_repo] = lambda: foreshadow_repo
|
||||
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
|
||||
app.dependency_overrides[get_outline_write_repo] = lambda: outline_repo
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
app.dependency_overrides[get_outline_gateway] = lambda: gateway
|
||||
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
return client, outline_repo, session
|
||||
|
||||
|
||||
async def _seed_project(repo: FakeProjectRepo) -> uuid.UUID:
|
||||
view = await repo.create(uuid.UUID(int=1), ProjectCreate(title="测试作品", genre="玄幻"))
|
||||
return uuid.UUID(str(view.id))
|
||||
|
||||
|
||||
# ---- 大纲生成 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_upserts_chapters_and_commits() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
client, outline_repo, session = _make_client(project_repo=project_repo)
|
||||
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/outline", json={"volume": 1})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert [c["no"] for c in body["chapters"]] == [1, 2]
|
||||
ch1 = body["chapters"][0]
|
||||
assert ch1["beats"] == ["开篇引入主角", "埋下信物伏笔"]
|
||||
assert ch1["foreshadow_windows"][0]["code"] == "F1"
|
||||
assert ch1["foreshadow_windows"][0]["expected_close_to"] == 10
|
||||
# 逐章 upsert(2 章)+ 端点末尾一次 commit。
|
||||
assert outline_repo.upsert_calls == 2
|
||||
assert (pid, 1) in outline_repo.rows
|
||||
assert outline_repo.rows[(pid, 1)][1] == ["开篇引入主角", "埋下信物伏笔"]
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_is_idempotent_overwrites_same_chapter() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
outline_repo = FakeOutlineWriteRepo()
|
||||
client, _, _ = _make_client(project_repo=project_repo, outline_repo=outline_repo)
|
||||
|
||||
async with client:
|
||||
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
|
||||
await client.post(f"/projects/{pid}/outline", json={"volume": 1})
|
||||
|
||||
# 同 (project_id, chapter_no) 覆盖,不堆叠 → 仍只 2 行。
|
||||
assert len(outline_repo.rows) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_unknown_project_404() -> None:
|
||||
client, _, _ = _make_client()
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{uuid.uuid4()}/outline", json={})
|
||||
assert resp.status_code == 404
|
||||
assert resp.json()["error"]["code"] == ErrorCode.NOT_FOUND
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_without_credentials_maps_to_llm_unavailable() -> None:
|
||||
# 无凭据时 `build_gateway_for_tier` 抛 LLM_UNAVAILABLE(503),在网关 dep 解析阶段发生。
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
session = FakeSession()
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import (
|
||||
get_foreshadow_repo,
|
||||
get_memory_repos,
|
||||
get_outline_gateway,
|
||||
get_outline_write_repo,
|
||||
get_project_repo,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
async def _no_creds() -> object:
|
||||
raise AppError(ErrorCode.LLM_UNAVAILABLE, "未配置凭据", {"provider": "deepseek"})
|
||||
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_project_repo] = lambda: project_repo
|
||||
app.dependency_overrides[get_foreshadow_repo] = lambda: FakeForeshadowRepo()
|
||||
app.dependency_overrides[get_memory_repos] = _empty_memory_repos
|
||||
app.dependency_overrides[get_outline_write_repo] = lambda: FakeOutlineWriteRepo()
|
||||
app.dependency_overrides[get_session] = lambda: session
|
||||
app.dependency_overrides[get_outline_gateway] = _no_creds
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.post(f"/projects/{pid}/outline", json={})
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert resp.json()["error"]["code"] == ErrorCode.LLM_UNAVAILABLE
|
||||
# 网关阶段失败 → 未生成、未提交。
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_outline_upper_error_propagates() -> None:
|
||||
project_repo = FakeProjectRepo()
|
||||
pid = await _seed_project(project_repo)
|
||||
gateway = FakeReviewGateway(error=AppError(ErrorCode.LLM_UNAVAILABLE, "boom"))
|
||||
client, _, session = _make_client(project_repo=project_repo, gateway=gateway)
|
||||
|
||||
async with client:
|
||||
resp = await client.post(f"/projects/{pid}/outline", json={})
|
||||
|
||||
assert resp.status_code == 503
|
||||
assert session.commits == 0
|
||||
|
||||
|
||||
# ---- 伏笔看板 ----
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_foreshadow_board_filters_by_status() -> None:
|
||||
repo = FakeForeshadowRepo()
|
||||
pid = uuid.uuid4()
|
||||
await repo.register(pid, code="F1", title="开放伏笔")
|
||||
await repo.register(pid, code="F2", title="部分回收")
|
||||
await repo.transition(pid, "F2", to_status="PARTIAL")
|
||||
from ww_api.main import create_app
|
||||
from ww_api.services.project_deps import get_foreshadow_repo
|
||||
|
||||
os.environ.setdefault("CREDENTIAL_ENC_KEY", "x" * 44)
|
||||
app = create_app()
|
||||
app.dependency_overrides[get_foreshadow_repo] = lambda: repo
|
||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
all_resp = await client.get(f"/projects/{pid}/foreshadow")
|
||||
open_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "OPEN"})
|
||||
bad_resp = await client.get(f"/projects/{pid}/foreshadow", params={"status": "NOPE"})
|
||||
|
||||
assert {f["code"] for f in all_resp.json()["foreshadow"]} == {"F1", "F2"}
|
||||
open_codes = [f["code"] for f in open_resp.json()["foreshadow"]]
|
||||
assert open_codes == ["F1"]
|
||||
assert bad_resp.status_code == 422
|
||||
assert bad_resp.json()["error"]["code"] == ErrorCode.VALIDATION
|
||||
assert bad_resp.json()["error"]["details"]["reason"] == "invalid_status"
|
||||
63
apps/api/tests/test_outline_context.py
Normal file
63
apps/api/tests/test_outline_context.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""T3.5 大纲注入上下文组装单测(确定性纯函数;ARCH §5.4)。
|
||||
|
||||
断言:四源(设定/伏笔/人物/世界观)都进文本、排序确定(同输入同输出)、空源不炸。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ww_api.services.outline_context import build_outline_context
|
||||
from ww_core.domain import ForeshadowLedgerView
|
||||
from ww_core.domain.project_repo import ProjectView
|
||||
from ww_core.domain.repositories import CharacterView, WorldEntityView
|
||||
|
||||
|
||||
def _project() -> ProjectView:
|
||||
import uuid
|
||||
|
||||
return ProjectView(
|
||||
id=uuid.UUID(int=1),
|
||||
title="逆天剑帝",
|
||||
genre="玄幻",
|
||||
logline="少年逆袭",
|
||||
selling_points=["爽点密集", "升级流"],
|
||||
)
|
||||
|
||||
|
||||
def test_build_context_includes_all_four_sources() -> None:
|
||||
text = build_outline_context(
|
||||
project=_project(),
|
||||
foreshadow=[
|
||||
ForeshadowLedgerView(
|
||||
code="F1", title="神秘信物", status="OPEN", planted_at=3, expected_close_to=10
|
||||
)
|
||||
],
|
||||
characters=[CharacterView(name="主角", role="protagonist", motive="复仇")],
|
||||
world_entities=[WorldEntityView(type="location", name="剑冢")],
|
||||
)
|
||||
assert "逆天剑帝" in text
|
||||
assert "F1" in text and "神秘信物" in text
|
||||
assert "回收窗口 ?-10" in text
|
||||
assert "主角" in text
|
||||
assert "剑冢" in text
|
||||
|
||||
|
||||
def test_build_context_is_deterministic_and_sorted() -> None:
|
||||
project = _project()
|
||||
fs = [
|
||||
ForeshadowLedgerView(code="F2", title="b", status="OPEN"),
|
||||
ForeshadowLedgerView(code="F1", title="a", status="OPEN"),
|
||||
]
|
||||
a = build_outline_context(project=project, foreshadow=fs, characters=[], world_entities=[])
|
||||
b = build_outline_context(
|
||||
project=project, foreshadow=list(reversed(fs)), characters=[], world_entities=[]
|
||||
)
|
||||
assert a == b
|
||||
# F1 排在 F2 前(按 code 升序)。
|
||||
assert a.index("[F1]") < a.index("[F2]")
|
||||
|
||||
|
||||
def test_build_context_handles_empty_sources() -> None:
|
||||
text = build_outline_context(
|
||||
project=_project(), foreshadow=[], characters=[], world_entities=[]
|
||||
)
|
||||
assert "(无)" in text
|
||||
@@ -17,6 +17,7 @@ from fakes_projects import (
|
||||
FakeReviewGateway,
|
||||
FakeReviewRepo,
|
||||
FakeSession,
|
||||
FakeSessionFactory,
|
||||
)
|
||||
from test_projects import _empty_memory_repos
|
||||
from ww_agents import ContinuityReview
|
||||
@@ -50,6 +51,7 @@ def _make_client(
|
||||
get_memory_repos,
|
||||
get_review_gateway,
|
||||
get_review_repo,
|
||||
get_session_factory,
|
||||
)
|
||||
from ww_db import get_session
|
||||
|
||||
@@ -68,6 +70,7 @@ def _make_client(
|
||||
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
|
||||
app.dependency_overrides[get_session_factory] = lambda: FakeSessionFactory()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
client = httpx.AsyncClient(transport=transport, base_url="http://test")
|
||||
return client, chapter_repo, review_repo, digest_repo, session
|
||||
|
||||
@@ -12,7 +12,14 @@ from ww_shared import AppError, ErrorBody, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import configure_logging, get_logger
|
||||
from ww_api.middleware import request_id_middleware
|
||||
from ww_api.routers import health, jobs, projects, settings_providers
|
||||
from ww_api.routers import (
|
||||
foreshadow,
|
||||
health,
|
||||
jobs,
|
||||
outline,
|
||||
projects,
|
||||
settings_providers,
|
||||
)
|
||||
from ww_api.services.project_deps import seed_stub_user
|
||||
|
||||
configure_logging()
|
||||
@@ -48,6 +55,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(health.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(foreshadow.router)
|
||||
app.include_router(outline.router)
|
||||
app.include_router(settings_providers.router)
|
||||
return app
|
||||
|
||||
|
||||
159
apps/api/ww_api/routers/foreshadow.py
Normal file
159
apps/api/ww_api/routers/foreshadow.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""伏笔登记 / 状态变更端点(C3 扩 / ARCH §6.2, §7.2;不变量 #3)。
|
||||
|
||||
- POST /projects/:id/foreshadow 作者显式登记一条伏笔(status=OPEN)。
|
||||
- PATCH /projects/:id/foreshadow/:code 状态机转移 和/或 追加进展。
|
||||
|
||||
登记/改状态是**作者显式动作**(不变量 #3:伏笔入库不经 AI 静默写库;验收后到期扫描
|
||||
是确定性纯函数置位,见 `services/foreshadow_scan.py`)。
|
||||
|
||||
提交边界:`ForeshadowLedgerRepo` 写方法只 `flush()`,端点写后 `await session.commit()`。
|
||||
|
||||
**T3.5 续接点**:本 router 是 foreshadow 端点的归属处。T3.5 的看板查询
|
||||
`GET /projects/:id/foreshadow?status=` 直接加到本 router(调 `repo.list_by_status`);
|
||||
大纲 `POST /projects/:id/outline` 与伏笔无关,建议落独立 `routers/outline.py`
|
||||
(或 projects router),不混进本文件。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import ForeshadowLedgerRepo, ForeshadowLedgerView
|
||||
from ww_core.domain.foreshadow_state import ForeshadowStatus, InvalidTransition
|
||||
from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.foreshadow import (
|
||||
ForeshadowBoardResponse,
|
||||
ForeshadowRegisterRequest,
|
||||
ForeshadowTransitionRequest,
|
||||
ForeshadowView,
|
||||
)
|
||||
from ww_api.services.project_deps import get_foreshadow_repo
|
||||
|
||||
log = get_logger("ww.api.foreshadow")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["foreshadow"])
|
||||
|
||||
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _to_view(v: ForeshadowLedgerView) -> ForeshadowView:
|
||||
# ForeshadowLedgerView 与 ForeshadowView 字段同名,逐字段映射(snake_case 契约)。
|
||||
return ForeshadowView.model_validate(v, from_attributes=True)
|
||||
|
||||
|
||||
@router.post("/{project_id}/foreshadow", status_code=201)
|
||||
async def register_foreshadow(
|
||||
project_id: uuid.UUID,
|
||||
body: ForeshadowRegisterRequest,
|
||||
request: Request,
|
||||
repo: ForeshadowRepoDep,
|
||||
session: SessionDep,
|
||||
) -> ForeshadowView:
|
||||
"""登记一条伏笔(status=OPEN)。重复 `code` → DB 唯一约束冲突 → VALIDATION 信封。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
try:
|
||||
view = await repo.register(
|
||||
project_id,
|
||||
code=body.code,
|
||||
title=body.title,
|
||||
planted_at=body.planted_at,
|
||||
content=body.content,
|
||||
expected_close_from=body.expected_close_from,
|
||||
expected_close_to=body.expected_close_to,
|
||||
importance=body.importance,
|
||||
)
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
# `(project_id, code)` 唯一约束冲突(register 仅 INSERT、不 upsert,见 foreshadow_repo)。
|
||||
await session.rollback()
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"伏笔代号已存在:{body.code}",
|
||||
{"field": "code", "code": body.code, "reason": "duplicate"},
|
||||
) from exc
|
||||
log.info(
|
||||
"foreshadow_registered",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
code=body.code,
|
||||
)
|
||||
return _to_view(view)
|
||||
|
||||
|
||||
@router.get("/{project_id}/foreshadow")
|
||||
async def list_foreshadow(
|
||||
project_id: uuid.UUID,
|
||||
repo: ForeshadowRepoDep,
|
||||
status: str | None = None,
|
||||
) -> ForeshadowBoardResponse:
|
||||
"""伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
|
||||
|
||||
`status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE)→ VALIDATION 信封。四泳道前端
|
||||
据 `status` 分组;OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
|
||||
"""
|
||||
if status is not None and status not in {s.value for s in ForeshadowStatus}:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"非法状态过滤:{status}",
|
||||
{"field": "status", "reason": "invalid_status"},
|
||||
)
|
||||
views = await repo.list_by_status(project_id, status)
|
||||
return ForeshadowBoardResponse(foreshadow=[_to_view(v) for v in views])
|
||||
|
||||
|
||||
@router.patch("/{project_id}/foreshadow/{code}")
|
||||
async def update_foreshadow(
|
||||
project_id: uuid.UUID,
|
||||
code: str,
|
||||
body: ForeshadowTransitionRequest,
|
||||
request: Request,
|
||||
repo: ForeshadowRepoDep,
|
||||
session: SessionDep,
|
||||
) -> ForeshadowView:
|
||||
"""状态机转移 和/或 追加进展。非法转移 → VALIDATION 信封;不存在 → NOT_FOUND。
|
||||
|
||||
`to_status` 与 `progress_entry` 皆可选;两者都缺 → VALIDATION(无操作)。先转移、后追加。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
if body.to_status is None and body.progress_entry is None:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
"至少提供 `to_status` 或 `progress_entry` 之一",
|
||||
{"reason": "empty_update"},
|
||||
)
|
||||
|
||||
view: ForeshadowLedgerView
|
||||
try:
|
||||
if body.to_status is not None:
|
||||
view = await repo.transition(project_id, code, to_status=body.to_status)
|
||||
if body.progress_entry is not None:
|
||||
view = await repo.record_progress(project_id, code, entry=body.progress_entry)
|
||||
await session.commit()
|
||||
except InvalidTransition as exc:
|
||||
await session.rollback()
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
str(exc),
|
||||
{"field": "to_status", "code": code, "reason": "invalid_transition"},
|
||||
) from exc
|
||||
except LookupError as exc:
|
||||
await session.rollback()
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"foreshadow not found: {code}") from exc
|
||||
|
||||
log.info(
|
||||
"foreshadow_updated",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
code=code,
|
||||
to_status=body.to_status,
|
||||
has_progress=body.progress_entry is not None,
|
||||
)
|
||||
return _to_view(view)
|
||||
135
apps/api/ww_api/routers/outline.py
Normal file
135
apps/api/ww_api/routers/outline.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""大纲生成端点(C3 扩 / ARCH §5.4 outliner / §7.2 POST /outline;不变量 #2/#3)。
|
||||
|
||||
POST /projects/:id/outline:作者显式发起大纲生成 + 持久化。
|
||||
|
||||
流程:组确定性上下文(设定 + 已登记伏笔 + 人物 + 世界观)→ `run_outline`(analyst 网关,
|
||||
结构化 `OutlineResult`)→ 逐章 upsert `outline` 表 → 端点末尾一次 `commit()`。
|
||||
|
||||
提交边界:网关 ledger 只 flush(run_outline 产 1 条 usage)、`OutlineWriteRepo.upsert_chapter`
|
||||
只 flush → 端点末尾 `await session.commit()`(否则 usage_ledger + outline 行静默丢失,见 gotcha)。
|
||||
|
||||
不变量 #2:经 `build_gateway_for_tier(..., "analyst")` 注入;outliner_spec 只声明档位不传 model。
|
||||
不变量 #3:outliner `writes=["outline"]` 经此**作者发起**端点落地,非 AI 静默写其它表;
|
||||
run_outline 节点本身只读不写库。无凭据 → `LLM_UNAVAILABLE`(友好提示,仿 draft/review)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_agents import outliner_spec
|
||||
from ww_core.domain import ForeshadowLedgerRepo, OutlineWriteRepo, ProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.orchestrator import run_outline
|
||||
from ww_db import get_session
|
||||
from ww_llm_gateway import Gateway
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.outline import (
|
||||
ForeshadowWindowView,
|
||||
OutlineChapterView,
|
||||
OutlineGenerateRequest,
|
||||
OutlineResponse,
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.outline_context import build_outline_context
|
||||
from ww_api.services.project_deps import (
|
||||
get_foreshadow_repo,
|
||||
get_memory_repos,
|
||||
get_outline_gateway,
|
||||
get_outline_write_repo,
|
||||
get_project_repo,
|
||||
)
|
||||
|
||||
log = get_logger("ww.api.outline")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["outline"])
|
||||
|
||||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||||
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
|
||||
MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)]
|
||||
OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)]
|
||||
OutlineGatewayDep = Annotated[Gateway, Depends(get_outline_gateway)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
@router.post("/{project_id}/outline")
|
||||
async def generate_outline(
|
||||
project_id: uuid.UUID,
|
||||
body: OutlineGenerateRequest,
|
||||
request: Request,
|
||||
project_repo: ProjectRepoDep,
|
||||
foreshadow_repo: ForeshadowRepoDep,
|
||||
memory_repos: MemoryReposDep,
|
||||
outline_repo: OutlineWriteRepoDep,
|
||||
gateway: OutlineGatewayDep,
|
||||
session: SessionDep,
|
||||
) -> OutlineResponse:
|
||||
"""生成大纲并逐章持久化。无凭据 → LLM_UNAVAILABLE;项目不存在 → NOT_FOUND。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
project = await project_repo.get(STUB_OWNER_ID, project_id)
|
||||
if project is None:
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||||
|
||||
foreshadow = await foreshadow_repo.list_by_status(project_id, None)
|
||||
characters = await memory_repos.character.list_for_project(project_id)
|
||||
world_entities = await memory_repos.world_entity.list_for_project(project_id)
|
||||
context = build_outline_context(
|
||||
project=project,
|
||||
foreshadow=foreshadow,
|
||||
characters=characters,
|
||||
world_entities=world_entities,
|
||||
)
|
||||
|
||||
log.info(
|
||||
"outline_generate_start",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
volume=body.volume,
|
||||
context_len=len(context),
|
||||
foreshadow_count=len(foreshadow),
|
||||
)
|
||||
|
||||
# run_outline 产结构化大纲(analyst 网关;网关失败上抛,无凭据 → LLM_UNAVAILABLE)。
|
||||
result = await run_outline(
|
||||
outliner_spec,
|
||||
context=context,
|
||||
gateway=gateway,
|
||||
user_id=STUB_OWNER_ID,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
chapters: list[OutlineChapterView] = []
|
||||
for chapter in result.chapters:
|
||||
windows = [w.model_dump() for w in chapter.foreshadow_windows]
|
||||
await outline_repo.upsert_chapter(
|
||||
project_id,
|
||||
volume=body.volume,
|
||||
chapter_no=chapter.no,
|
||||
beats=chapter.beats,
|
||||
foreshadow_windows=windows,
|
||||
)
|
||||
chapters.append(
|
||||
OutlineChapterView(
|
||||
no=chapter.no,
|
||||
volume=body.volume,
|
||||
beats=list(chapter.beats),
|
||||
foreshadow_windows=[ForeshadowWindowView(**w) for w in windows],
|
||||
)
|
||||
)
|
||||
|
||||
# 提交边界:网关 ledger + outline upsert 均只 flush → 端点末尾一次 commit。
|
||||
await session.commit()
|
||||
|
||||
log.info(
|
||||
"outline_generate_done",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
chapter_count=len(chapters),
|
||||
)
|
||||
return OutlineResponse(chapters=chapters)
|
||||
@@ -17,7 +17,7 @@ import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain.chapter_repo import ChapterRepo
|
||||
@@ -59,6 +59,7 @@ from ww_api.services.accept_service import (
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.digest_extraction import extract_digest_facts
|
||||
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
|
||||
from ww_api.services.project_deps import (
|
||||
get_chapter_repo,
|
||||
get_digest_append_repo,
|
||||
@@ -67,6 +68,7 @@ from ww_api.services.project_deps import (
|
||||
get_project_repo,
|
||||
get_review_gateway,
|
||||
get_review_repo,
|
||||
get_session_factory,
|
||||
get_writer_gateway,
|
||||
)
|
||||
|
||||
@@ -302,14 +304,23 @@ async def accept_chapter(
|
||||
chapter_no: int,
|
||||
body: AcceptRequest,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
chapter_repo: ChapterRepoDep,
|
||||
digest_repo: DigestRepoDep,
|
||||
review_repo: ReviewRepoDep,
|
||||
gateway: DigestGatewayDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
session_factory: Annotated[SessionFactory, Depends(get_session_factory)],
|
||||
) -> AcceptResponse:
|
||||
"""验收事务 + 冲突 gate(§5.5):gate(事务前)→ 终稿提炼 digest(事务外,R2)→
|
||||
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
|
||||
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
|
||||
BackgroundTask(M3-b/d,§6.2)。
|
||||
|
||||
到期扫描接 §5.5 步骤 3 的 `TODO(M3)` 占位:验收提交成功**后**,把
|
||||
`current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 OVERDUE。
|
||||
伏笔登记/回收(PARTIAL/CLOSED)的「采纳建议」经作者经 foreshadow 端点显式确认,
|
||||
不在验收事务里 AI 静默改库(不变量 #3)。**§7.4 持久性局限**:BackgroundTask 进程内
|
||||
跑、重启丢失——原型可接受(见 `services/foreshadow_scan.py`,不引入 jobs 表)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
# R3:审稿真相从领域表重读(最近一条 chapter_reviews),不依赖 checkpoint。
|
||||
@@ -350,6 +361,17 @@ async def accept_chapter(
|
||||
latest_review=latest_review,
|
||||
decisions=body.decisions,
|
||||
)
|
||||
|
||||
# 验收**提交成功后**登记伏笔到期扫描 BackgroundTask(M3-b/d)。current_chapter=刚验收章号;
|
||||
# 任务在请求 session 关闭后跑,故 run_overdue_scan 自建新 session 并 commit(§7.4 局限可接受)。
|
||||
background_tasks.add_task(
|
||||
run_overdue_scan,
|
||||
session_factory,
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
return AcceptResponse(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
|
||||
66
apps/api/ww_api/schemas/foreshadow.py
Normal file
66
apps/api/ww_api/schemas/foreshadow.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""伏笔登记 / 状态变更端点的请求/响应 schema(C3 扩 / ARCH §6.2, §7.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
|
||||
登记是**作者显式动作**(不变量 #3:伏笔入库不经 AI 静默写库),到期扫描是验收后的
|
||||
确定性纯函数置位(M3-d),二者都不在审稿/写章流水线里。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ForeshadowRegisterRequest(BaseModel):
|
||||
"""POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。"""
|
||||
|
||||
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一")
|
||||
title: str = Field(min_length=1, description="伏笔标题/一句话描述")
|
||||
planted_at: int | None = Field(default=None, description="埋设章号")
|
||||
content: str | None = Field(default=None, description="伏笔正文/线索")
|
||||
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")
|
||||
expected_close_to: int | None = Field(
|
||||
default=None, description="预期回收窗口结束章(到期判据用)"
|
||||
)
|
||||
importance: str | None = Field(default=None, description="重要度(自由文本,看板用)")
|
||||
|
||||
|
||||
class ForeshadowTransitionRequest(BaseModel):
|
||||
"""PATCH /projects/:id/foreshadow/:code:状态机转移 和/或 追加一条进展。
|
||||
|
||||
两字段皆可选:`to_status` 走状态机(非法 → VALIDATION 信封);`progress_entry`
|
||||
append-only 追加到 progress JSONB。二者可同时给(先转移、后追加进展)。
|
||||
"""
|
||||
|
||||
to_status: str | None = Field(
|
||||
default=None, description="目标状态(OPEN/PARTIAL/CLOSED/OVERDUE)"
|
||||
)
|
||||
progress_entry: dict[str, Any] | None = Field(
|
||||
default=None, description="追加一条进展记录(append-only,不覆盖历史)"
|
||||
)
|
||||
|
||||
|
||||
class ForeshadowView(BaseModel):
|
||||
"""伏笔账本视图(登记/状态变更/看板共用;snake_case)。形对齐 `ForeshadowLedgerView`。"""
|
||||
|
||||
code: str
|
||||
title: str
|
||||
status: str
|
||||
planted_at: int | None = None
|
||||
content: str | None = None
|
||||
expected_close_from: int | None = None
|
||||
expected_close_to: int | None = None
|
||||
importance: str | None = None
|
||||
links: list[dict[str, Any]] = Field(default_factory=list)
|
||||
progress: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ForeshadowBoardResponse(BaseModel):
|
||||
"""GET /projects/:id/foreshadow?status=:伏笔看板(四泳道 + OVERDUE 字段齐)。
|
||||
|
||||
`status` 缺省返回全部;按 `code` 升序(repo `list_by_status` 已排序)。前端按
|
||||
`status` 分四泳道(OPEN/PARTIAL/CLOSED/OVERDUE),用 `expected_close_to` 标逾期。
|
||||
"""
|
||||
|
||||
foreshadow: list[ForeshadowView] = Field(default_factory=list)
|
||||
43
apps/api/ww_api/schemas/outline.py
Normal file
43
apps/api/ww_api/schemas/outline.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""大纲生成端点的请求/响应 schema(C3 扩 / ARCH §5.4, §7.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
|
||||
大纲生成是**作者显式动作**(不变量 #3:outliner `writes=["outline"]` 声明经此端点落地,
|
||||
非 AI 静默写其它表)。窗口字段供前端 T3.6 渲染伏笔窗口徽标。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OutlineGenerateRequest(BaseModel):
|
||||
"""POST /projects/:id/outline:触发大纲生成 + 持久化。
|
||||
|
||||
M3 简单分卷:`volume` 缺省=1(schema 无逐章卷号;本端点把整批章节落到同一卷)。
|
||||
"""
|
||||
|
||||
volume: int = Field(default=1, ge=1, description="本批大纲所属卷号(M3 默认全卷 1)")
|
||||
|
||||
|
||||
class ForeshadowWindowView(BaseModel):
|
||||
"""单章关联的伏笔回收窗口(snake_case;形对齐 `ww_agents.ForeshadowWindow`)。"""
|
||||
|
||||
code: str
|
||||
plant_chapter: int | None = None
|
||||
expected_close_from: int | None = None
|
||||
expected_close_to: int | None = None
|
||||
|
||||
|
||||
class OutlineChapterView(BaseModel):
|
||||
"""单章大纲视图:章号 + 卷 + 节拍 + 伏笔窗口。"""
|
||||
|
||||
no: int
|
||||
volume: int
|
||||
beats: list[str] = Field(default_factory=list)
|
||||
foreshadow_windows: list[ForeshadowWindowView] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OutlineResponse(BaseModel):
|
||||
"""大纲生成响应:逐章已持久化的大纲(含窗口,供前端 T3.6)。"""
|
||||
|
||||
chapters: list[OutlineChapterView] = Field(default_factory=list)
|
||||
@@ -114,9 +114,11 @@ async def run_accept_transaction(
|
||||
)
|
||||
review_id = updated.id
|
||||
|
||||
# 步骤 3(占位):人物 latest_state / 伏笔状态更新——M3 才正式接伏笔表,
|
||||
# 这里按 §5.5 步骤 3 留占位,不引入 M3 表逻辑(避免越界写未就绪的状态机)。
|
||||
# TODO(M3): 按裁决应用 latest_state 变更 + 伏笔登记/到期扫描(§6.2)。
|
||||
# 步骤 3:伏笔到期扫描——验收提交**后**经端点登记的 BackgroundTask 跑(M3-b/d,
|
||||
# `services/foreshadow_scan.run_overdue_scan`:current_ch > expected_close_to 且未 CLOSED
|
||||
# → 置 OVERDUE,确定性纯函数非 AI,不变量 #3)。放在事务外/提交后是因为它是验收**结果**
|
||||
# 的副作用、且自建独立 session(请求 session 此时已关闭)。
|
||||
# 人物 latest_state 更新仍留后续(M4+):本事务只落晋升 + digest + 裁决留痕。
|
||||
|
||||
await session.commit() # type: ignore[attr-defined] # AsyncSession.commit()(fake 同形)
|
||||
|
||||
|
||||
90
apps/api/ww_api/services/foreshadow_scan.py
Normal file
90
apps/api/ww_api/services/foreshadow_scan.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""验收后伏笔到期扫描(M3-b/d;ARCH §6.2, §7.4,不变量 #3)。
|
||||
|
||||
接 §5.5 验收事务后的 `TODO(M3)` 占位:验收**提交成功后**触发一次伏笔到期扫描,把
|
||||
`current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 `OVERDUE`(M3-d 纯
|
||||
函数,非 Agent;不变量 #3:状态变更是确定性扫描而非 AI 静默写库)。
|
||||
|
||||
挂载方式(端点):FastAPI `BackgroundTasks` 在 accept 端点登记 `run_overdue_scan`。
|
||||
BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故本函数**自建新
|
||||
session**(不复用请求 session)并自己 `commit()`。
|
||||
|
||||
**持久性局限(§7.4)**:BackgroundTasks 在 API 进程内跑,进程重启/崩溃会丢失未跑完的
|
||||
任务——原型可接受,这里把触发/完成/失败都记结构化日志便于查错;**不引入 `jobs` 表**
|
||||
(那是 M4/T4.1 的工作,本任务不越界)。
|
||||
|
||||
可测性:扫描逻辑抽成纯 async 函数 `run_overdue_scan(session_factory, ...)`,端点用
|
||||
BackgroundTasks 调它;单测直接 `await` 它(注入 fake session 工厂)断言行被置 OVERDUE,
|
||||
**绝不真起不可控后台线程**。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import Protocol
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import ForeshadowLedgerView, SqlForeshadowLedgerRepo
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# 新建独立 session 的工厂:调用得到一个 async 上下文管理器(`async with` → AsyncSession)。
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
|
||||
class OverdueScanRepo(Protocol):
|
||||
"""到期扫描对账本 repo 的最小依赖(仅 `scan_overdue`)。"""
|
||||
|
||||
async def scan_overdue(
|
||||
self, project_id: uuid.UUID, *, current_chapter: int
|
||||
) -> list[ForeshadowLedgerView]: ...
|
||||
|
||||
|
||||
# repo 工厂:从新 session 造账本 repo。默认建 SQL 实现;测试注入 fake(避免真连 DB)。
|
||||
RepoFactory = Callable[[AsyncSession], OverdueScanRepo]
|
||||
|
||||
|
||||
def _default_repo_factory(session: AsyncSession) -> OverdueScanRepo:
|
||||
return SqlForeshadowLedgerRepo(session)
|
||||
|
||||
|
||||
async def run_overdue_scan(
|
||||
session_factory: SessionFactory,
|
||||
*,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
request_id: str | None = None,
|
||||
repo_factory: RepoFactory = _default_repo_factory,
|
||||
) -> int:
|
||||
"""验收后伏笔到期扫描:新建 session → `scan_overdue` 置 OVERDUE → `commit()`。
|
||||
|
||||
返回被置 OVERDUE 的伏笔条数(供日志/测试断言)。任何异常被捕获并记错误日志后
|
||||
吞掉——后台任务失败不应冒泡崩进程,也不影响已成功的验收事务(§7.4 局限可接受)。
|
||||
`repo_factory` 是可注入缝:默认建 SQL 账本 repo,测试注 fake(直接 await、不联网)。
|
||||
"""
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
repo = repo_factory(session)
|
||||
changed = await repo.scan_overdue(project_id, current_chapter=chapter_no)
|
||||
if changed:
|
||||
await session.commit()
|
||||
log.info(
|
||||
"foreshadow_overdue_scan",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
overdue_count=len(changed),
|
||||
overdue_codes=[v.code for v in changed],
|
||||
)
|
||||
return len(changed)
|
||||
except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误、不冒泡崩进程(§7.4)。
|
||||
log.error(
|
||||
"foreshadow_overdue_scan_failed",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return 0
|
||||
87
apps/api/ww_api/services/outline_context.py
Normal file
87
apps/api/ww_api/services/outline_context.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""大纲生成的注入上下文组装(确定性序列化文本;ARCH §5.4 outliner reads / §6.2)。
|
||||
|
||||
把 projects 设定 + 已登记伏笔 + 人物 + 世界观确定性序列化成纯文本喂给 outliner——
|
||||
**无时间戳/无 UUID**(缓存前缀稳定,不变量 #9),逐项排序保证同输入同输出(可单测)。
|
||||
outliner 据此排出分章节拍 + 伏笔回收窗口(关联本章与伏笔,§6.2)。
|
||||
|
||||
注:reads=["projects","foreshadow","characters","world_entities"](C6 outliner_spec),
|
||||
本组装恰好覆盖这四源,不读 digest/outline(排大纲是从设定生成、非续写既有章)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from ww_core.domain import ForeshadowLedgerView, ProjectView
|
||||
from ww_core.domain.repositories import CharacterView, WorldEntityView
|
||||
|
||||
|
||||
def _project_block(project: ProjectView) -> str:
|
||||
lines = [f"标题:{project.title}"]
|
||||
if project.genre:
|
||||
lines.append(f"类型:{project.genre}")
|
||||
if project.logline:
|
||||
lines.append(f"一句话简介:{project.logline}")
|
||||
if project.premise:
|
||||
lines.append(f"前提:{project.premise}")
|
||||
if project.theme:
|
||||
lines.append(f"主题:{project.theme}")
|
||||
if project.selling_points:
|
||||
lines.append("卖点:" + "、".join(project.selling_points))
|
||||
if project.structure:
|
||||
lines.append(f"结构:{project.structure}")
|
||||
return "【作品设定】\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _foreshadow_block(foreshadow: Sequence[ForeshadowLedgerView]) -> str:
|
||||
if not foreshadow:
|
||||
return "【已登记伏笔】\n(无)"
|
||||
rows = sorted(foreshadow, key=lambda f: f.code)
|
||||
lines = []
|
||||
for f in rows:
|
||||
window = ""
|
||||
if f.expected_close_from is not None or f.expected_close_to is not None:
|
||||
lo = f.expected_close_from if f.expected_close_from is not None else "?"
|
||||
hi = f.expected_close_to if f.expected_close_to is not None else "?"
|
||||
window = f"(回收窗口 {lo}-{hi})"
|
||||
plant = f"埋设第{f.planted_at}章 " if f.planted_at is not None else ""
|
||||
lines.append(f"- [{f.code}] {f.title} {plant}{window}".rstrip())
|
||||
return "【已登记伏笔】\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _characters_block(characters: Sequence[CharacterView]) -> str:
|
||||
if not characters:
|
||||
return "【人物】\n(无)"
|
||||
rows = sorted(characters, key=lambda c: c.name)
|
||||
lines = []
|
||||
for c in rows:
|
||||
role = f"({c.role})" if c.role else ""
|
||||
motive = f" 动机:{c.motive}" if c.motive else ""
|
||||
lines.append(f"- {c.name}{role}{motive}".rstrip())
|
||||
return "【人物】\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _world_block(entities: Sequence[WorldEntityView]) -> str:
|
||||
if not entities:
|
||||
return "【世界观】\n(无)"
|
||||
rows = sorted(entities, key=lambda e: (e.type, e.name))
|
||||
lines = [f"- [{e.type}] {e.name}" for e in rows]
|
||||
return "【世界观】\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def build_outline_context(
|
||||
*,
|
||||
project: ProjectView,
|
||||
foreshadow: Sequence[ForeshadowLedgerView],
|
||||
characters: Sequence[CharacterView],
|
||||
world_entities: Sequence[WorldEntityView],
|
||||
) -> str:
|
||||
"""组装 outliner 注入上下文(确定性文本,纯函数)。"""
|
||||
return "\n\n".join(
|
||||
[
|
||||
_project_block(project),
|
||||
_foreshadow_block(foreshadow),
|
||||
_characters_block(characters),
|
||||
_world_block(world_entities),
|
||||
]
|
||||
)
|
||||
@@ -16,13 +16,15 @@ from openai import AsyncOpenAI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
|
||||
from ww_core.memory.sql_repositories import sql_memory_repos
|
||||
from ww_db import get_session
|
||||
from ww_db import get_session, get_sessionmaker
|
||||
from ww_db.models import User
|
||||
from ww_llm_gateway import (
|
||||
Gateway,
|
||||
@@ -42,6 +44,7 @@ from ww_api.services.credentials import (
|
||||
CredentialStore,
|
||||
SqlCredentialStore,
|
||||
)
|
||||
from ww_api.services.foreshadow_scan import SessionFactory
|
||||
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
|
||||
|
||||
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
|
||||
@@ -95,6 +98,30 @@ def get_digest_append_repo(
|
||||
return SqlDigestAppendRepo(session)
|
||||
|
||||
|
||||
def get_foreshadow_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> ForeshadowLedgerRepo:
|
||||
"""伏笔账本写侧 repo(登记/状态变更端点;只 flush,端点提交)。测试经 override 注 fake。"""
|
||||
return SqlForeshadowLedgerRepo(session)
|
||||
|
||||
|
||||
def get_outline_write_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> OutlineWriteRepo:
|
||||
"""大纲写侧 repo(大纲生成端点逐章 upsert;只 flush,端点提交)。测试经 override 注 fake。"""
|
||||
return SqlOutlineWriteRepo(session)
|
||||
|
||||
|
||||
def get_session_factory() -> SessionFactory:
|
||||
"""验收后到期扫描的**独立 session 工厂**缝。
|
||||
|
||||
BackgroundTask 在请求 session 关闭后才跑,必须自建 session(不复用 `get_session`)。
|
||||
返回的工厂 `()` → `async with` 得一个新 `AsyncSession`。测试经 `app.dependency_overrides`
|
||||
注入 fake 工厂(避免真起后台线程/真连 DB)。
|
||||
"""
|
||||
return get_sessionmaker()
|
||||
|
||||
|
||||
async def build_gateway_for_tier(
|
||||
session: AsyncSession, store: CredentialStore, tier: Tier
|
||||
) -> Gateway:
|
||||
@@ -160,3 +187,11 @@ async def get_digest_gateway(
|
||||
"""验收终稿 digest 提炼(light 档位)的可注入网关缝。测试经 override 注 mock。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "light")
|
||||
|
||||
|
||||
async def get_outline_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""大纲生成(analyst 档位)的可注入网关缝。测试经 override 注 mock(产 OutlineResult)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
23
apps/web/app/projects/[id]/foreshadow/page.tsx
Normal file
23
apps/web/app/projects/[id]/foreshadow/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { ForeshadowBoard } from "@/components/foreshadow/ForeshadowBoard";
|
||||
import { fetchForeshadow, fetchProject } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 伏笔看板页(UX §6.8)。Server Component 取项目 + 全量伏笔(GET .../foreshadow);
|
||||
// ForeshadowBoard(Client)按 status 分四泳道并承载登记/状态变更交互。
|
||||
export default async function ForeshadowPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
const board = await fetchForeshadow(id);
|
||||
return (
|
||||
<ForeshadowBoard project={project} initialItems={board.foreshadow ?? []} />
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
20
apps/web/app/projects/[id]/outline/page.tsx
Normal file
20
apps/web/app/projects/[id]/outline/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { OutlineEditor } from "@/components/outline/OutlineEditor";
|
||||
import { fetchProject } from "@/lib/api/server";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 大纲编辑器页(UX §6.7)。Server Component 取项目;OutlineEditor(Client)按需
|
||||
// 触发 POST .../outline 生成大纲(M3 无 GET 大纲端点,进页空、生成后填充)。
|
||||
export default async function OutlinePage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
try {
|
||||
const project = await fetchProject(id);
|
||||
return <OutlineEditor project={project} initialChapters={[]} />;
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,20 @@ interface AppShellProps {
|
||||
// 顶栏右侧的上下文信息(如作品名 / 进度)。
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
// 项目内页给出 projectId → 左导航展开项目级入口(大纲/伏笔/审稿,UX §3.1)。
|
||||
projectId?: string;
|
||||
// 当前激活的项目级入口键(用于高亮)。
|
||||
activeNav?: "write" | "outline" | "foreshadow" | "review";
|
||||
}
|
||||
|
||||
// 全局外壳:顶栏(64px) + 左导航 + 主区(UX §5.1)。
|
||||
export function AppShell({ children, title, subtitle }: AppShellProps) {
|
||||
export function AppShell({
|
||||
children,
|
||||
title,
|
||||
subtitle,
|
||||
projectId,
|
||||
activeNav,
|
||||
}: AppShellProps) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<header className="flex h-16 items-center gap-4 border-b border-line bg-panel px-6">
|
||||
@@ -38,7 +48,7 @@ export function AppShell({ children, title, subtitle }: AppShellProps) {
|
||||
</nav>
|
||||
</header>
|
||||
<div className="flex">
|
||||
<LeftNav />
|
||||
<LeftNav projectId={projectId} activeNav={activeNav} />
|
||||
<main className="min-h-[calc(100vh-4rem)] flex-1 bg-bg">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,64 +3,126 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export type ActiveNav = "write" | "outline" | "foreshadow" | "review";
|
||||
|
||||
interface LeftNavProps {
|
||||
// 项目内页传 projectId → 展开项目级入口(大纲/伏笔/审稿,UX §3.1)。
|
||||
projectId?: string;
|
||||
activeNav?: ActiveNav;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
// M1 仅作品库 + 设置可达;其余为后续里程碑占位(禁用)。
|
||||
enabled: boolean;
|
||||
// 项目级入口的激活键(与 activeNav 比对高亮)。
|
||||
key?: ActiveNav;
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
const GLOBAL_ITEMS: NavItem[] = [
|
||||
{ href: "/", label: "作品库", enabled: true },
|
||||
{ href: "/settings/providers", label: "设置", enabled: true },
|
||||
{ href: "#outline", label: "大纲", enabled: false },
|
||||
{ href: "#codex", label: "设定库", enabled: false },
|
||||
{ href: "#foreshadow", label: "伏笔", enabled: false },
|
||||
{ href: "#review", label: "审稿", enabled: false },
|
||||
{ href: "#style", label: "文风", enabled: false },
|
||||
];
|
||||
|
||||
// 左导航:当前项朱砂竖条 + 浅晕底(UX §5.1)。
|
||||
export function LeftNav() {
|
||||
function projectItems(projectId: string): NavItem[] {
|
||||
return [
|
||||
{
|
||||
href: `/projects/${projectId}/write`,
|
||||
label: "写作",
|
||||
enabled: true,
|
||||
key: "write",
|
||||
},
|
||||
{
|
||||
href: `/projects/${projectId}/outline`,
|
||||
label: "大纲",
|
||||
enabled: true,
|
||||
key: "outline",
|
||||
},
|
||||
{
|
||||
href: `/projects/${projectId}/foreshadow`,
|
||||
label: "伏笔",
|
||||
enabled: true,
|
||||
key: "foreshadow",
|
||||
},
|
||||
{
|
||||
href: `/projects/${projectId}/review`,
|
||||
label: "审稿",
|
||||
enabled: true,
|
||||
key: "review",
|
||||
},
|
||||
{ href: "#codex", label: "设定库", enabled: false },
|
||||
{ href: "#style", label: "文风", enabled: false },
|
||||
];
|
||||
}
|
||||
|
||||
// 左导航:当前项朱砂竖条 + 浅晕底(UX §5.1 / §3.1)。
|
||||
export function LeftNav({ projectId, activeNav }: LeftNavProps) {
|
||||
const pathname = usePathname();
|
||||
const scoped = projectId ? projectItems(projectId) : [];
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="w-44 shrink-0 border-r border-line bg-panel py-4"
|
||||
aria-label="主导航"
|
||||
>
|
||||
<ul className="flex flex-col gap-1 px-2">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.enabled && pathname === item.href;
|
||||
if (!item.enabled) {
|
||||
return (
|
||||
<li key={item.label}>
|
||||
<span
|
||||
className="block cursor-not-allowed rounded px-3 py-2 text-sm text-ink-soft/50"
|
||||
aria-disabled="true"
|
||||
title="后续里程碑开放"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={item.label}>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
|
||||
active
|
||||
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{GLOBAL_ITEMS.map((item) => (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
item={item}
|
||||
active={item.enabled && pathname === item.href}
|
||||
/>
|
||||
))}
|
||||
{scoped.length > 0 ? (
|
||||
<li
|
||||
className="mt-3 px-3 pb-1 text-xs font-semibold uppercase tracking-wide text-ink-soft/60"
|
||||
aria-hidden="true"
|
||||
>
|
||||
本作
|
||||
</li>
|
||||
) : null}
|
||||
{scoped.map((item) => (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
item={item}
|
||||
active={
|
||||
item.key !== undefined &&
|
||||
(item.key === activeNav || pathname === item.href)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ item, active }: { item: NavItem; active: boolean }) {
|
||||
if (!item.enabled) {
|
||||
return (
|
||||
<li>
|
||||
<span
|
||||
className="block cursor-not-allowed rounded px-3 py-2 text-sm text-ink-soft/50"
|
||||
aria-disabled="true"
|
||||
title="后续里程碑开放"
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li>
|
||||
<Link
|
||||
href={item.href}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`flex items-center gap-2 rounded px-3 py-2 text-sm ${
|
||||
active
|
||||
? "border-l-2 border-cinnabar bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
: "text-ink hover:bg-[var(--color-cinnabar-wash)]"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
72
apps/web/components/foreshadow/ForeshadowBoard.tsx
Normal file
72
apps/web/components/foreshadow/ForeshadowBoard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { ForeshadowView, ProjectResponse } from "@/lib/api/types";
|
||||
import {
|
||||
LANES,
|
||||
groupByStatus,
|
||||
type ForeshadowStatus,
|
||||
} from "@/lib/foreshadow/board";
|
||||
import { useForeshadow } from "@/lib/foreshadow/useForeshadow";
|
||||
import { KanbanColumn } from "./KanbanColumn";
|
||||
import { RegisterForm } from "./RegisterForm";
|
||||
|
||||
interface ForeshadowBoardProps {
|
||||
project: ProjectResponse;
|
||||
initialItems: ForeshadowView[];
|
||||
}
|
||||
|
||||
// 伏笔看板主体(UX §6.8):四泳道 + 登记/状态变更交互。
|
||||
// RSC 取全量种入;Client 承载登记/转移(乐观 + 回滚 + Toast)。
|
||||
export function ForeshadowBoard({
|
||||
project,
|
||||
initialItems,
|
||||
}: ForeshadowBoardProps) {
|
||||
const { items, busy, register, transition } = useForeshadow(initialItems);
|
||||
const lanes = useMemo(() => groupByStatus(items), [items]);
|
||||
|
||||
const onTransition = (
|
||||
code: string,
|
||||
toStatus: ForeshadowStatus | null,
|
||||
progressNote: string,
|
||||
): void => {
|
||||
void transition(code, {
|
||||
projectId: project.id,
|
||||
toStatus,
|
||||
progressEntry: progressNote ? { note: progressNote } : null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title={`〈${project.title}〉`}
|
||||
subtitle="伏笔看板"
|
||||
projectId={project.id}
|
||||
activeNav="foreshadow"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col p-4">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<h1 className="font-serif text-lg text-ink">伏笔看板</h1>
|
||||
<RegisterForm
|
||||
projectId={project.id}
|
||||
busy={busy}
|
||||
onRegister={register}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{LANES.map((status) => (
|
||||
<KanbanColumn
|
||||
key={status}
|
||||
status={status}
|
||||
items={lanes[status]}
|
||||
busy={busy}
|
||||
onTransition={onTransition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
133
apps/web/components/foreshadow/ForeshadowCard.tsx
Normal file
133
apps/web/components/foreshadow/ForeshadowCard.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
|
||||
interface ForeshadowCardProps {
|
||||
item: ForeshadowView;
|
||||
overdue: boolean;
|
||||
busy: boolean;
|
||||
onTransition: (
|
||||
code: string,
|
||||
toStatus: ForeshadowStatus | null,
|
||||
progressNote: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
const NEXT_STATUS: Record<string, ForeshadowStatus[]> = {
|
||||
OPEN: ["PARTIAL", "CLOSED"],
|
||||
PARTIAL: ["CLOSED"],
|
||||
OVERDUE: ["PARTIAL", "CLOSED"],
|
||||
CLOSED: [],
|
||||
};
|
||||
|
||||
// 伏笔卡(UX §6.8):code/title/回收窗口/importance/progress 时间线 + 状态变更/加进展。
|
||||
// OVERDUE 用琥珀强调(不单靠色:加 ⚠ 图标 + 文案,a11y §10)。
|
||||
export function ForeshadowCard({
|
||||
item,
|
||||
overdue,
|
||||
busy,
|
||||
onTransition,
|
||||
}: ForeshadowCardProps) {
|
||||
const [note, setNote] = useState("");
|
||||
const transitions = NEXT_STATUS[item.status] ?? [];
|
||||
const windowText = recoveryWindow(item);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`rounded border bg-panel p-3 ${
|
||||
overdue ? "border-overdue ring-1 ring-overdue/40" : "border-line"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-mono text-xs text-cinnabar">{item.code}</span>
|
||||
{overdue ? (
|
||||
<span className="text-xs text-overdue" aria-label="逾期">
|
||||
⚠ 逾期
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-0.5 font-serif text-sm text-ink">{item.title}</p>
|
||||
|
||||
<dl className="mt-2 space-y-0.5 text-xs text-ink-soft">
|
||||
{typeof item.planted_at === "number" ? (
|
||||
<div>埋于 {item.planted_at} 章</div>
|
||||
) : null}
|
||||
{windowText ? <div>窗口 {windowText}</div> : null}
|
||||
{item.importance ? <div>{item.importance}</div> : null}
|
||||
</dl>
|
||||
|
||||
{item.progress && item.progress.length > 0 ? (
|
||||
<ul className="mt-2 border-l-2 border-line pl-2 text-xs text-ink-soft">
|
||||
{item.progress.map((p, i) => (
|
||||
<li key={i}>· {progressLabel(p)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{transitions.length > 0 || item.status !== "CLOSED" ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{transitions.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{transitions.map((to) => (
|
||||
<button
|
||||
key={to}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onTransition(item.code, to, "")}
|
||||
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar hover:text-cinnabar disabled:opacity-40"
|
||||
>
|
||||
→ {to === "CLOSED" ? "回收" : to}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{item.status !== "CLOSED" ? (
|
||||
<div className="flex gap-1.5">
|
||||
<label htmlFor={`prog-${item.code}`} className="sr-only">
|
||||
{item.code} 进展
|
||||
</label>
|
||||
<input
|
||||
id={`prog-${item.code}`}
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder="加进展(如:23章发光)"
|
||||
className="min-w-0 flex-1 rounded border border-line bg-bg px-2 py-0.5 text-xs text-ink focus:border-cinnabar focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || note.trim().length === 0}
|
||||
onClick={() => {
|
||||
onTransition(item.code, null, note.trim());
|
||||
setNote("");
|
||||
}}
|
||||
className="rounded border border-line px-2 py-0.5 text-xs text-ink hover:border-cinnabar disabled:opacity-40"
|
||||
>
|
||||
记
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function recoveryWindow(item: ForeshadowView): string | null {
|
||||
const from = item.expected_close_from;
|
||||
const to = item.expected_close_to;
|
||||
if (typeof from === "number" && typeof to === "number") return `${from}-${to}`;
|
||||
if (typeof to === "number") return `≤${to}`;
|
||||
if (typeof from === "number") return `≥${from}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function progressLabel(entry: Record<string, unknown>): string {
|
||||
const chapter = entry["chapter"];
|
||||
const note = entry["note"];
|
||||
const noteStr = typeof note === "string" ? note : JSON.stringify(entry);
|
||||
return typeof chapter === "number" ? `${chapter}章:${noteStr}` : noteStr;
|
||||
}
|
||||
61
apps/web/components/foreshadow/KanbanColumn.tsx
Normal file
61
apps/web/components/foreshadow/KanbanColumn.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import type { ForeshadowStatus } from "@/lib/foreshadow/board";
|
||||
import { ForeshadowCard } from "./ForeshadowCard";
|
||||
|
||||
interface KanbanColumnProps {
|
||||
status: ForeshadowStatus;
|
||||
items: ForeshadowView[];
|
||||
busy: boolean;
|
||||
onTransition: (
|
||||
code: string,
|
||||
toStatus: ForeshadowStatus | null,
|
||||
progressNote: string,
|
||||
) => void;
|
||||
}
|
||||
|
||||
// 单个状态泳道(UX §6.8)。OVERDUE 列整列琥珀强调(表头 + 卡片)。
|
||||
export function KanbanColumn({
|
||||
status,
|
||||
items,
|
||||
busy,
|
||||
onTransition,
|
||||
}: KanbanColumnProps) {
|
||||
const overdue = status === "OVERDUE";
|
||||
return (
|
||||
<section
|
||||
className="flex min-w-0 flex-col rounded border border-line bg-bg"
|
||||
aria-label={`${status} 泳道`}
|
||||
>
|
||||
<header
|
||||
className={`flex items-center justify-between rounded-t border-b px-3 py-2 ${
|
||||
overdue
|
||||
? "border-overdue bg-overdue/10 text-overdue"
|
||||
: "border-line bg-panel text-ink-soft"
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{overdue ? "⚠ " : ""}
|
||||
{status}
|
||||
</span>
|
||||
<span className="font-mono text-xs">{items.length}</span>
|
||||
</header>
|
||||
<ul className="flex-1 space-y-2 overflow-auto p-2">
|
||||
{items.length === 0 ? (
|
||||
<li className="px-1 py-3 text-xs text-ink-soft/60">—</li>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<ForeshadowCard
|
||||
key={item.code}
|
||||
item={item}
|
||||
overdue={overdue}
|
||||
busy={busy}
|
||||
onTransition={onTransition}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
185
apps/web/components/foreshadow/RegisterForm.tsx
Normal file
185
apps/web/components/foreshadow/RegisterForm.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { RegisterInput } from "@/lib/foreshadow/board";
|
||||
|
||||
interface RegisterFormProps {
|
||||
projectId: string;
|
||||
busy: boolean;
|
||||
onRegister: (input: RegisterInput) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const EMPTY = {
|
||||
code: "",
|
||||
title: "",
|
||||
plantedAt: "",
|
||||
content: "",
|
||||
expectedCloseFrom: "",
|
||||
expectedCloseTo: "",
|
||||
importance: "",
|
||||
};
|
||||
|
||||
// 登记新伏笔(UX §6.8)。受控表单 → 组装 RegisterInput;成功后清空。
|
||||
export function RegisterForm({
|
||||
projectId,
|
||||
busy,
|
||||
onRegister,
|
||||
}: RegisterFormProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState({ ...EMPTY });
|
||||
|
||||
const set = (key: keyof typeof EMPTY, value: string): void =>
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
const ok = await onRegister({
|
||||
projectId,
|
||||
code: form.code,
|
||||
title: form.title,
|
||||
plantedAt: toInt(form.plantedAt),
|
||||
content: form.content || null,
|
||||
expectedCloseFrom: toInt(form.expectedCloseFrom),
|
||||
expectedCloseTo: toInt(form.expectedCloseTo),
|
||||
importance: form.importance || null,
|
||||
});
|
||||
if (ok) {
|
||||
setForm({ ...EMPTY });
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90"
|
||||
>
|
||||
+ 登记伏笔
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
form.code.trim().length > 0 && form.title.trim().length > 0 && !busy;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void submit();
|
||||
}}
|
||||
className="rounded border border-line bg-panel p-4"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<Field label="代号 *" id="f-code">
|
||||
<input
|
||||
id="f-code"
|
||||
value={form.code}
|
||||
onChange={(e) => set("code", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="标题 *" id="f-title" span2>
|
||||
<input
|
||||
id="f-title"
|
||||
value={form.title}
|
||||
onChange={(e) => set("title", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="埋设章" id="f-planted">
|
||||
<input
|
||||
id="f-planted"
|
||||
inputMode="numeric"
|
||||
value={form.plantedAt}
|
||||
onChange={(e) => set("plantedAt", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口起" id="f-from">
|
||||
<input
|
||||
id="f-from"
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseFrom}
|
||||
onChange={(e) => set("expectedCloseFrom", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="回收窗口止" id="f-to">
|
||||
<input
|
||||
id="f-to"
|
||||
inputMode="numeric"
|
||||
value={form.expectedCloseTo}
|
||||
onChange={(e) => set("expectedCloseTo", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="重要度" id="f-imp">
|
||||
<input
|
||||
id="f-imp"
|
||||
value={form.importance}
|
||||
onChange={(e) => set("importance", e.target.value)}
|
||||
placeholder="主线/支线"
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="线索内容" id="f-content" span3>
|
||||
<input
|
||||
id="f-content"
|
||||
value={form.content}
|
||||
onChange={(e) => set("content", e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{busy ? "登记中…" : "登记"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded border border-line px-3 py-1.5 text-sm text-ink-soft hover:border-cinnabar"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none";
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
id: string;
|
||||
span2?: boolean;
|
||||
span3?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Field({ label, id, span2, span3, children }: FieldProps) {
|
||||
const span = span3 ? "col-span-2 sm:col-span-3" : span2 ? "col-span-2" : "";
|
||||
return (
|
||||
<div className={span}>
|
||||
<label htmlFor={id} className="block text-xs text-ink-soft">
|
||||
{label}
|
||||
</label>
|
||||
<div className="mt-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toInt(raw: string): number | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const n = Number.parseInt(trimmed, 10);
|
||||
return Number.isInteger(n) ? n : null;
|
||||
}
|
||||
51
apps/web/components/outline/OutlineChapterRow.tsx
Normal file
51
apps/web/components/outline/OutlineChapterRow.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import type { OutlineChapterView } from "@/lib/api/types";
|
||||
import {
|
||||
isCloseWindow,
|
||||
windowBadgeLabel,
|
||||
} from "@/lib/outline/outline";
|
||||
|
||||
interface OutlineChapterRowProps {
|
||||
chapter: OutlineChapterView;
|
||||
}
|
||||
|
||||
// 单章大纲行(UX §6.7):章号 + beats + 伏笔窗口徽标(⚑)。
|
||||
// 接近回收窗口的徽标用琥珀 + 「可回收」提示(a11y:图标 + 文案,不单靠色)。
|
||||
export function OutlineChapterRow({ chapter }: OutlineChapterRowProps) {
|
||||
const windows = chapter.foreshadow_windows ?? [];
|
||||
return (
|
||||
<li className="border-l-2 border-line py-2 pl-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
第 {chapter.no} 章
|
||||
</span>
|
||||
{windows.map((w) => {
|
||||
const close = isCloseWindow(chapter, w);
|
||||
return (
|
||||
<span
|
||||
key={w.code}
|
||||
title={close ? "进入回收窗口,建议本章安排回收" : undefined}
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
close
|
||||
? "bg-overdue/15 text-overdue"
|
||||
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
{windowBadgeLabel(w)}
|
||||
{close ? " · 可回收" : ""}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{chapter.beats && chapter.beats.length > 0 ? (
|
||||
<p className="mt-1 text-sm text-ink">
|
||||
<span className="text-ink-soft">beats: </span>
|
||||
{chapter.beats.join(" / ")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-sm text-ink-soft/60">(无节拍)</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
97
apps/web/components/outline/OutlineEditor.tsx
Normal file
97
apps/web/components/outline/OutlineEditor.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import type { OutlineChapterView, ProjectResponse } from "@/lib/api/types";
|
||||
import { groupByVolume } from "@/lib/outline/outline";
|
||||
import { useOutline } from "@/lib/outline/useOutline";
|
||||
import { OutlineChapterRow } from "./OutlineChapterRow";
|
||||
|
||||
interface OutlineEditorProps {
|
||||
project: ProjectResponse;
|
||||
initialChapters: OutlineChapterView[];
|
||||
}
|
||||
|
||||
// 大纲编辑器主体(UX §6.7)。RSC 种入已存大纲;Client 承载生成(POST .../outline)。
|
||||
// 无凭据 → 503 LLM_UNAVAILABLE 经 error 检出、引导去设置。
|
||||
export function OutlineEditor({
|
||||
project,
|
||||
initialChapters,
|
||||
}: OutlineEditorProps) {
|
||||
const { chapters, status, error, generate } = useOutline(initialChapters);
|
||||
const [volume, setVolume] = useState(1);
|
||||
const volumes = useMemo(() => groupByVolume(chapters), [chapters]);
|
||||
const generating = status === "generating";
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title={`〈${project.title}〉`}
|
||||
subtitle="大纲"
|
||||
projectId={project.id}
|
||||
activeNav="outline"
|
||||
>
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<h1 className="font-serif text-lg text-ink">大纲</h1>
|
||||
<label htmlFor="vol" className="ml-auto text-xs text-ink-soft">
|
||||
卷号
|
||||
</label>
|
||||
<input
|
||||
id="vol"
|
||||
inputMode="numeric"
|
||||
value={volume}
|
||||
onChange={(e) => setVolume(Math.max(1, Number(e.target.value) || 1))}
|
||||
className="w-16 rounded border border-line bg-bg px-2 py-1 text-sm text-ink focus:border-cinnabar focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={generating}
|
||||
onClick={() => void generate(project.id, volume)}
|
||||
className="rounded bg-cinnabar px-3 py-1.5 text-sm text-panel hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{generating ? "排大纲中…" : "✦ AI 排大纲"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mb-4 text-sm text-conflict">
|
||||
大纲生成失败({error.code}):{error.message}
|
||||
{error.code === "LLM_UNAVAILABLE" ? (
|
||||
<>
|
||||
{" "}
|
||||
<a
|
||||
href="/settings/providers"
|
||||
className="underline hover:text-cinnabar"
|
||||
>
|
||||
去设置提供商
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{volumes.length === 0 ? (
|
||||
<p className="rounded border border-dashed border-line p-6 text-sm text-ink-soft">
|
||||
暂无大纲。点「✦ AI 排大纲」生成逐章节拍与伏笔窗口。
|
||||
</p>
|
||||
) : (
|
||||
volumes.map((group) => (
|
||||
<section key={group.volume} className="mb-6">
|
||||
<h2 className="mb-2 font-serif text-base text-ink">
|
||||
卷 {group.volume}
|
||||
</h2>
|
||||
<ul className="space-y-1">
|
||||
{group.chapters.map((ch) => (
|
||||
<OutlineChapterRow key={ch.no} chapter={ch} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
34
apps/web/components/review/BeatMap.tsx
Normal file
34
apps/web/components/review/BeatMap.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { toBeatCells, toSparkline } from "@/lib/pace/beatmap";
|
||||
|
||||
interface BeatMapProps {
|
||||
beatMap: number[];
|
||||
}
|
||||
|
||||
// 爽点节拍图(UX §6.4):beat_map(int 序列) → ▁▃▅ 柱状条。
|
||||
// 渲染为定高柱(CSS height%),同时给文本 sparkline 作无障碍标签(a11y §10)。
|
||||
// 静态高度,无动画 → 天然尊重 prefers-reduced-motion。
|
||||
export function BeatMap({ beatMap }: BeatMapProps) {
|
||||
const cells = toBeatCells(beatMap);
|
||||
if (cells.length === 0) {
|
||||
return <span className="text-xs text-ink-soft/60">无节拍数据</span>;
|
||||
}
|
||||
const spark = toSparkline(beatMap);
|
||||
return (
|
||||
<div
|
||||
className="flex h-12 items-end gap-0.5"
|
||||
role="img"
|
||||
aria-label={`爽点节拍图 ${spark}`}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-w-[4px] flex-1 rounded-t bg-cinnabar/70"
|
||||
style={{ height: `${cell.heightPct}%` }}
|
||||
title={`第${i + 1}拍 强度${cell.value}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
apps/web/components/review/ForeshadowSuggestions.tsx
Normal file
58
apps/web/components/review/ForeshadowSuggestions.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import type { ForeshadowSuggestion } from "@/lib/review/sse";
|
||||
|
||||
interface ForeshadowSuggestionsProps {
|
||||
suggestions: ForeshadowSuggestion[];
|
||||
// 审项是否未完成(incomplete)。
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
// 伏笔建议区(UX §6.4 ②):planted「新埋待确认」/ resolved「疑似回收」,只读建议。
|
||||
// 不静默写库(不变量#3):仅展示,登记/回收经伏笔看板显式确认。
|
||||
export function ForeshadowSuggestions({
|
||||
suggestions,
|
||||
incomplete,
|
||||
}: ForeshadowSuggestionsProps) {
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
伏笔 (foreshadow-analyst)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
) : suggestions.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">无伏笔建议。</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{suggestions.map((s, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="rounded border border-line bg-panel p-2 text-xs"
|
||||
>
|
||||
<span
|
||||
className={`mr-1 rounded px-1.5 py-0.5 ${
|
||||
s.kind === "resolved"
|
||||
? "bg-pass/15 text-pass"
|
||||
: "bg-[var(--color-cinnabar-wash)] text-cinnabar"
|
||||
}`}
|
||||
>
|
||||
{s.kind === "resolved" ? "⚑ 疑似回收" : "+ 新埋待确认"}
|
||||
</span>
|
||||
{s.code ? (
|
||||
<span className="font-mono text-ink-soft"> {s.code}</span>
|
||||
) : null}
|
||||
<p className="mt-1 text-ink">{s.title}</p>
|
||||
{s.where ? (
|
||||
<span className="text-ink-soft">{s.where}</span>
|
||||
) : null}
|
||||
{s.note ? (
|
||||
<p className="mt-0.5 text-ink-soft">{s.note}</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
56
apps/web/components/review/PacePanel.tsx
Normal file
56
apps/web/components/review/PacePanel.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import type { PaceReport } from "@/lib/review/sse";
|
||||
import { BeatMap } from "./BeatMap";
|
||||
|
||||
interface PacePanelProps {
|
||||
pace: PaceReport | null;
|
||||
incomplete: boolean;
|
||||
}
|
||||
|
||||
// 节奏报告区(UX §6.4 ④):节拍图 ▁▃▅ + 注水段列表 + 章末钩子徽标。只读建议。
|
||||
export function PacePanel({ pace, incomplete }: PacePanelProps) {
|
||||
return (
|
||||
<section className="mb-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-ink-soft">
|
||||
节奏 (pace-checker)
|
||||
</h2>
|
||||
{incomplete ? (
|
||||
<p className="mt-1 text-xs text-overdue">◌ 未完成</p>
|
||||
) : pace === null ? (
|
||||
<p className="mt-1 text-xs text-ink-soft">无节奏报告。</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2 text-xs">
|
||||
<div>
|
||||
<p className="mb-1 text-ink-soft">爽点节拍图</p>
|
||||
<BeatMap beatMap={pace.beat_map} />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
章末钩子:
|
||||
{pace.hook ? (
|
||||
<span className="text-pass">✓ 有</span>
|
||||
) : (
|
||||
<span className="text-overdue">⚠ 无(建议补悬念)</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{pace.water.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-overdue">⚠ 注水段</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{pace.water.map((w, i) => (
|
||||
<li key={i} className="text-ink-soft">
|
||||
{w.where}:{w.reason}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-pass">✓ 无明显注水</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -13,13 +13,19 @@ import {
|
||||
type DecisionDraft,
|
||||
type Verdict,
|
||||
} from "@/lib/review/decisions";
|
||||
import { normalizeConflicts } from "@/lib/review/history";
|
||||
import {
|
||||
normalizeConflicts,
|
||||
normalizeForeshadowSug,
|
||||
normalizePace,
|
||||
} 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";
|
||||
import { ForeshadowSuggestions } from "./ForeshadowSuggestions";
|
||||
import { PacePanel } from "./PacePanel";
|
||||
|
||||
interface ReviewReportProps {
|
||||
project: ProjectResponse;
|
||||
@@ -48,19 +54,41 @@ export function ReviewReport({
|
||||
const [missing, setMissing] = useState<Set<number>>(new Set());
|
||||
const seededRef = useRef(false);
|
||||
|
||||
// 进页一次性把历史 conflicts 种入流状态(无需重审即可裁决)。
|
||||
// 进页一次性把历史留痕(冲突 + 伏笔建议 + 节奏)种入流状态(无需重审即可裁决/查看)。
|
||||
const seededConflicts = useMemo(
|
||||
() => normalizeConflicts(initialReview),
|
||||
[initialReview],
|
||||
);
|
||||
const seededForeshadow = useMemo(
|
||||
() => normalizeForeshadowSug(initialReview),
|
||||
[initialReview],
|
||||
);
|
||||
const seededPace = useMemo(
|
||||
() => normalizePace(initialReview),
|
||||
[initialReview],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (seededRef.current) return;
|
||||
seededRef.current = true;
|
||||
if (seededConflicts.length > 0) review.seed(seededConflicts);
|
||||
}, [review, seededConflicts]);
|
||||
const hasSeed =
|
||||
seededConflicts.length > 0 ||
|
||||
seededForeshadow.length > 0 ||
|
||||
seededPace !== null;
|
||||
if (hasSeed) {
|
||||
review.seed({
|
||||
conflicts: seededConflicts,
|
||||
foreshadow: seededForeshadow,
|
||||
pace: seededPace,
|
||||
});
|
||||
}
|
||||
}, [review, seededConflicts, seededForeshadow, seededPace]);
|
||||
|
||||
// 当前生效冲突 = 流状态(重审后即时更新,否则种入的历史)。
|
||||
const conflicts: ReviewConflict[] = review.state.conflicts;
|
||||
const foreshadow = review.state.foreshadow;
|
||||
const pace = review.state.pace;
|
||||
const sectionStatus = (name: string): string | undefined =>
|
||||
review.state.sections.find((s) => s.name === name)?.status;
|
||||
|
||||
// 重审完成(done 边沿)→ 重置裁决草稿对齐新冲突集(冲突可能整组变化)。
|
||||
const conflictCount = conflicts.length;
|
||||
@@ -127,6 +155,8 @@ export function ReviewReport({
|
||||
<AppShell
|
||||
title={`〈${project.title}〉`}
|
||||
subtitle={`第 ${chapterNo} 章 · 审稿报告`}
|
||||
projectId={project.id}
|
||||
activeNav="review"
|
||||
>
|
||||
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[1fr_24rem]">
|
||||
{/* 左:终稿正文 + 就地标注 */}
|
||||
@@ -240,6 +270,17 @@ export function ReviewReport({
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="mt-4 border-t border-line pt-4">
|
||||
<ForeshadowSuggestions
|
||||
suggestions={foreshadow}
|
||||
incomplete={sectionStatus("foreshadow") === "incomplete"}
|
||||
/>
|
||||
<PacePanel
|
||||
pace={pace}
|
||||
incomplete={sectionStatus("pace") === "incomplete"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="mt-4">
|
||||
<AcceptPanel
|
||||
conflictCount={conflictCount}
|
||||
|
||||
@@ -55,6 +55,8 @@ export function Workbench({ project }: WorkbenchProps) {
|
||||
<AppShell
|
||||
title={`〈${project.title}〉`}
|
||||
subtitle={`第 ${chapterNo} 章`}
|
||||
projectId={project.id}
|
||||
activeNav="write"
|
||||
>
|
||||
<div className="grid h-[calc(100vh-4rem)] grid-cols-1 lg:grid-cols-[12rem_1fr_18rem]">
|
||||
<ChapterList currentChapterNo={chapterNo} />
|
||||
@@ -137,6 +139,18 @@ function Toolbar({
|
||||
<span className="font-mono text-xs text-ink-soft">
|
||||
字数 {wordCount.toLocaleString()}
|
||||
</span>
|
||||
<Link
|
||||
href={`/projects/${projectId}/outline`}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
||||
>
|
||||
大纲
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${projectId}/foreshadow`}
|
||||
className="rounded border border-line px-4 py-2 text-sm text-ink hover:border-cinnabar hover:text-cinnabar"
|
||||
>
|
||||
伏笔
|
||||
</Link>
|
||||
<Link
|
||||
href={`/projects/${projectId}/review?chapter=${chapterNo}`}
|
||||
className="rounded border border-cinnabar px-4 py-2 text-sm text-cinnabar hover:bg-[var(--color-cinnabar-wash)]"
|
||||
|
||||
370
apps/web/lib/api/schema.d.ts
vendored
370
apps/web/lib/api/schema.d.ts
vendored
@@ -169,7 +169,14 @@ export interface paths {
|
||||
/**
|
||||
* Accept Chapter
|
||||
* @description 验收事务 + 冲突 gate(§5.5):gate(事务前)→ 终稿提炼 digest(事务外,R2)→
|
||||
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
|
||||
* 单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
|
||||
* BackgroundTask(M3-b/d,§6.2)。
|
||||
*
|
||||
* 到期扫描接 §5.5 步骤 3 的 `TODO(M3)` 占位:验收提交成功**后**,把
|
||||
* `current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 OVERDUE。
|
||||
* 伏笔登记/回收(PARTIAL/CLOSED)的「采纳建议」经作者经 foreshadow 端点显式确认,
|
||||
* 不在验收事务里 AI 静默改库(不变量 #3)。**§7.4 持久性局限**:BackgroundTask 进程内
|
||||
* 跑、重启丢失——原型可接受(见 `services/foreshadow_scan.py`,不引入 jobs 表)。
|
||||
*/
|
||||
post: operations["accept_chapter_projects__project_id__chapters__chapter_no__accept_post"];
|
||||
delete?: never;
|
||||
@@ -178,6 +185,75 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/foreshadow": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List Foreshadow
|
||||
* @description 伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
|
||||
*
|
||||
* `status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE)→ VALIDATION 信封。四泳道前端
|
||||
* 据 `status` 分组;OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
|
||||
*/
|
||||
get: operations["list_foreshadow_projects__project_id__foreshadow_get"];
|
||||
put?: never;
|
||||
/**
|
||||
* Register Foreshadow
|
||||
* @description 登记一条伏笔(status=OPEN)。重复 `code` → DB 唯一约束冲突 → VALIDATION 信封。
|
||||
*/
|
||||
post: operations["register_foreshadow_projects__project_id__foreshadow_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/foreshadow/{code}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Update Foreshadow
|
||||
* @description 状态机转移 和/或 追加进展。非法转移 → VALIDATION 信封;不存在 → NOT_FOUND。
|
||||
*
|
||||
* `to_status` 与 `progress_entry` 皆可选;两者都缺 → VALIDATION(无操作)。先转移、后追加。
|
||||
*/
|
||||
patch: operations["update_foreshadow_projects__project_id__foreshadow__code__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/projects/{project_id}/outline": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Generate Outline
|
||||
* @description 生成大纲并逐章持久化。无凭据 → LLM_UNAVAILABLE;项目不存在 → NOT_FOUND。
|
||||
*/
|
||||
post: operations["generate_outline_projects__project_id__outline_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/settings/providers": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -338,11 +414,164 @@ export interface components {
|
||||
/** Text */
|
||||
text: string;
|
||||
};
|
||||
/**
|
||||
* ForeshadowBoardResponse
|
||||
* @description GET /projects/:id/foreshadow?status=:伏笔看板(四泳道 + OVERDUE 字段齐)。
|
||||
*
|
||||
* `status` 缺省返回全部;按 `code` 升序(repo `list_by_status` 已排序)。前端按
|
||||
* `status` 分四泳道(OPEN/PARTIAL/CLOSED/OVERDUE),用 `expected_close_to` 标逾期。
|
||||
*/
|
||||
ForeshadowBoardResponse: {
|
||||
/** Foreshadow */
|
||||
foreshadow?: components["schemas"]["ForeshadowView"][];
|
||||
};
|
||||
/**
|
||||
* ForeshadowRegisterRequest
|
||||
* @description POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。
|
||||
*/
|
||||
ForeshadowRegisterRequest: {
|
||||
/**
|
||||
* Code
|
||||
* @description 伏笔代号,`(project_id, code)` 唯一
|
||||
*/
|
||||
code: string;
|
||||
/**
|
||||
* Title
|
||||
* @description 伏笔标题/一句话描述
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Planted At
|
||||
* @description 埋设章号
|
||||
*/
|
||||
planted_at?: number | null;
|
||||
/**
|
||||
* Content
|
||||
* @description 伏笔正文/线索
|
||||
*/
|
||||
content?: string | null;
|
||||
/**
|
||||
* Expected Close From
|
||||
* @description 预期回收窗口起始章
|
||||
*/
|
||||
expected_close_from?: number | null;
|
||||
/**
|
||||
* Expected Close To
|
||||
* @description 预期回收窗口结束章(到期判据用)
|
||||
*/
|
||||
expected_close_to?: number | null;
|
||||
/**
|
||||
* Importance
|
||||
* @description 重要度(自由文本,看板用)
|
||||
*/
|
||||
importance?: string | null;
|
||||
};
|
||||
/**
|
||||
* ForeshadowTransitionRequest
|
||||
* @description PATCH /projects/:id/foreshadow/:code:状态机转移 和/或 追加一条进展。
|
||||
*
|
||||
* 两字段皆可选:`to_status` 走状态机(非法 → VALIDATION 信封);`progress_entry`
|
||||
* append-only 追加到 progress JSONB。二者可同时给(先转移、后追加进展)。
|
||||
*/
|
||||
ForeshadowTransitionRequest: {
|
||||
/**
|
||||
* To Status
|
||||
* @description 目标状态(OPEN/PARTIAL/CLOSED/OVERDUE)
|
||||
*/
|
||||
to_status?: string | null;
|
||||
/**
|
||||
* Progress Entry
|
||||
* @description 追加一条进展记录(append-only,不覆盖历史)
|
||||
*/
|
||||
progress_entry?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
/**
|
||||
* ForeshadowView
|
||||
* @description 伏笔账本视图(登记/状态变更/看板共用;snake_case)。形对齐 `ForeshadowLedgerView`。
|
||||
*/
|
||||
ForeshadowView: {
|
||||
/** Code */
|
||||
code: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Status */
|
||||
status: string;
|
||||
/** Planted At */
|
||||
planted_at?: number | null;
|
||||
/** Content */
|
||||
content?: string | null;
|
||||
/** Expected Close From */
|
||||
expected_close_from?: number | null;
|
||||
/** Expected Close To */
|
||||
expected_close_to?: number | null;
|
||||
/** Importance */
|
||||
importance?: string | null;
|
||||
/** Links */
|
||||
links?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
/** Progress */
|
||||
progress?: {
|
||||
[key: string]: unknown;
|
||||
}[];
|
||||
};
|
||||
/**
|
||||
* ForeshadowWindowView
|
||||
* @description 单章关联的伏笔回收窗口(snake_case;形对齐 `ww_agents.ForeshadowWindow`)。
|
||||
*/
|
||||
ForeshadowWindowView: {
|
||||
/** Code */
|
||||
code: string;
|
||||
/** Plant Chapter */
|
||||
plant_chapter?: number | null;
|
||||
/** Expected Close From */
|
||||
expected_close_from?: number | null;
|
||||
/** Expected Close To */
|
||||
expected_close_to?: number | null;
|
||||
};
|
||||
/** HTTPValidationError */
|
||||
HTTPValidationError: {
|
||||
/** Detail */
|
||||
detail?: components["schemas"]["ValidationError"][];
|
||||
};
|
||||
/**
|
||||
* OutlineChapterView
|
||||
* @description 单章大纲视图:章号 + 卷 + 节拍 + 伏笔窗口。
|
||||
*/
|
||||
OutlineChapterView: {
|
||||
/** No */
|
||||
no: number;
|
||||
/** Volume */
|
||||
volume: number;
|
||||
/** Beats */
|
||||
beats?: string[];
|
||||
/** Foreshadow Windows */
|
||||
foreshadow_windows?: components["schemas"]["ForeshadowWindowView"][];
|
||||
};
|
||||
/**
|
||||
* OutlineGenerateRequest
|
||||
* @description POST /projects/:id/outline:触发大纲生成 + 持久化。
|
||||
*
|
||||
* M3 简单分卷:`volume` 缺省=1(schema 无逐章卷号;本端点把整批章节落到同一卷)。
|
||||
*/
|
||||
OutlineGenerateRequest: {
|
||||
/**
|
||||
* Volume
|
||||
* @description 本批大纲所属卷号(M3 默认全卷 1)
|
||||
* @default 1
|
||||
*/
|
||||
volume: number;
|
||||
};
|
||||
/**
|
||||
* OutlineResponse
|
||||
* @description 大纲生成响应:逐章已持久化的大纲(含窗口,供前端 T3.6)。
|
||||
*/
|
||||
OutlineResponse: {
|
||||
/** Chapters */
|
||||
chapters?: components["schemas"]["OutlineChapterView"][];
|
||||
};
|
||||
/**
|
||||
* ProjectCreateRequest
|
||||
* @description POST /projects:立项向导字段(owner_id 由后端补 stub,不入参)。
|
||||
@@ -898,6 +1127,145 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_foreshadow_projects__project_id__foreshadow_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowBoardResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
register_foreshadow_projects__project_id__foreshadow_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowRegisterRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowView"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_foreshadow_projects__project_id__foreshadow__code__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
code: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowTransitionRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ForeshadowView"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
generate_outline_projects__project_id__outline_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
project_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["OutlineGenerateRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["OutlineResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_providers_settings_providers_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { serverApiBase } from "./config";
|
||||
import type {
|
||||
ForeshadowBoardResponse,
|
||||
ProjectListResponse,
|
||||
ProjectResponse,
|
||||
ProvidersResponse,
|
||||
@@ -35,3 +36,10 @@ export async function fetchReviews(
|
||||
`/projects/${projectId}/chapters/${chapterNo}/reviews`,
|
||||
);
|
||||
}
|
||||
|
||||
// 伏笔看板(全量,前端按 status 分四泳道)。
|
||||
export async function fetchForeshadow(
|
||||
projectId: string,
|
||||
): Promise<ForeshadowBoardResponse> {
|
||||
return getJson<ForeshadowBoardResponse>(`/projects/${projectId}/foreshadow`);
|
||||
}
|
||||
|
||||
@@ -25,3 +25,18 @@ export type ReviewHistoryResponse =
|
||||
export type ConflictDecision = components["schemas"]["ConflictDecision"];
|
||||
export type AcceptRequest = components["schemas"]["AcceptRequest"];
|
||||
export type AcceptResponse = components["schemas"]["AcceptResponse"];
|
||||
|
||||
// M3 伏笔 + 大纲(C3 扩 T3.2/T3.5)。
|
||||
export type ForeshadowView = components["schemas"]["ForeshadowView"];
|
||||
export type ForeshadowBoardResponse =
|
||||
components["schemas"]["ForeshadowBoardResponse"];
|
||||
export type ForeshadowRegisterRequest =
|
||||
components["schemas"]["ForeshadowRegisterRequest"];
|
||||
export type ForeshadowTransitionRequest =
|
||||
components["schemas"]["ForeshadowTransitionRequest"];
|
||||
export type ForeshadowWindowView =
|
||||
components["schemas"]["ForeshadowWindowView"];
|
||||
export type OutlineChapterView = components["schemas"]["OutlineChapterView"];
|
||||
export type OutlineGenerateRequest =
|
||||
components["schemas"]["OutlineGenerateRequest"];
|
||||
export type OutlineResponse = components["schemas"]["OutlineResponse"];
|
||||
|
||||
134
apps/web/lib/foreshadow/board.test.ts
Normal file
134
apps/web/lib/foreshadow/board.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import {
|
||||
buildRegisterRequest,
|
||||
buildTransitionRequest,
|
||||
extractReason,
|
||||
groupByStatus,
|
||||
isWindowApproaching,
|
||||
validationReasonMessage,
|
||||
} from "./board";
|
||||
|
||||
const view = (over: Partial<ForeshadowView>): ForeshadowView => ({
|
||||
code: "F-001",
|
||||
title: "线索",
|
||||
status: "OPEN",
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("groupByStatus", () => {
|
||||
it("splits items into four lanes preserving order", () => {
|
||||
const lanes = groupByStatus([
|
||||
view({ code: "F-001", status: "OPEN" }),
|
||||
view({ code: "F-002", status: "OVERDUE" }),
|
||||
view({ code: "F-003", status: "OPEN" }),
|
||||
view({ code: "F-004", status: "CLOSED" }),
|
||||
view({ code: "F-005", status: "PARTIAL" }),
|
||||
]);
|
||||
expect(lanes.OPEN.map((v) => v.code)).toEqual(["F-001", "F-003"]);
|
||||
expect(lanes.OVERDUE.map((v) => v.code)).toEqual(["F-002"]);
|
||||
expect(lanes.CLOSED.map((v) => v.code)).toEqual(["F-004"]);
|
||||
expect(lanes.PARTIAL.map((v) => v.code)).toEqual(["F-005"]);
|
||||
});
|
||||
|
||||
it("falls back unknown status to OPEN and handles undefined", () => {
|
||||
const lanes = groupByStatus([view({ status: "WEIRD" })]);
|
||||
expect(lanes.OPEN).toHaveLength(1);
|
||||
expect(groupByStatus(undefined).OPEN).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWindowApproaching", () => {
|
||||
it("is true when current chapter is inside [from,to] for OPEN/PARTIAL", () => {
|
||||
const f = view({
|
||||
status: "PARTIAL",
|
||||
expected_close_from: 40,
|
||||
expected_close_to: 60,
|
||||
});
|
||||
expect(isWindowApproaching(f, 50)).toBe(true);
|
||||
expect(isWindowApproaching(f, 39)).toBe(false);
|
||||
expect(isWindowApproaching(f, 61)).toBe(false);
|
||||
});
|
||||
|
||||
it("never flags CLOSED/OVERDUE or items without a window", () => {
|
||||
expect(
|
||||
isWindowApproaching(
|
||||
view({ status: "CLOSED", expected_close_to: 60 }),
|
||||
50,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(isWindowApproaching(view({ status: "OPEN" }), 50)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRegisterRequest", () => {
|
||||
it("trims and omits empty optionals", () => {
|
||||
expect(
|
||||
buildRegisterRequest({
|
||||
projectId: "p1",
|
||||
code: " F-007 ",
|
||||
title: " 旧誓约 ",
|
||||
content: " ",
|
||||
expectedCloseTo: 110,
|
||||
}),
|
||||
).toEqual({ code: "F-007", title: "旧誓约", expected_close_to: 110 });
|
||||
});
|
||||
|
||||
it("keeps numeric and non-empty string optionals", () => {
|
||||
expect(
|
||||
buildRegisterRequest({
|
||||
projectId: "p1",
|
||||
code: "F-1",
|
||||
title: "t",
|
||||
plantedAt: 8,
|
||||
content: "线索",
|
||||
expectedCloseFrom: 40,
|
||||
importance: "主线",
|
||||
}),
|
||||
).toEqual({
|
||||
code: "F-1",
|
||||
title: "t",
|
||||
planted_at: 8,
|
||||
content: "线索",
|
||||
expected_close_from: 40,
|
||||
importance: "主线",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTransitionRequest", () => {
|
||||
it("includes only provided fields", () => {
|
||||
expect(buildTransitionRequest({ projectId: "p1", toStatus: "PARTIAL" })).toEqual({
|
||||
to_status: "PARTIAL",
|
||||
});
|
||||
expect(
|
||||
buildTransitionRequest({
|
||||
projectId: "p1",
|
||||
progressEntry: { chapter: 23, note: "发光" },
|
||||
}),
|
||||
).toEqual({ progress_entry: { chapter: 23, note: "发光" } });
|
||||
expect(buildTransitionRequest({ projectId: "p1" })).toEqual({});
|
||||
expect(
|
||||
buildTransitionRequest({ projectId: "p1", progressEntry: {} }),
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validationReasonMessage / extractReason", () => {
|
||||
it("maps known reasons to friendly text", () => {
|
||||
expect(validationReasonMessage("duplicate")).toContain("已存在");
|
||||
expect(validationReasonMessage("invalid_transition")).toContain("终态");
|
||||
expect(validationReasonMessage("empty_update")).toContain("进展");
|
||||
expect(validationReasonMessage("invalid_status")).toContain("筛选");
|
||||
expect(validationReasonMessage(undefined)).toContain("校验");
|
||||
});
|
||||
|
||||
it("extracts details.reason from envelope", () => {
|
||||
expect(
|
||||
extractReason({ error: { details: { reason: "duplicate" } } }),
|
||||
).toBe("duplicate");
|
||||
expect(extractReason({ error: { details: null } })).toBeUndefined();
|
||||
expect(extractReason("oops")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
141
apps/web/lib/foreshadow/board.ts
Normal file
141
apps/web/lib/foreshadow/board.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
// 伏笔看板纯逻辑:四泳道分组、登记/转移请求体组装、422 reason 映射。
|
||||
// 对齐 C3 扩(GET/POST/PATCH .../foreshadow)。纯逻辑,便于 node 环境单测。
|
||||
|
||||
import type {
|
||||
ForeshadowRegisterRequest,
|
||||
ForeshadowTransitionRequest,
|
||||
ForeshadowView,
|
||||
} from "@/lib/api/types";
|
||||
|
||||
// 四状态泳道(UX §6.8);OVERDUE 用琥珀强调(渲染层处理)。
|
||||
export type ForeshadowStatus = "OPEN" | "PARTIAL" | "CLOSED" | "OVERDUE";
|
||||
|
||||
export const LANES: readonly ForeshadowStatus[] = [
|
||||
"OPEN",
|
||||
"PARTIAL",
|
||||
"CLOSED",
|
||||
"OVERDUE",
|
||||
] as const;
|
||||
|
||||
export const LANE_LABELS: Record<ForeshadowStatus, string> = {
|
||||
OPEN: "OPEN",
|
||||
PARTIAL: "PARTIAL",
|
||||
CLOSED: "CLOSED",
|
||||
OVERDUE: "OVERDUE",
|
||||
};
|
||||
|
||||
export type ForeshadowLanes = Record<ForeshadowStatus, ForeshadowView[]>;
|
||||
|
||||
function emptyLanes(): ForeshadowLanes {
|
||||
return { OPEN: [], PARTIAL: [], CLOSED: [], OVERDUE: [] };
|
||||
}
|
||||
|
||||
function isStatus(v: string): v is ForeshadowStatus {
|
||||
return v === "OPEN" || v === "PARTIAL" || v === "CLOSED" || v === "OVERDUE";
|
||||
}
|
||||
|
||||
// 按 status 分四泳道(未知 status 落 OPEN 兜底);保持 repo 已排序的 code 顺序。
|
||||
export function groupByStatus(
|
||||
items: readonly ForeshadowView[] | undefined,
|
||||
): ForeshadowLanes {
|
||||
const lanes = emptyLanes();
|
||||
for (const item of items ?? []) {
|
||||
const status = isStatus(item.status) ? item.status : "OPEN";
|
||||
lanes[status] = [...lanes[status], item];
|
||||
}
|
||||
return lanes;
|
||||
}
|
||||
|
||||
// 接近回收窗口判定:current 在 [from, to] 内,或已超 from 但尚未 CLOSED(提示安排回收)。
|
||||
// 仅对 OPEN/PARTIAL 提示;CLOSED 已回收、OVERDUE 已逾期另有强调。
|
||||
export function isWindowApproaching(
|
||||
item: ForeshadowView,
|
||||
currentChapter: number,
|
||||
): boolean {
|
||||
if (item.status === "CLOSED" || item.status === "OVERDUE") return false;
|
||||
const to = item.expected_close_to;
|
||||
if (typeof to !== "number") return false;
|
||||
const from = item.expected_close_from ?? to;
|
||||
return currentChapter >= from && currentChapter <= to;
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
projectId: string;
|
||||
code: string;
|
||||
title: string;
|
||||
plantedAt?: number | null;
|
||||
content?: string | null;
|
||||
expectedCloseFrom?: number | null;
|
||||
expectedCloseTo?: number | null;
|
||||
importance?: string | null;
|
||||
}
|
||||
|
||||
// 组装登记请求体(去空白;空可选字段省略)。
|
||||
export function buildRegisterRequest(
|
||||
input: RegisterInput,
|
||||
): ForeshadowRegisterRequest {
|
||||
const body: ForeshadowRegisterRequest = {
|
||||
code: input.code.trim(),
|
||||
title: input.title.trim(),
|
||||
};
|
||||
if (typeof input.plantedAt === "number") body.planted_at = input.plantedAt;
|
||||
const content = input.content?.trim();
|
||||
if (content) body.content = content;
|
||||
if (typeof input.expectedCloseFrom === "number")
|
||||
body.expected_close_from = input.expectedCloseFrom;
|
||||
if (typeof input.expectedCloseTo === "number")
|
||||
body.expected_close_to = input.expectedCloseTo;
|
||||
const importance = input.importance?.trim();
|
||||
if (importance) body.importance = importance;
|
||||
return body;
|
||||
}
|
||||
|
||||
export interface TransitionInput {
|
||||
projectId: string;
|
||||
toStatus?: ForeshadowStatus | null;
|
||||
// 一条进展记录(自由 dict;如 {chapter, note})。
|
||||
progressEntry?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// 组装转移/进展请求体(两字段皆可选;至少给一项由调用方校验)。
|
||||
export function buildTransitionRequest(
|
||||
input: TransitionInput,
|
||||
): ForeshadowTransitionRequest {
|
||||
const body: ForeshadowTransitionRequest = {};
|
||||
if (input.toStatus) body.to_status = input.toStatus;
|
||||
if (input.progressEntry && Object.keys(input.progressEntry).length > 0)
|
||||
body.progress_entry = input.progressEntry;
|
||||
return body;
|
||||
}
|
||||
|
||||
// 422 VALIDATION 信封 details.reason(C3 扩)→ 用户文案。
|
||||
export type ValidationReason =
|
||||
| "duplicate"
|
||||
| "invalid_transition"
|
||||
| "empty_update"
|
||||
| "invalid_status";
|
||||
|
||||
export function validationReasonMessage(reason: string | undefined): string {
|
||||
switch (reason) {
|
||||
case "duplicate":
|
||||
return "该伏笔代号已存在,请换一个。";
|
||||
case "invalid_transition":
|
||||
return "非法状态转移(CLOSED 为终态,不可再改)。";
|
||||
case "empty_update":
|
||||
return "请填写目标状态或一条进展。";
|
||||
case "invalid_status":
|
||||
return "无效的筛选状态。";
|
||||
default:
|
||||
return "操作未通过校验,请检查输入。";
|
||||
}
|
||||
}
|
||||
|
||||
// 从错误信封安全提取 details.reason。
|
||||
export function extractReason(error: unknown): string | undefined {
|
||||
if (typeof error !== "object" || error === null) return undefined;
|
||||
const env = error as {
|
||||
error?: { details?: { reason?: unknown } | null };
|
||||
};
|
||||
const reason = env.error?.details?.reason;
|
||||
return typeof reason === "string" ? reason : undefined;
|
||||
}
|
||||
96
apps/web/lib/foreshadow/useForeshadow.ts
Normal file
96
apps/web/lib/foreshadow/useForeshadow.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { ForeshadowView } from "@/lib/api/types";
|
||||
import {
|
||||
buildRegisterRequest,
|
||||
buildTransitionRequest,
|
||||
extractReason,
|
||||
validationReasonMessage,
|
||||
type ForeshadowStatus,
|
||||
type RegisterInput,
|
||||
type TransitionInput,
|
||||
} from "./board";
|
||||
|
||||
export interface UseForeshadow {
|
||||
items: ForeshadowView[];
|
||||
busy: boolean;
|
||||
register: (input: RegisterInput) => Promise<boolean>;
|
||||
transition: (code: string, input: TransitionInput) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// 伏笔登记/状态变更(乐观更新 + 回滚 + Toast)。
|
||||
// register:成功后把返回行追加进 items;422 duplicate → 友好提示,不改 items。
|
||||
// transition:先乐观替换该行 status,失败回滚到旧 items 并 toast(读 details.reason)。
|
||||
export function useForeshadow(initial: ForeshadowView[]): UseForeshadow {
|
||||
const [items, setItems] = useState<ForeshadowView[]>(initial);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const register = useCallback<UseForeshadow["register"]>(
|
||||
async (input) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data, error } = await api.POST(
|
||||
"/projects/{project_id}/foreshadow",
|
||||
{
|
||||
params: { path: { project_id: input.projectId } },
|
||||
body: buildRegisterRequest(input),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
toast(validationReasonMessage(extractReason(error)), "error");
|
||||
return false;
|
||||
}
|
||||
setItems((prev) => [...prev, data]);
|
||||
toast(`已登记伏笔 ${data.code}`, "success");
|
||||
return true;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
const transition = useCallback<UseForeshadow["transition"]>(
|
||||
async (code, input) => {
|
||||
const snapshot = items;
|
||||
// 乐观:本地先改 status(若给了 toStatus)。
|
||||
if (input.toStatus) {
|
||||
const next = input.toStatus as ForeshadowStatus;
|
||||
setItems((prev) =>
|
||||
prev.map((it) => (it.code === code ? { ...it, status: next } : it)),
|
||||
);
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const { data, error } = await api.PATCH(
|
||||
"/projects/{project_id}/foreshadow/{code}",
|
||||
{
|
||||
params: {
|
||||
path: { project_id: input.projectId, code },
|
||||
},
|
||||
body: buildTransitionRequest(input),
|
||||
},
|
||||
);
|
||||
if (error || !data) {
|
||||
setItems(snapshot); // 回滚
|
||||
toast(validationReasonMessage(extractReason(error)), "error");
|
||||
return false;
|
||||
}
|
||||
// 用服务端权威行替换(status + progress 都对齐)。
|
||||
setItems((prev) => prev.map((it) => (it.code === code ? data : it)));
|
||||
toast(`已更新 ${data.code}`, "success");
|
||||
return true;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[items, toast],
|
||||
);
|
||||
|
||||
return { items, busy, register, transition };
|
||||
}
|
||||
63
apps/web/lib/outline/outline.test.ts
Normal file
63
apps/web/lib/outline/outline.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
ForeshadowWindowView,
|
||||
OutlineChapterView,
|
||||
} from "@/lib/api/types";
|
||||
import {
|
||||
groupByVolume,
|
||||
isCloseWindow,
|
||||
windowBadgeLabel,
|
||||
} from "./outline";
|
||||
|
||||
const ch = (over: Partial<OutlineChapterView>): OutlineChapterView => ({
|
||||
no: 1,
|
||||
volume: 1,
|
||||
beats: [],
|
||||
foreshadow_windows: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
const win = (
|
||||
over: Partial<ForeshadowWindowView>,
|
||||
): ForeshadowWindowView => ({ code: "F-012", ...over });
|
||||
|
||||
describe("groupByVolume", () => {
|
||||
it("groups by volume and sorts volumes and chapters ascending", () => {
|
||||
const groups = groupByVolume([
|
||||
ch({ no: 127, volume: 3 }),
|
||||
ch({ no: 12, volume: 1 }),
|
||||
ch({ no: 126, volume: 3 }),
|
||||
]);
|
||||
expect(groups.map((g) => g.volume)).toEqual([1, 3]);
|
||||
expect(groups[1]?.chapters.map((c) => c.no)).toEqual([126, 127]);
|
||||
});
|
||||
|
||||
it("handles undefined", () => {
|
||||
expect(groupByVolume(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("windowBadgeLabel", () => {
|
||||
it("renders a range, half-open, or bare badge", () => {
|
||||
expect(
|
||||
windowBadgeLabel(win({ expected_close_from: 40, expected_close_to: 60 })),
|
||||
).toBe("⚑ F-012 (40-60)");
|
||||
expect(windowBadgeLabel(win({ expected_close_to: 60 }))).toBe(
|
||||
"⚑ F-012 (≤60)",
|
||||
);
|
||||
expect(windowBadgeLabel(win({ expected_close_from: 40 }))).toBe(
|
||||
"⚑ F-012 (≥40)",
|
||||
);
|
||||
expect(windowBadgeLabel(win({}))).toBe("⚑ F-012");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCloseWindow", () => {
|
||||
it("is true when chapter no is inside the recovery window", () => {
|
||||
const w = win({ expected_close_from: 40, expected_close_to: 60 });
|
||||
expect(isCloseWindow(ch({ no: 50 }), w)).toBe(true);
|
||||
expect(isCloseWindow(ch({ no: 61 }), w)).toBe(false);
|
||||
expect(isCloseWindow(ch({ no: 50 }), win({}))).toBe(false);
|
||||
});
|
||||
});
|
||||
49
apps/web/lib/outline/outline.ts
Normal file
49
apps/web/lib/outline/outline.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// 大纲编辑器纯逻辑:按卷分组、伏笔窗口徽标文案、接近回收窗口提示(UX §6.7)。
|
||||
// OutlineChapterView/ForeshadowWindowView 已由 OpenAPI 强类型,无需手动收窄。
|
||||
|
||||
import type { ForeshadowWindowView, OutlineChapterView } from "@/lib/api/types";
|
||||
|
||||
export interface VolumeGroup {
|
||||
volume: number;
|
||||
chapters: OutlineChapterView[];
|
||||
}
|
||||
|
||||
// 按 volume 分组(保持章号升序);卷号升序。
|
||||
export function groupByVolume(
|
||||
chapters: readonly OutlineChapterView[] | undefined,
|
||||
): VolumeGroup[] {
|
||||
const byVolume = new Map<number, OutlineChapterView[]>();
|
||||
for (const ch of chapters ?? []) {
|
||||
const list = byVolume.get(ch.volume) ?? [];
|
||||
byVolume.set(ch.volume, [...list, ch]);
|
||||
}
|
||||
return [...byVolume.entries()]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([volume, list]) => ({
|
||||
volume,
|
||||
chapters: [...list].sort((a, b) => a.no - b.no),
|
||||
}));
|
||||
}
|
||||
|
||||
// 伏笔窗口徽标文案:`⚑ F-012 (40-60)` / `⚑ F-012 (≤60)` / `⚑ F-012`。
|
||||
export function windowBadgeLabel(window: ForeshadowWindowView): string {
|
||||
const from = window.expected_close_from;
|
||||
const to = window.expected_close_to;
|
||||
if (typeof from === "number" && typeof to === "number") {
|
||||
return `⚑ ${window.code} (${from}-${to})`;
|
||||
}
|
||||
if (typeof to === "number") return `⚑ ${window.code} (≤${to})`;
|
||||
if (typeof from === "number") return `⚑ ${window.code} (≥${from})`;
|
||||
return `⚑ ${window.code}`;
|
||||
}
|
||||
|
||||
// 接近回收:本章号落在窗口 [from,to] 内(提示「本章可回收」,UX §6.7 ⚑ 可回收)。
|
||||
export function isCloseWindow(
|
||||
chapter: OutlineChapterView,
|
||||
window: ForeshadowWindowView,
|
||||
): boolean {
|
||||
const to = window.expected_close_to;
|
||||
if (typeof to !== "number") return false;
|
||||
const from = window.expected_close_from ?? to;
|
||||
return chapter.no >= from && chapter.no <= to;
|
||||
}
|
||||
63
apps/web/lib/outline/useOutline.ts
Normal file
63
apps/web/lib/outline/useOutline.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api/client";
|
||||
import { useToast } from "@/components/Toast";
|
||||
import type { OutlineChapterView } from "@/lib/api/types";
|
||||
|
||||
export type OutlineStatus = "idle" | "generating" | "ready" | "error";
|
||||
|
||||
export interface OutlineError {
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UseOutline {
|
||||
chapters: OutlineChapterView[];
|
||||
status: OutlineStatus;
|
||||
error: OutlineError | null;
|
||||
generate: (projectId: string, volume: number) => Promise<void>;
|
||||
}
|
||||
|
||||
// 生成大纲:POST .../outline(body {volume})。无凭据/网关失败 → 503 LLM_UNAVAILABLE
|
||||
// (流前 JSON 信封),经 error 检出、引导去设置(同 draft/review 范式)。
|
||||
export function useOutline(initial: OutlineChapterView[]): UseOutline {
|
||||
const [chapters, setChapters] = useState<OutlineChapterView[]>(initial);
|
||||
const [status, setStatus] = useState<OutlineStatus>(
|
||||
initial.length > 0 ? "ready" : "idle",
|
||||
);
|
||||
const [error, setError] = useState<OutlineError | null>(null);
|
||||
const toast = useToast();
|
||||
|
||||
const generate = useCallback<UseOutline["generate"]>(
|
||||
async (projectId, volume) => {
|
||||
setStatus("generating");
|
||||
setError(null);
|
||||
const { data, error: apiError } = await api.POST(
|
||||
"/projects/{project_id}/outline",
|
||||
{
|
||||
params: { path: { project_id: projectId } },
|
||||
body: { volume },
|
||||
},
|
||||
);
|
||||
if (apiError || !data) {
|
||||
const env = apiError as
|
||||
| { error?: { code?: string; message?: string } }
|
||||
| undefined;
|
||||
const code = env?.error?.code ?? "OUTLINE_FAILED";
|
||||
const message = env?.error?.message ?? "大纲生成失败,请重试。";
|
||||
setError({ code, message });
|
||||
setStatus("error");
|
||||
toast(message, "error");
|
||||
return;
|
||||
}
|
||||
setChapters(data.chapters ?? []);
|
||||
setStatus("ready");
|
||||
toast("大纲已生成", "success");
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
|
||||
return { chapters, status, error, generate };
|
||||
}
|
||||
37
apps/web/lib/pace/beatmap.test.ts
Normal file
37
apps/web/lib/pace/beatmap.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SPARK_CHARS, toBeatCells, toSparkline } from "./beatmap";
|
||||
|
||||
describe("toBeatCells", () => {
|
||||
it("maps min→▁ and max→▇ via linear normalization", () => {
|
||||
const cells = toBeatCells([1, 7]);
|
||||
expect(cells[0]?.level).toBe(0);
|
||||
expect(cells[0]?.char).toBe("▁");
|
||||
expect(cells[1]?.level).toBe(SPARK_CHARS.length - 1);
|
||||
expect(cells[1]?.char).toBe("▇");
|
||||
});
|
||||
|
||||
it("centers all cells when every value is equal", () => {
|
||||
const cells = toBeatCells([5, 5, 5]);
|
||||
expect(cells.every((c) => c.level === 3)).toBe(true);
|
||||
expect(cells.every((c) => c.char === "▄")).toBe(true);
|
||||
});
|
||||
|
||||
it("gives every cell a visible min height", () => {
|
||||
const cells = toBeatCells([0, 10]);
|
||||
expect(cells[0]?.heightPct).toBeGreaterThanOrEqual(12);
|
||||
expect(cells[1]?.heightPct).toBe(100);
|
||||
});
|
||||
|
||||
it("returns [] for empty or non-finite-only input", () => {
|
||||
expect(toBeatCells([])).toEqual([]);
|
||||
expect(toBeatCells([Number.NaN, Number.POSITIVE_INFINITY])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSparkline", () => {
|
||||
it("joins chars into a sparkline string", () => {
|
||||
expect(toSparkline([1, 4, 7])).toBe("▁▄▇");
|
||||
expect(toSparkline([])).toBe("");
|
||||
});
|
||||
});
|
||||
53
apps/web/lib/pace/beatmap.ts
Normal file
53
apps/web/lib/pace/beatmap.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// 爽点节拍图:把 pace.beat_map(int 序列)映射成 ▁▃▅ 风格的 sparkline 格子(UX §6.4)。
|
||||
// 纯逻辑,便于 node 环境单测;渲染时配合高度类与 prefers-reduced-motion。
|
||||
|
||||
// 七级火花字符(低→高),与高度档位一一对应。
|
||||
export const SPARK_CHARS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇"] as const;
|
||||
|
||||
export interface BeatCell {
|
||||
// 原始强度值。
|
||||
value: number;
|
||||
// 归一化档位 0..6(映射到 SPARK_CHARS / 高度百分比)。
|
||||
level: number;
|
||||
// 对应火花字符(▁..▇)。
|
||||
char: string;
|
||||
// 渲染用高度百分比(12%..100%,最低也可见)。
|
||||
heightPct: number;
|
||||
}
|
||||
|
||||
const MIN_HEIGHT_PCT = 12;
|
||||
const MAX_LEVEL = SPARK_CHARS.length - 1;
|
||||
|
||||
// 把 beat_map 映射成可渲染的格子序列。
|
||||
// 归一化按「本序列 min..max」线性分桶到 0..6;全相等 → 全部居中档(level 3)。
|
||||
export function toBeatCells(beatMap: readonly number[]): BeatCell[] {
|
||||
const values = beatMap.filter((v) => typeof v === "number" && Number.isFinite(v));
|
||||
if (values.length === 0) return [];
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
const span = max - min;
|
||||
|
||||
return values.map((value) => {
|
||||
const level =
|
||||
span === 0
|
||||
? Math.round(MAX_LEVEL / 2)
|
||||
: Math.round(((value - min) / span) * MAX_LEVEL);
|
||||
const clamped = Math.min(MAX_LEVEL, Math.max(0, level));
|
||||
const heightPct =
|
||||
MIN_HEIGHT_PCT + (clamped / MAX_LEVEL) * (100 - MIN_HEIGHT_PCT);
|
||||
return {
|
||||
value,
|
||||
level: clamped,
|
||||
char: SPARK_CHARS[clamped] ?? SPARK_CHARS[0],
|
||||
heightPct: Math.round(heightPct),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 文本版 sparkline(无障碍/紧凑场景;UX §6.4 示例 ▁▃▅▂▁▇)。
|
||||
export function toSparkline(beatMap: readonly number[]): string {
|
||||
return toBeatCells(beatMap)
|
||||
.map((c) => c.char)
|
||||
.join("");
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ReviewHistoryItem } from "@/lib/api/types";
|
||||
import { latestReview, normalizeConflicts } from "./history";
|
||||
import {
|
||||
latestReview,
|
||||
normalizeConflicts,
|
||||
normalizeForeshadowSug,
|
||||
normalizePace,
|
||||
} from "./history";
|
||||
|
||||
const item = (
|
||||
conflicts: Record<string, unknown>[],
|
||||
@@ -43,6 +48,56 @@ describe("normalizeConflicts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeForeshadowSug", () => {
|
||||
it("tightens flat list with kind, defaulting kind to planted", () => {
|
||||
const out = normalizeForeshadowSug({
|
||||
...item([]),
|
||||
foreshadow_sug: [
|
||||
{ kind: "resolved", code: "F-012", title: "神秘玉佩", where: "第4段" },
|
||||
{ title: "残破地图" },
|
||||
],
|
||||
});
|
||||
expect(out).toEqual([
|
||||
{
|
||||
kind: "resolved",
|
||||
code: "F-012",
|
||||
title: "神秘玉佩",
|
||||
where: "第4段",
|
||||
note: null,
|
||||
},
|
||||
{ kind: "planted", code: null, title: "残破地图", where: null, note: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns [] when missing", () => {
|
||||
expect(normalizeForeshadowSug(item([]))).toEqual([]);
|
||||
expect(normalizeForeshadowSug(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePace", () => {
|
||||
it("tightens dict into PaceReport, filtering bad water/beats", () => {
|
||||
const out = normalizePace({
|
||||
...item([]),
|
||||
pace: {
|
||||
water: [{ where: "第3段", reason: "注水" }, "junk"],
|
||||
hook: true,
|
||||
beat_map: [1, 3, "x", 5],
|
||||
},
|
||||
});
|
||||
expect(out).toEqual({
|
||||
water: [{ where: "第3段", reason: "注水" }],
|
||||
hook: true,
|
||||
beat_map: [1, 3, 5],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when pace is missing or not a dict", () => {
|
||||
expect(normalizePace(item([]))).toBeNull();
|
||||
expect(normalizePace(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("latestReview", () => {
|
||||
it("returns first item (history is newest-first)", () => {
|
||||
const a = item([]);
|
||||
|
||||
@@ -2,17 +2,32 @@
|
||||
// 纯逻辑(可单测);schema 把 conflicts 标成 {[k]:unknown}[],这里安全收窄。
|
||||
|
||||
import type { ReviewHistoryItem } from "@/lib/api/types";
|
||||
import type { ReviewConflict } from "./sse";
|
||||
import type {
|
||||
ForeshadowKind,
|
||||
ForeshadowSuggestion,
|
||||
PaceIssue,
|
||||
PaceReport,
|
||||
ReviewConflict,
|
||||
} from "./sse";
|
||||
|
||||
function asString(v: unknown, fallback = ""): string {
|
||||
return typeof v === "string" ? v : fallback;
|
||||
}
|
||||
|
||||
function asOptionalString(v: unknown): string | null {
|
||||
return typeof v === "string" ? v : null;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === "string");
|
||||
}
|
||||
|
||||
function asIntArray(v: unknown): number[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is number => typeof x === "number" && Number.isFinite(x));
|
||||
}
|
||||
|
||||
// 把一条留痕的 conflicts 收紧(缺字段给安全默认;保持顺序=下标身份,对齐冲突 gate)。
|
||||
export function normalizeConflicts(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
@@ -26,6 +41,46 @@ export function normalizeConflicts(
|
||||
}));
|
||||
}
|
||||
|
||||
// 把一条留痕的 foreshadow_sug(扁平 list,每条带 kind)收紧成建议(缺字段给安全默认)。
|
||||
// 对齐 collect 列映射(C4 扩 T3.3):planted/resolved 扁平成单 list、每条加 kind。
|
||||
export function normalizeForeshadowSug(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
): ForeshadowSuggestion[] {
|
||||
const raw = item?.foreshadow_sug ?? [];
|
||||
return raw.map((s) => ({
|
||||
kind: asKind(s["kind"]),
|
||||
code: asOptionalString(s["code"]),
|
||||
title: asString(s["title"], "(未命名线索)"),
|
||||
where: asOptionalString(s["where"]),
|
||||
note: asOptionalString(s["note"]),
|
||||
}));
|
||||
}
|
||||
|
||||
function asKind(v: unknown): ForeshadowKind {
|
||||
return v === "resolved" ? "resolved" : "planted";
|
||||
}
|
||||
|
||||
// 把一条留痕的 pace(dict)收紧成节奏报告;缺失/非 dict → null(不渲染节拍图)。
|
||||
export function normalizePace(
|
||||
item: ReviewHistoryItem | undefined,
|
||||
): PaceReport | null {
|
||||
const raw = item?.pace;
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const dict = raw as Record<string, unknown>;
|
||||
const waterRaw = Array.isArray(dict["water"]) ? dict["water"] : [];
|
||||
const water: PaceIssue[] = waterRaw
|
||||
.filter((w): w is Record<string, unknown> => typeof w === "object" && w !== null)
|
||||
.map((w) => ({
|
||||
where: asString(w["where"]),
|
||||
reason: asString(w["reason"]),
|
||||
}));
|
||||
return {
|
||||
water,
|
||||
hook: dict["hook"] === true,
|
||||
beat_map: asIntArray(dict["beat_map"]),
|
||||
};
|
||||
}
|
||||
|
||||
// 最近一条留痕(GET .../reviews 已按新→旧排序)。
|
||||
export function latestReview(
|
||||
items: readonly ReviewHistoryItem[] | undefined,
|
||||
|
||||
@@ -49,6 +49,35 @@ describe("parseReviewBlock", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("parses foreshadow and pace frames (C4 扩 T3.3)", () => {
|
||||
expect(
|
||||
parseReviewBlock(
|
||||
'event:foreshadow\ndata:{"kind":"resolved","code":"F-012","title":"神秘玉佩","where":"第4段","note":"疑似回收"}',
|
||||
),
|
||||
).toEqual({
|
||||
event: "foreshadow",
|
||||
data: {
|
||||
kind: "resolved",
|
||||
code: "F-012",
|
||||
title: "神秘玉佩",
|
||||
where: "第4段",
|
||||
note: "疑似回收",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
parseReviewBlock(
|
||||
'event:pace\ndata:{"water":[{"where":"第3段","reason":"注水"}],"hook":false,"beat_map":[1,3,5,2,1,7]}',
|
||||
),
|
||||
).toEqual({
|
||||
event: "pace",
|
||||
data: {
|
||||
water: [{ where: "第3段", reason: "注水" }],
|
||||
hook: false,
|
||||
beat_map: [1, 3, 5, 2, 1, 7],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
@@ -107,6 +136,39 @@ describe("reduceReview", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("accumulates foreshadow suggestions and replaces pace report (last wins)", () => {
|
||||
const events: ReviewSseEvent[] = [
|
||||
{
|
||||
event: "foreshadow",
|
||||
data: { kind: "planted", title: "残破地图", where: "第5段" },
|
||||
},
|
||||
{
|
||||
event: "foreshadow",
|
||||
data: { kind: "resolved", code: "F-012", title: "神秘玉佩" },
|
||||
},
|
||||
{
|
||||
event: "pace",
|
||||
data: { water: [], hook: true, beat_map: [1, 2, 3] },
|
||||
},
|
||||
{
|
||||
event: "pace",
|
||||
data: {
|
||||
water: [{ where: "第3段", reason: "注水" }],
|
||||
hook: false,
|
||||
beat_map: [3, 1, 5],
|
||||
},
|
||||
},
|
||||
];
|
||||
const state = events.reduce(reduceReview, initialReviewState);
|
||||
expect(state.foreshadow.map((f) => f.kind)).toEqual([
|
||||
"planted",
|
||||
"resolved",
|
||||
]);
|
||||
expect(state.pace?.hook).toBe(false);
|
||||
expect(state.pace?.beat_map).toEqual([3, 1, 5]);
|
||||
expect(state.phase).toBe("reviewing");
|
||||
});
|
||||
|
||||
it("marks done and captures error preserving collected data", () => {
|
||||
let s = reduceReview(initialReviewState, {
|
||||
event: "section",
|
||||
|
||||
@@ -19,6 +19,29 @@ export interface ReviewConflict {
|
||||
|
||||
export type SectionStatus = "started" | "done" | "incomplete";
|
||||
|
||||
// 伏笔建议(C4 扩 T3.3):planted=新埋待确认 / resolved=疑似回收(只读建议)。
|
||||
export type ForeshadowKind = "planted" | "resolved";
|
||||
|
||||
export interface ForeshadowSuggestion {
|
||||
kind: ForeshadowKind;
|
||||
code?: string | null;
|
||||
title: string;
|
||||
where?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
// 节奏报告(C4 扩 T3.3):注水段 + 章末钩子 + 爽点节拍图。
|
||||
export interface PaceIssue {
|
||||
where: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PaceReport {
|
||||
water: PaceIssue[];
|
||||
hook: boolean;
|
||||
beat_map: number[];
|
||||
}
|
||||
|
||||
export interface SectionEvent {
|
||||
event: "section";
|
||||
data: { name: string; status: SectionStatus };
|
||||
@@ -27,6 +50,24 @@ export interface ConflictEvent {
|
||||
event: "conflict";
|
||||
data: ReviewConflict;
|
||||
}
|
||||
export interface ForeshadowEvent {
|
||||
event: "foreshadow";
|
||||
data: {
|
||||
kind: ForeshadowKind;
|
||||
code?: string | null;
|
||||
title: string;
|
||||
where?: string | null;
|
||||
note?: string | null;
|
||||
};
|
||||
}
|
||||
export interface PaceEvent {
|
||||
event: "pace";
|
||||
data: {
|
||||
water: PaceIssue[];
|
||||
hook: boolean;
|
||||
beat_map: number[];
|
||||
};
|
||||
}
|
||||
export interface DoneEvent {
|
||||
event: "done";
|
||||
data: { length: number };
|
||||
@@ -38,10 +79,19 @@ export interface ErrorEvent {
|
||||
export type ReviewSseEvent =
|
||||
| SectionEvent
|
||||
| ConflictEvent
|
||||
| ForeshadowEvent
|
||||
| PaceEvent
|
||||
| DoneEvent
|
||||
| ErrorEvent;
|
||||
|
||||
const KNOWN_EVENTS = new Set(["section", "conflict", "done", "error"]);
|
||||
const KNOWN_EVENTS = new Set([
|
||||
"section",
|
||||
"conflict",
|
||||
"foreshadow",
|
||||
"pace",
|
||||
"done",
|
||||
"error",
|
||||
]);
|
||||
|
||||
// 把一个完整 SSE 块(多行)解析成事件;无法解析则返回 null(跳过)。
|
||||
export function parseReviewBlock(block: string): ReviewSseEvent | null {
|
||||
@@ -108,6 +158,8 @@ export interface ReviewStreamState {
|
||||
phase: ReviewPhase;
|
||||
sections: ReviewSection[];
|
||||
conflicts: ReviewConflict[];
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
error: { code: string; message: string; request_id?: string | null } | null;
|
||||
}
|
||||
|
||||
@@ -115,6 +167,8 @@ export const initialReviewState: ReviewStreamState = {
|
||||
phase: "idle",
|
||||
sections: [],
|
||||
conflicts: [],
|
||||
foreshadow: [],
|
||||
pace: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
@@ -136,6 +190,18 @@ export function reduceReview(
|
||||
phase: "reviewing",
|
||||
conflicts: [...state.conflicts, event.data],
|
||||
};
|
||||
case "foreshadow":
|
||||
return {
|
||||
...state,
|
||||
phase: "reviewing",
|
||||
foreshadow: [...state.foreshadow, { ...event.data }],
|
||||
};
|
||||
case "pace":
|
||||
return {
|
||||
...state,
|
||||
phase: "reviewing",
|
||||
pace: { ...event.data },
|
||||
};
|
||||
case "done":
|
||||
return { ...state, phase: "done" };
|
||||
case "error":
|
||||
|
||||
@@ -7,16 +7,24 @@ import {
|
||||
ReviewFrameBuffer,
|
||||
initialReviewState,
|
||||
reduceReview,
|
||||
type ForeshadowSuggestion,
|
||||
type PaceReport,
|
||||
type ReviewConflict,
|
||||
type ReviewStreamState,
|
||||
} from "./sse";
|
||||
|
||||
export interface ReviewSeed {
|
||||
conflicts: ReviewConflict[];
|
||||
foreshadow: ForeshadowSuggestion[];
|
||||
pace: PaceReport | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "start" }
|
||||
| { type: "events"; events: ReturnType<ReviewFrameBuffer["push"]> }
|
||||
| { type: "abort" }
|
||||
| { type: "fail"; code: string; message: string }
|
||||
| { type: "seed"; conflicts: ReviewConflict[] };
|
||||
| { type: "seed"; seed: ReviewSeed };
|
||||
|
||||
function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
|
||||
switch (action.type) {
|
||||
@@ -33,7 +41,12 @@ function reducer(state: ReviewStreamState, action: Action): ReviewStreamState {
|
||||
error: { code: action.code, message: action.message },
|
||||
};
|
||||
case "seed":
|
||||
return { ...initialReviewState, conflicts: action.conflicts };
|
||||
return {
|
||||
...initialReviewState,
|
||||
conflicts: action.seed.conflicts,
|
||||
foreshadow: action.seed.foreshadow,
|
||||
pace: action.seed.pace,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
@@ -45,8 +58,8 @@ export interface UseReviewStream {
|
||||
// 用当前编辑器草稿重新审稿。
|
||||
start: (projectId: string, chapterNo: number, draft: string) => Promise<void>;
|
||||
stop: () => void;
|
||||
// 进页用历史留痕的冲突种入(无需重审即可裁决)。
|
||||
seed: (conflicts: ReviewConflict[]) => void;
|
||||
// 进页用历史留痕(冲突 + 伏笔建议 + 节奏)种入(无需重审即可裁决/查看)。
|
||||
seed: (seed: ReviewSeed) => void;
|
||||
}
|
||||
|
||||
// 消费 POST .../review 的 SSE 流:fetch+ReadableStream(EventSource 不支持 POST)。
|
||||
@@ -62,8 +75,8 @@ export function useReviewStream(): UseReviewStream {
|
||||
dispatch({ type: "abort" });
|
||||
}, []);
|
||||
|
||||
const seed = useCallback((conflicts: ReviewConflict[]) => {
|
||||
dispatch({ type: "seed", conflicts });
|
||||
const seed = useCallback((seedData: ReviewSeed) => {
|
||||
dispatch({ type: "seed", seed: seedData });
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user