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