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:
Yaojia Wang
2026-06-18 14:21:17 +02:00
parent 68f194a043
commit 5fb7bfb1de
74 changed files with 6529 additions and 126 deletions

View File

@@ -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`,绝不联网。"""