Files
writer-work-flow/apps/api/tests/fakes_projects.py
Yaojia Wang b523b4fd21 feat: M1 — 立项→写章草稿(SSE)→自动保存;连一家 provider
- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由
- 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本)
- LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error)
- API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接)
- 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页
- M1 E2E:真实 DB + mock 网关零 token 走通闭环
2026-06-18 11:38:28 +02:00

228 lines
8.2 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 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