- 薄自建 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 走通闭环
228 lines
8.2 KiB
Python
228 lines
8.2 KiB
Python
"""内存替身:项目/章节 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
|