"""内存替身:项目/章节 repo + 写章网关(端点测试用,无 DB/无网络)。 绝对导入 `from fakes_projects import ...`(包目录无 __init__.py,见 memory/gotchas)。 全局唯一名(避免跨包同名碰撞)。 """ from __future__ import annotations import uuid from collections.abc import AsyncIterator from typing import Any from pydantic import BaseModel from ww_core.domain.chapter_repo import ChapterDraftView, ChapterView 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 次数(验收事务边界断言)。""" def __init__(self) -> None: self.commits = 0 async def commit(self) -> None: self.commits += 1 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