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`,绝不联网。"""
|
||||
|
||||
Reference in New Issue
Block a user