Files
writer-work-flow/apps/api/tests/fakes_projects.py
2026-06-28 07:31:20 +02:00

533 lines
19 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 datetime import UTC, datetime
from typing import Any
from pydantic import BaseModel
from ww_core.domain import ForeshadowLedgerView, OutlineWriteView, StyleFingerprintView
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.job_repo import (
PROGRESS_COMPLETE,
STATUS_AWAITING,
STATUS_DONE,
STATUS_FAILED,
STATUS_QUEUED,
STATUS_RUNNING,
JobView,
)
from ww_core.domain.project_repo import ProjectCreate, ProjectView
from ww_core.domain.repositories import DigestView, OutlineView
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]] = {}
self.now = datetime(2026, 6, 28, tzinfo=UTC)
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,
updated_at=self.now,
)
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 FakeOutlineReadRepo:
"""实现读侧 `OutlineRepo` Protocol内存DB beats 形 `{"beats": [...]}`)。
`add_chapter` 以裸 `list[str]` 便利入种,内部包成 dict与 DB JSONB round-trip 一致)。
`list_for_project` 按 chapter_no 升序返回(仿 SqlOutlineRepo 的 order_by
"""
def __init__(self) -> None:
self.rows: dict[tuple[uuid.UUID, int], OutlineView] = {}
def add_chapter(
self,
project_id: uuid.UUID,
*,
volume: int,
chapter_no: int,
beats: list[str],
foreshadow_windows: list[dict[str, Any]] | None = None,
) -> None:
self.rows[(project_id, chapter_no)] = OutlineView(
volume=volume,
chapter_no=chapter_no,
beats={"beats": list(beats)},
foreshadow_windows=[dict(w) for w in (foreshadow_windows or [])],
)
async def get(self, project_id: uuid.UUID, chapter_no: int) -> OutlineView | None:
return self.rows.get((project_id, chapter_no))
async def list_for_project(self, project_id: uuid.UUID) -> list[OutlineView]:
views = [v for (p, _), v in self.rows.items() if p == project_id]
return sorted(views, key=lambda v: v.chapter_no)
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 FakeStyleWriteRepo:
"""实现 `StyleFingerprintWriteRepo` Protocol内存版本化 append + 最新读取)。
`append` 模拟 version=当前 max+1首次 1`latest` 取最大 version 行。
供 `GET /style` 读侧 + 学文风 workmode=update → 第二次 append version+1断言。
"""
def __init__(self) -> None:
# version -> (dimensions, evidence)
self.rows: dict[int, tuple[dict[str, Any], dict[str, Any]]] = {}
self.append_calls = 0
async def append(
self,
project_id: uuid.UUID,
*,
dimensions_json: dict[str, Any],
evidence_json: dict[str, Any],
) -> int:
self.append_calls += 1
next_version = (max(self.rows) if self.rows else 0) + 1
self.rows[next_version] = (dict(dimensions_json), dict(evidence_json))
return next_version
async def latest(self, project_id: uuid.UUID) -> StyleFingerprintView | None:
if not self.rows:
return None
version = max(self.rows)
dimensions, evidence = self.rows[version]
return StyleFingerprintView(dimensions=dimensions, evidence=evidence, version=version)
class FakeJobRepo:
"""实现 `JobRepo` Protocol内存create/get/状态流转,供 `POST /style` 断言。"""
def __init__(self) -> None:
self.rows: dict[uuid.UUID, JobView] = {}
async def create(self, project_id: uuid.UUID | None, kind: str) -> JobView:
view = JobView(
id=uuid.uuid4(),
project_id=project_id,
kind=kind,
status=STATUS_QUEUED,
progress=0,
)
self.rows[view.id] = view
return view
async def set_running(self, job_id: uuid.UUID) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_RUNNING})
self.rows[job_id] = view
return view
async def set_progress(self, job_id: uuid.UUID, pct: int) -> JobView:
view = self.rows[job_id].model_copy(update={"progress": max(0, min(100, pct))})
self.rows[job_id] = view
return view
async def complete(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
view = self.rows[job_id].model_copy(
update={"status": STATUS_DONE, "progress": PROGRESS_COMPLETE, "result": dict(result)}
)
self.rows[job_id] = view
return view
async def set_awaiting(self, job_id: uuid.UUID, result: dict[str, Any]) -> JobView:
view = self.rows[job_id].model_copy(
update={"status": STATUS_AWAITING, "result": dict(result)}
)
self.rows[job_id] = view
return view
async def claim_awaiting_to_running(self, job_id: uuid.UUID) -> JobView | None:
"""原子抢占 awaiting→running内存替身仅当现态 awaiting 才抢到,否则 None"""
current = self.rows.get(job_id)
if current is None or current.status != STATUS_AWAITING:
return None
view = current.model_copy(update={"status": STATUS_RUNNING})
self.rows[job_id] = view
return view
async def fail(self, job_id: uuid.UUID, error: str) -> JobView:
view = self.rows[job_id].model_copy(update={"status": STATUS_FAILED, "error": error})
self.rows[job_id] = view
return view
async def get(self, job_id: uuid.UUID) -> JobView | None:
return self.rows.get(job_id)
async def reap_zombies(self) -> int:
return 0
class FakeReviewGateway:
"""实现审稿/digest 节点最小依赖(`run`):吐固定结构化 `parsed`,绝不联网。"""
def __init__(
self,
parsed: BaseModel | None = None,
error: Exception | None = None,
*,
text: str | None = None,
) -> None:
self._parsed = parsed
self._error = error
# `text` 覆盖纯文本产出refiner output_schema=None → 端点消费 resp.text
self._text = text
self.requests: list[LlmRequest] = []
async def run(self, req: LlmRequest) -> LlmResponse:
self.requests.append(req)
if self._error is not None:
raise self._error
if self._text is not None:
out_text = self._text
elif self._parsed is not None:
out_text = self._parsed.model_dump_json()
else:
out_text = "{}"
return LlmResponse(
text=out_text,
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