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 走通闭环
This commit is contained in:
227
apps/api/tests/fakes_projects.py
Normal file
227
apps/api/tests/fakes_projects.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""内存替身:项目/章节 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
|
||||
Reference in New Issue
Block a user