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,47 @@
"""LLM 网关C1 / ARCH §4薄自建tier→provider+model屏蔽厂商差异。"""
from __future__ import annotations
from .adapters.base import (
Capabilities,
ProviderAdapter,
ProviderResult,
ProviderUsage,
StreamChunk,
)
from .adapters.openai_compat import OpenAICompatAdapter
from .gateway import Gateway
from .ledger import LedgerSink, SqlAlchemyLedgerSink
from .routing import Route, resolve_route
from .types import (
Block,
Delta,
LlmRequest,
LlmResponse,
Scope,
ServedBy,
Tier,
Usage,
)
__all__ = [
"Block",
"Capabilities",
"Delta",
"Gateway",
"LedgerSink",
"LlmRequest",
"LlmResponse",
"OpenAICompatAdapter",
"ProviderAdapter",
"ProviderResult",
"ProviderUsage",
"Route",
"Scope",
"ServedBy",
"SqlAlchemyLedgerSink",
"StreamChunk",
"Tier",
"Usage",
"resolve_route",
]

View File

@@ -0,0 +1,51 @@
"""适配器接口与中间数据形ARCH §4.2/§4.4)。
适配器把 `LlmRequest` 翻译成目标厂商请求,并把响应/流/usage 翻译回统一中间形。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Protocol, runtime_checkable
from pydantic import BaseModel, ConfigDict
from ..types import LlmRequest
class Capabilities(BaseModel):
structured_output: bool = False
prefix_cache: bool = False
thinking: bool = False
class ProviderUsage(BaseModel):
input_tokens: int
output_tokens: int
cache_read_tokens: int = 0
class ProviderResult(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
text: str
usage: ProviderUsage
parsed: BaseModel | None = None # output_schema 命中时的结构化结果§4.4
class StreamChunk(BaseModel):
"""流式块文本增量usage=None或末尾用量块text="")。"""
text: str = ""
usage: ProviderUsage | None = None
@runtime_checkable
class ProviderAdapter(Protocol):
provider: str
def capabilities(self) -> Capabilities: ...
async def complete(self, req: LlmRequest, model: str) -> ProviderResult: ...
def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]: ...

View File

@@ -0,0 +1,129 @@
"""OpenAI 兼容适配器:一套覆盖 DeepSeek/Kimi/Qwen/GLM/OpenAIARCH §4.2)。
仅 base_url + model + key 不同。注入 `AsyncOpenAI` 客户端以便测试用替身。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any, Protocol
import instructor
from openai import AsyncOpenAI
from openai.types.chat import ChatCompletionMessageParam
from pydantic import BaseModel
from ..types import LlmRequest
from .base import Capabilities, ProviderResult, ProviderUsage, StreamChunk
class StructuredClient(Protocol):
"""instructor 风格的结构化客户端缝(`AsyncInstructor` 即满足此协议)。
抽成 Protocol 以便测试注入 fake绝不联网不变量测试零真实 LLM
"""
async def create_with_completion(
self, *, messages: Any, response_model: type[BaseModel], **kwargs: Any
) -> tuple[BaseModel, Any]: ...
def _system_text(req: LlmRequest) -> str:
return "\n\n".join(b.text for b in req.system)
def _input_text(req: LlmRequest) -> str:
if isinstance(req.input, str):
return req.input
return "\n\n".join(b.text for b in req.input)
def _messages(req: LlmRequest) -> list[ChatCompletionMessageParam]:
msgs: list[ChatCompletionMessageParam] = []
system = _system_text(req)
if system:
msgs.append({"role": "system", "content": system})
msgs.append({"role": "user", "content": _input_text(req)})
return msgs
def _cache_read(usage: Any) -> int:
details = getattr(usage, "prompt_tokens_details", None)
if details is None:
return 0
return int(getattr(details, "cached_tokens", 0) or 0)
def _usage_from(usage: Any) -> ProviderUsage:
if usage is None:
return ProviderUsage(input_tokens=0, output_tokens=0)
return ProviderUsage(
input_tokens=getattr(usage, "prompt_tokens", 0) or 0,
output_tokens=getattr(usage, "completion_tokens", 0) or 0,
cache_read_tokens=_cache_read(usage),
)
class OpenAICompatAdapter:
def __init__(
self,
provider: str,
client: AsyncOpenAI,
*,
structured_client: StructuredClient | None = None,
) -> None:
self.provider = provider
self._client = client
# 结构化输出走 instructorPydantic 校验 + 重试,锁定栈);可注入便于测试。
self._structured_client = structured_client
def capabilities(self) -> Capabilities:
return Capabilities(structured_output=True, prefix_cache=True, thinking=False)
def _structured(self) -> StructuredClient:
if self._structured_client is None:
# 懒构建:从同一 AsyncOpenAI client patch 出 instructor 客户端。
self._structured_client = instructor.from_openai(self._client)
return self._structured_client
async def complete(self, req: LlmRequest, model: str) -> ProviderResult:
if req.output_schema is not None:
return await self._complete_structured(req, model)
return await self._complete_text(req, model)
async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult:
resp = await self._client.chat.completions.create(
model=model,
messages=_messages(req),
max_tokens=req.max_tokens,
)
text = resp.choices[0].message.content or ""
return ProviderResult(text=text, usage=_usage_from(resp.usage))
async def _complete_structured(self, req: LlmRequest, model: str) -> ProviderResult:
assert req.output_schema is not None
parsed, raw = await self._structured().create_with_completion(
messages=_messages(req),
response_model=req.output_schema,
model=model,
max_tokens=req.max_tokens,
)
usage = _usage_from(getattr(raw, "usage", None))
# 文本载体保留校验后的 JSON便于日志/留痕);程序消费走 parsed。
return ProviderResult(text=parsed.model_dump_json(), usage=usage, parsed=parsed)
async def stream(self, req: LlmRequest, model: str) -> AsyncIterator[StreamChunk]:
stream = await self._client.chat.completions.create(
model=model,
messages=_messages(req),
max_tokens=req.max_tokens,
stream=True,
stream_options={"include_usage": True},
)
async for chunk in stream:
if chunk.choices:
delta = chunk.choices[0].delta
if delta and delta.content:
yield StreamChunk(text=delta.content)
if getattr(chunk, "usage", None):
yield StreamChunk(usage=_usage_from(chunk.usage))

