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,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")