Files
writer-work-flow/apps/api/tests/fakes_projects.py
Yaojia Wang 5fb7bfb1de 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
2026-06-18 14:21:17 +02:00

379 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""内存替身:项目/章节 repo + 写章网关(端点测试用,无 DB/无网络)。
绝对导入 `from fakes_projects import ...`(包目录无 __init__.py见 memory/gotchas
全局唯一名(避免跨包同名碰撞)。
"""
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
from ww_llm_gateway.types import Delta, LlmRequest, LlmResponse, ServedBy, Usage
class FakeProjectRepo:
"""实现 `ProjectRepo` Protocol 的内存版(按 owner_id 隔离)。"""
def __init__(self) -> None:
self.rows: dict[uuid.UUID, tuple[uuid.UUID, ProjectView]] = {}
async def create(self, owner_id: uuid.UUID, data: ProjectCreate) -> ProjectView:
pid = uuid.uuid4()
view = ProjectView(
id=pid,
title=data.title,
genre=data.genre,
logline=data.logline,
premise=data.premise,
theme=data.theme,
selling_points=list(data.selling_points),
structure=data.structure,
)
self.rows[pid] = (owner_id, view)
return view
async def list_for_owner(self, owner_id: uuid.UUID) -> list[ProjectView]:
return [v for (o, v) in self.rows.values() if o == owner_id]
async def get(self, owner_id: uuid.UUID, project_id: uuid.UUID) -> ProjectView | None:
entry = self.rows.get(project_id)
if entry is None or entry[0] != owner_id:
return None
return entry[1]
class FakeChapterRepo:
"""实现 `ChapterRepo` Protocol幂等覆盖同 (project_id, chapter_no) 草稿。"""
def __init__(self) -> None:
self.drafts: dict[tuple[uuid.UUID, int], ChapterDraftView] = {}
self.accepted_versions: dict[tuple[uuid.UUID, int], int] = {}
self.accepted_content: dict[tuple[uuid.UUID, int, int], str] = {}
async def save_draft(
self, project_id: uuid.UUID, chapter_no: int, *, text: str, volume: int = 1
) -> ChapterDraftView:
view = ChapterDraftView(
project_id=project_id,
chapter_no=chapter_no,
volume=volume,
content=text,
status="draft",
version=1,
)
self.drafts[(project_id, chapter_no)] = view
return view
async def get_draft(self, project_id: uuid.UUID, chapter_no: int) -> ChapterDraftView | None:
return self.drafts.get((project_id, chapter_no))
async def max_version(self, project_id: uuid.UUID, chapter_no: int) -> int:
versions = [
v for (p, c), v in self.accepted_versions.items() if p == project_id and c == chapter_no
]
draft = 1 if (project_id, chapter_no) in self.drafts else 0
return max([*versions, draft], default=0)
async def promote_to_accepted(
self, project_id: uuid.UUID, chapter_no: int, *, content: str, volume: int = 1
) -> ChapterView:
next_version = (await self.max_version(project_id, chapter_no)) + 1
self.accepted_versions[(project_id, chapter_no)] = next_version
self.accepted_content[(project_id, chapter_no, next_version)] = content
return ChapterView(
project_id=project_id,
chapter_no=chapter_no,
volume=volume,
content=content,
status="accepted",
version=next_version,
)
async def latest_accepted(self, project_id: uuid.UUID, chapter_no: int) -> ChapterView | None:
ver = self.accepted_versions.get((project_id, chapter_no))
if ver is None:
return None
return ChapterView(
project_id=project_id,
chapter_no=chapter_no,
volume=1,
content=self.accepted_content[(project_id, chapter_no, ver)],
status="accepted",
version=ver,
)
class FakeReviewRepo:
"""实现 `ReviewRepo` Protocol内存留痕 + 历史 + 裁决;只 flush 语义无 DB"""
def __init__(self, *, fail_set_decisions: bool = False) -> None:
self.rows: list[ReviewView] = []
self._fail_set_decisions = fail_set_decisions
async def record(
self,
project_id: uuid.UUID,
chapter_no: int,
*,
chapter_version: int | None = None,
conflicts: list[dict[str, Any]],
foreshadow_sug: list[dict[str, Any]] | None = None,
style: dict[str, Any] | None = None,
pace: dict[str, Any] | None = None,
health_score: int | None = None,
) -> ReviewView:
view = ReviewView(
id=uuid.uuid4(),
project_id=project_id,
chapter_no=chapter_no,
chapter_version=chapter_version,
conflicts=[dict(c) for c in conflicts],
foreshadow_sug=[dict(s) for s in (foreshadow_sug or [])],
style=style,
pace=pace,
health_score=health_score,
)
# 新→旧:插到列表头部。
self.rows.insert(0, view)
return view
async def list_for_chapter(self, project_id: uuid.UUID, chapter_no: int) -> list[ReviewView]:
return [r for r in self.rows if r.project_id == project_id and r.chapter_no == chapter_no]
async def set_decisions(self, review_id: uuid.UUID, *, decisions: dict[str, Any]) -> ReviewView:
if self._fail_set_decisions:
raise RuntimeError("boom: set_decisions failed (transaction rollback test)")
for i, r in enumerate(self.rows):
if r.id == review_id:
updated = r.model_copy(update={"decisions": dict(decisions)})
self.rows[i] = updated
return updated
raise LookupError(f"review not found: {review_id}")
class FakeDigestAppendRepo:
"""实现 `DigestAppendRepo` Protocol内存 append-only"""
def __init__(self) -> None:
self.rows: list[tuple[uuid.UUID, int, dict[str, Any]]] = []
async def append(
self, project_id: uuid.UUID, chapter_no: int, *, facts: dict[str, Any]
) -> DigestView:
self.rows.append((project_id, chapter_no, dict(facts)))
return DigestView(chapter_no=chapter_no, facts=dict(facts))
class FakeSession:
"""最小 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`,绝不联网。"""
def __init__(self, parsed: BaseModel | None = None, error: Exception | None = None) -> None:
self._parsed = parsed
self._error = error
self.requests: list[LlmRequest] = []
async def run(self, req: LlmRequest) -> LlmResponse:
self.requests.append(req)
if self._error is not None:
raise self._error
return LlmResponse(
text=self._parsed.model_dump_json() if self._parsed else "{}",
parsed=self._parsed,
usage=Usage(
provider="fake",
model="fake",
input_tokens=1,
output_tokens=1,
cost_minor=0,
currency="USD",
),
served_by=ServedBy(provider="fake", model="fake"),
)
class FakeWriterGateway:
"""实现 write 节点最小依赖(`stream`):吐固定 `Delta` 序列,绝不联网。
可注入 `error` 以验证 SSE 归一层把异常归一为 error 事件。
"""
def __init__(self, chunks: list[str] | None = None, error: Exception | None = None) -> None:
self._chunks = chunks if chunks is not None else ["第一段。", "第二段。"]
self._error = error
self.requests: list[LlmRequest] = []
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
self.requests.append(req)
for c in self._chunks:
yield Delta(text=c)
if self._error is not None:
raise self._error