View File

@@ -0,0 +1,99 @@
"""网关核心:路由 → 调用适配器 → 记账 → 返回ARCH §4.14.3/§4.8)。
M1 单 provider无回退/熔断(那是 M5/T5.4)。流式经 `stream()` 归一为 `Delta`。
日志脱敏:只记长度,绝不记原文/api key不变量、§9.3)。
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Callable
import structlog
from ww_shared import AppError, ErrorCode
from .adapters.base import ProviderAdapter, ProviderUsage
from .ledger import LedgerSink
from .pricing import cost_minor
from .routing import Route, resolve_route
from .types import Delta, LlmRequest, LlmResponse, ServedBy, Tier, Usage
log = structlog.get_logger(__name__)
def _input_len(req: LlmRequest) -> int:
if isinstance(req.input, str):
return len(req.input)
return sum(len(b.text) for b in req.input)
class Gateway:
def __init__(
self,
adapters: dict[str, ProviderAdapter],
ledger: LedgerSink,
resolver: Callable[[Tier], Route] = resolve_route,
) -> None:
self._adapters = adapters
self._ledger = ledger
self._resolve = resolver
def _adapter_for(self, provider: str) -> ProviderAdapter:
adapter = self._adapters.get(provider)
if adapter is None:
raise AppError(ErrorCode.LLM_UNAVAILABLE, f"no adapter for provider {provider!r}")
return adapter
def _usage(self, route: Route, pu: ProviderUsage) -> Usage:
cost, currency = cost_minor(route.provider, route.model, pu.input_tokens, pu.output_tokens)
return Usage(
provider=route.provider,
model=route.model,
input_tokens=pu.input_tokens,
output_tokens=pu.output_tokens,
cache_read_tokens=pu.cache_read_tokens,
cost_minor=cost,
currency=currency,
)
def _log_call(self, req: LlmRequest, usage: Usage, *, stream: bool) -> None:
log.info(
"llm_call",
provider=usage.provider,
model=usage.model,
tier=req.tier,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
cache_read_tokens=usage.cache_read_tokens,
cost_minor=usage.cost_minor,
currency=usage.currency,
stream=stream,
input_chars=_input_len(req),
project_id=str(req.scope.project_id) if req.scope.project_id else None,
)
async def run(self, req: LlmRequest) -> LlmResponse:
route = self._resolve(req.tier)
adapter = self._adapter_for(route.provider)
result = await adapter.complete(req, route.model)
usage = self._usage(route, result.usage)
await self._ledger.record(req.scope, usage)
self._log_call(req, usage, stream=False)
return LlmResponse(
text=result.text,
parsed=result.parsed,
usage=usage,
served_by=ServedBy(provider=route.provider, model=route.model),
)
async def stream(self, req: LlmRequest) -> AsyncIterator[Delta]:
route = self._resolve(req.tier)
adapter = self._adapter_for(route.provider)
final = ProviderUsage(input_tokens=0, output_tokens=0)
async for chunk in adapter.stream(req, route.model):
if chunk.text:
yield Delta(text=chunk.text)
if chunk.usage is not None:
final = chunk.usage
usage = self._usage(route, final)
await self._ledger.record(req.scope, usage)
self._log_call(req, usage, stream=True)

View File

@@ -0,0 +1,39 @@
"""用量记账落库ARCH §4.8)。
`LedgerSink` 为接口,便于测试注入内存替身;生产用 SQLAlchemy 实现写 usage_ledger。
"""
from __future__ import annotations
from typing import Protocol
from sqlalchemy.ext.asyncio import AsyncSession
from ww_db.models import UsageLedger
from .types import Scope, Usage
class LedgerSink(Protocol):
async def record(self, scope: Scope, usage: Usage) -> None: ...
class SqlAlchemyLedgerSink:
"""把每次调用写入 usage_ledgerowner_id 取 scope.user_id单用户 stub"""
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def record(self, scope: Scope, usage: Usage) -> None:
row = UsageLedger(
owner_id=scope.user_id,
project_id=scope.project_id,
provider=usage.provider,
model=usage.model,
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
cache_read=usage.cache_read_tokens,
cost_minor=usage.cost_minor,
currency=usage.currency,
)
self._session.add(row)
await self._session.flush()

View File

@@ -0,0 +1,34 @@
"""提供商价格表与成本换算ARCH §4.8)。
价格以「每百万 token 的最小货币单位(如分/cent」表示随 provider 配置维护;
未知 (provider, model) 则成本计 0仍记账便于观测
"""
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class Price:
in_per_mtok: int
out_per_mtok: int
currency: str
# 近似价(可后续移入 config / provider 配置维护)
_PRICING: dict[tuple[str, str], Price] = {
("deepseek", "deepseek-chat"): Price(in_per_mtok=27, out_per_mtok=110, currency="USD"),
}
def cost_minor(provider: str, model: str, input_tokens: int, output_tokens: int) -> tuple[int, str]:
price = _PRICING.get((provider, model))
if price is None:
return 0, "USD"
cost = math.ceil(
input_tokens / 1_000_000 * price.in_per_mtok
+ output_tokens / 1_000_000 * price.out_per_mtok
)
return cost, price.currency

View File

@@ -0,0 +1,29 @@
"""档位路由tier -> (provider, model)ARCH §4.3)。
M1 只读全局默认config.tier_defaults形如 "deepseek:deepseek-chat"
作品级 / Skill 级覆盖留待后续§4.3 三级解析)。
"""
from __future__ import annotations
from dataclasses import dataclass
from ww_config import get_settings
from .types import Tier
@dataclass(frozen=True)
class Route:
provider: str
model: str
def resolve_route(tier: Tier) -> Route:
spec = get_settings().tier_defaults.get(tier)
if not spec:
raise ValueError(f"no tier_defaults entry for tier={tier!r}")
provider, sep, model = spec.partition(":")
if not sep or not provider or not model:
raise ValueError(f"invalid tier route {spec!r}; expected 'provider:model'")
return Route(provider=provider, model=model)

View File

@@ -0,0 +1,78 @@
"""LLM 网关统一接口契约C1 / ARCH §4.1——snake_casePydantic v2。
上层(编排器/Agent只碰这些类型永不接触具体厂商字段。Agent 只声明 `tier`
不传具体 model不变量 ②)。
"""
from __future__ import annotations
import uuid
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
Tier = Literal["writer", "analyst", "light"]
class Block(BaseModel):
"""一个 prompt 文本块;`cache=True` 标记缓存断点前的稳定块ARCH §4.6)。"""
text: str
cache: bool = False
class Scope(BaseModel):
"""调用作用域。原型单用户:`user_id` 可固定 stub`project_id` 可空。"""
user_id: uuid.UUID
project_id: uuid.UUID | None = None
class LlmRequest(BaseModel):
"""统一请求。`system` 稳定块在前(断点前),`input` 易变内容在后。"""
model_config = ConfigDict(arbitrary_types_allowed=True)
tier: Tier
input: str | list[Block]
system: list[Block] = Field(default_factory=list)
stream: bool = False
output_schema: type[BaseModel] | None = None
thinking: bool = False
max_tokens: int | None = None
scope: Scope
class Usage(BaseModel):
"""一次调用的用量与成本(落 usage_ledgerARCH §4.8)。"""
provider: str
model: str
input_tokens: int
output_tokens: int
cache_read_tokens: int = 0
cost_minor: int # 最小货币单位(如分/cent
currency: str
class ServedBy(BaseModel):
"""实际服务方;`fell_back` 标记是否走了回退链M5 才有回退M1 恒 False"""
provider: str
model: str
fell_back: bool = False
class LlmResponse(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
text: str
parsed: BaseModel | None = None
usage: Usage
served_by: ServedBy
class Delta(BaseModel):
"""流式增量:归一各家 SSE 的统一 token 块。"""
text: str