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:
Yaojia Wang
2026-06-18 11:38:28 +02:00
parent d3dc620a71
commit b523b4fd21
70 changed files with 6642 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
"""网关单测公用 fixtures。"""
from __future__ import annotations
import uuid
import pytest
from ww_llm_gateway.types import LlmRequest, Scope
@pytest.fixture
def scope() -> Scope:
return Scope(user_id=uuid.UUID(int=1), project_id=uuid.UUID(int=2))
@pytest.fixture
def req(scope: Scope) -> LlmRequest:
return LlmRequest(tier="writer", input="写第 1 章", scope=scope)

View File

@@ -0,0 +1,56 @@
"""网关单测替身mock provider + 内存 ledger——不碰真实 API / DB。
放在独立模块(非 conftest以便测试用绝对导入 `from fakes import ...`
本目录无 __init__.py避免与顶层 tests 包同名冲突),故走 pytest 路径注入。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from ww_llm_gateway.adapters.base import (
Capabilities,
ProviderResult,
ProviderUsage,
StreamChunk,
)
from ww_llm_gateway.routing import Route
from ww_llm_gateway.types import LlmRequest, Scope, Tier, Usage
class FakeAdapter:
provider = "deepseek"
def __init__(self, text: str = "hello world", deltas: list[str] | None = None) -> None:
self.text = text
self.deltas = deltas if deltas is not None else ["hel", "lo ", "world"]
self.complete_calls = 0
self.stream_calls = 0
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True, prefix_cache=True)
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
self.complete_calls += 1
return ProviderResult(
text=self.text,
usage=ProviderUsage(input_tokens=100, output_tokens=50),
)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
self.stream_calls += 1
for d in self.deltas:
yield StreamChunk(text=d)
yield StreamChunk(usage=ProviderUsage(input_tokens=100, output_tokens=50))
class FakeLedger:
def __init__(self) -> None:
self.records: list[Usage] = []
async def record(self, scope: Scope, usage: Usage) -> None:
self.records.append(usage)
def fake_route(tier: Tier) -> Route:
return Route(provider="deepseek", model="deepseek-chat")

View File

@@ -0,0 +1,82 @@
"""T1.1 网关核心单测run / stream / 记账 / 路由 / 错误。"""
from __future__ import annotations
import uuid
import pytest
from fakes import FakeAdapter, FakeLedger, fake_route
from ww_llm_gateway.gateway import Gateway
from ww_llm_gateway.routing import resolve_route
from ww_llm_gateway.types import LlmRequest, Scope
from ww_shared import AppError, ErrorCode
async def test_run_returns_text_usage_and_served_by(req: LlmRequest) -> None:
adapter = FakeAdapter(text="第一章正文")
ledger = FakeLedger()
gw = Gateway({"deepseek": adapter}, ledger, resolver=fake_route)
resp = await gw.run(req)
assert resp.text == "第一章正文"
assert resp.served_by.provider == "deepseek"
assert resp.served_by.fell_back is False
assert resp.usage.input_tokens == 100
assert resp.usage.output_tokens == 50
assert adapter.complete_calls == 1
async def test_run_writes_exactly_one_ledger_record(req: LlmRequest) -> None:
ledger = FakeLedger()
gw = Gateway({"deepseek": FakeAdapter()}, ledger, resolver=fake_route)
await gw.run(req)
assert len(ledger.records) == 1
assert ledger.records[0].provider == "deepseek"
assert ledger.records[0].model == "deepseek-chat"
async def test_run_computes_cost_from_pricing(req: LlmRequest) -> None:
ledger = FakeLedger()
gw = Gateway({"deepseek": FakeAdapter()}, ledger, resolver=fake_route)
resp = await gw.run(req)
# ceil(100/1e6*27 + 50/1e6*110) == 1 cent
assert resp.usage.cost_minor == 1
assert resp.usage.currency == "USD"
async def test_stream_yields_deltas_and_records_once(req: LlmRequest) -> None:
ledger = FakeLedger()
gw = Gateway({"deepseek": FakeAdapter(deltas=["a", "b", "c"])}, ledger, resolver=fake_route)
collected = [d.text async for d in gw.stream(req)]
assert "".join(collected) == "abc"
assert len(ledger.records) == 1
assert ledger.records[0].output_tokens == 50
async def test_unknown_provider_raises_llm_unavailable(req: LlmRequest) -> None:
gw = Gateway({}, FakeLedger(), resolver=fake_route)
with pytest.raises(AppError) as exc:
await gw.run(req)
assert exc.value.code == ErrorCode.LLM_UNAVAILABLE
def test_resolve_route_parses_tier_defaults() -> None:
route = resolve_route("writer")
assert route.provider == "deepseek"
assert route.model == "deepseek-chat"
def test_agent_passes_only_tier_never_model() -> None:
# 不变量 ②LlmRequest 无 model 字段,只有 tier
req = LlmRequest(tier="analyst", input="x", scope=Scope(user_id=uuid.UUID(int=9)))
assert "model" not in LlmRequest.model_fields
assert req.tier == "analyst"

View File

@@ -0,0 +1,102 @@
"""OpenAI 兼容适配器单测:用替身 client 验证消息翻译 + usage 映射(不联网)。"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from types import SimpleNamespace
from typing import Any, cast
from openai import AsyncOpenAI
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter, _messages
from ww_llm_gateway.types import Block, LlmRequest, Scope
def _req(**kw: Any) -> LlmRequest:
kw.setdefault("tier", "writer")
return LlmRequest(scope=Scope(user_id=uuid.UUID(int=1)), **kw)
class _FakeStream:
def __init__(self, chunks: list[Any]) -> None:
self._chunks = chunks
async def __aiter__(self) -> AsyncIterator[Any]:
for c in self._chunks:
yield c
class _FakeCompletions:
def __init__(self, response: Any, stream_chunks: list[Any]) -> None:
self._response = response
self._stream_chunks = stream_chunks
self.last_messages: Any = None
async def create(self, **kw: Any) -> Any:
self.last_messages = kw.get("messages")
if kw.get("stream"):
return _FakeStream(self._stream_chunks)
return self._response
class _FakeClient:
def __init__(self, completions: _FakeCompletions) -> None:
self.chat = SimpleNamespace(completions=completions)
def test_messages_put_system_first_then_user() -> None:
req = _req(input="正文", system=[Block(text="世界观硬规则", cache=True)])
msgs = _messages(req)
assert msgs[0]["role"] == "system"
assert msgs[0]["content"] == "世界观硬规则"
assert msgs[1]["role"] == "user"
assert msgs[1]["content"] == "正文"
async def test_complete_maps_text_and_usage() -> None:
response = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="草稿"))],
usage=SimpleNamespace(
prompt_tokens=120,
completion_tokens=80,
prompt_tokens_details=SimpleNamespace(cached_tokens=30),
),
)
completions = _FakeCompletions(response, [])
adapter = OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, _FakeClient(completions)))
result = await adapter.complete(_req(input="x"), "deepseek-chat")
assert result.text == "草稿"
assert result.usage.input_tokens == 120
assert result.usage.output_tokens == 80
assert result.usage.cache_read_tokens == 30
async def test_stream_yields_text_then_final_usage() -> None:
chunks = [
SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=""))], usage=None),
SimpleNamespace(choices=[SimpleNamespace(delta=SimpleNamespace(content=""))], usage=None),
SimpleNamespace(
choices=[],
usage=SimpleNamespace(
prompt_tokens=10,
completion_tokens=2,
prompt_tokens_details=None,
),
),
]
completions = _FakeCompletions(None, chunks)
adapter = OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, _FakeClient(completions)))
texts: list[str] = []
usage_seen = None
async for ch in adapter.stream(_req(input="x"), "deepseek-chat"):
if ch.text:
texts.append(ch.text)
if ch.usage is not None:
usage_seen = ch.usage
assert "".join(texts) == "他说"
assert usage_seen is not None
assert usage_seen.output_tokens == 2

View File

@@ -0,0 +1,123 @@
"""T2.1 结构化输出接线单测output_schema → instructor → LlmResponse.parsed。
注入 fake instructor client返回 (parsed, raw_completion)),不联网。
覆盖:① 带 schema → parsed 是实例且字段正确 + 仍记 1 条 ledger
② 无 schema → parsed is None 且纯文本路径不变。
"""
from __future__ import annotations
import uuid
from types import SimpleNamespace
from typing import Any, cast
from openai import AsyncOpenAI
from pydantic import BaseModel
from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter
from ww_llm_gateway.gateway import Gateway
from ww_llm_gateway.routing import Route
from ww_llm_gateway.types import LlmRequest, Scope, Tier
class _Out(BaseModel):
label: str
score: int
def _req(**kw: Any) -> LlmRequest:
kw.setdefault("tier", "analyst")
return LlmRequest(scope=Scope(user_id=uuid.UUID(int=1)), **kw)
def _route(_tier: Tier) -> Route:
return Route(provider="deepseek", model="deepseek-chat")
class _FakeStructured:
"""模拟 instructor.AsyncInstructorcreate_with_completion 返回 (parsed, raw)。"""
def __init__(self, parsed: BaseModel, raw: Any) -> None:
self._parsed = parsed
self._raw = raw
self.calls = 0
self.last_response_model: Any = None
async def create_with_completion(
self, *, messages: Any, response_model: Any, **kw: Any
) -> tuple[BaseModel, Any]:
self.calls += 1
self.last_response_model = response_model
return self._parsed, self._raw
def _raw_with_usage() -> Any:
return SimpleNamespace(
usage=SimpleNamespace(
prompt_tokens=42,
completion_tokens=7,
prompt_tokens_details=SimpleNamespace(cached_tokens=5),
)
)
def _client() -> AsyncOpenAI:
return cast(AsyncOpenAI, SimpleNamespace(chat=SimpleNamespace(completions=None)))
async def test_complete_with_schema_returns_parsed_instance() -> None:
structured = _FakeStructured(_Out(label="ok", score=9), _raw_with_usage())
adapter = OpenAICompatAdapter("deepseek", _client(), structured_client=structured)
result = await adapter.complete(_req(input="x", output_schema=_Out), "deepseek-chat")
assert isinstance(result.parsed, _Out)
assert result.parsed.label == "ok"
assert result.parsed.score == 9
assert structured.last_response_model is _Out
# usage 从 raw completion 提取
assert result.usage.input_tokens == 42
assert result.usage.output_tokens == 7
assert result.usage.cache_read_tokens == 5
async def test_complete_without_schema_keeps_parsed_none() -> None:
response = SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content="纯文本"))],
usage=SimpleNamespace(prompt_tokens=3, completion_tokens=2, prompt_tokens_details=None),
)
class _Completions:
async def create(self, **kw: Any) -> Any:
return response
client = cast(
AsyncOpenAI,
SimpleNamespace(chat=SimpleNamespace(completions=_Completions())),
)
adapter = OpenAICompatAdapter("deepseek", client)
result = await adapter.complete(_req(input="x"), "deepseek-chat")
assert result.parsed is None
assert result.text == "纯文本"
class _FakeLedger:
def __init__(self) -> None:
self.records: list[Any] = []
async def record(self, scope: Any, usage: Any) -> None:
self.records.append(usage)
async def test_gateway_run_passes_parsed_through_and_records_once() -> None:
structured = _FakeStructured(_Out(label="hit", score=1), _raw_with_usage())
adapter = OpenAICompatAdapter("deepseek", _client(), structured_client=structured)
ledger = _FakeLedger()
gw = Gateway({"deepseek": adapter}, ledger, resolver=_route)
resp = await gw.run(_req(input="x", output_schema=_Out))
assert isinstance(resp.parsed, _Out)
assert resp.parsed.label == "hit"
assert len(ledger.records) == 1