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,162 @@
"""项目/章节端点的依赖装配(运行时实现)。
- `get_project_repo` / `get_chapter_repo`:把请求 session 装配成 SQLAlchemy repo。
- `get_writer_gateway`:据 writer 档位路由从已存凭据解密 → 建 OpenAI 兼容适配器
→ `Gateway`(注入 `SqlAlchemyLedgerSink` + `resolve_route`)。这是 **draft SSE 的可注入缝**——
测试经 `app.dependency_overrides[get_writer_gateway]` 注入 mock 网关(产 `Delta`,绝不联网)。
- `seed_stub_user`:幂等 seed 单用户 stubowner_id FK 依赖它,见 memory/gotchas
"""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends
from openai import AsyncOpenAI
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ww_config import get_settings
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
from ww_core.domain.repositories import MemoryRepos
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
from ww_core.memory.sql_repositories import sql_memory_repos
from ww_db import get_session
from ww_db.models import User
from ww_llm_gateway import (
Gateway,
OpenAICompatAdapter,
SqlAlchemyLedgerSink,
resolve_route,
)
from ww_llm_gateway.types import Tier
from ww_shared import AppError, ErrorCode
from ww_api.security.credentials import (
CredentialKeyError,
decrypt_api_key,
)
from ww_api.services.credentials import (
STUB_OWNER_ID,
CredentialStore,
SqlCredentialStore,
)
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
_STUB_USER_EMAIL = "stub@local"
async def seed_stub_user(session: AsyncSession) -> None:
"""幂等 seed 单用户 stub 行——所有 owner_id FKprojects/usage_ledger/...)依赖它。
无该行时插入;已存在则跳过。在 app lifespan 启动时调用一次。
"""
existing = (
await session.execute(select(User).where(User.id == STUB_OWNER_ID))
).scalar_one_or_none()
if existing is not None:
return
session.add(User(id=STUB_OWNER_ID, email=_STUB_USER_EMAIL, display_name="stub"))
await session.commit()
def get_project_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ProjectRepo:
return SqlProjectRepo(session)
def get_chapter_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ChapterRepo:
return SqlChapterRepo(session)
def get_memory_repos(
session: Annotated[AsyncSession, Depends(get_session)],
) -> MemoryRepos:
"""记忆组装的 7-repo 捆绑draft SSE 用)。测试覆盖此依赖注入内存 fake。"""
return sql_memory_repos(session)
def get_review_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> ReviewRepo:
"""审稿留痕 reporeview SSE collect / 历史 / accept 裁决)。"""
return SqlReviewRepo(session)
def get_digest_append_repo(
session: Annotated[AsyncSession, Depends(get_session)],
) -> DigestAppendRepo:
"""章节摘要写侧 repo验收事务追加终稿 digest"""
return SqlDigestAppendRepo(session)
async def build_gateway_for_tier(
session: AsyncSession, store: CredentialStore, tier: Tier
) -> Gateway:
"""据指定档位路由解密对应 provider 凭据 → 建网关(解析器仍为全局 `resolve_route`)。
无凭据/未知 provider → `LLM_UNAVAILABLE`(友好提示,前端引导去配置)。
解析器用 `resolve_route`(按 tier 路由);这里只决定**要预备哪个 provider 的适配器**。
"""
settings = get_settings()
route = resolve_route(tier)
base_url = _PROVIDER_BASE_URLS.get(route.provider)
if base_url is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位 provider {route.provider} 暂不支持",
{"provider": route.provider, "tier": tier},
)
cred = await store.get_credential(STUB_OWNER_ID, route.provider)
if cred is None:
raise AppError(
ErrorCode.LLM_UNAVAILABLE,
f"{tier} 档位 provider {route.provider} 未配置凭据,请先在设置中配置",
{"provider": route.provider, "tier": tier},
)
try:
api_key = decrypt_api_key(cred.api_key_enc, key=settings.credential_enc_key)
except CredentialKeyError as exc:
raise AppError(ErrorCode.INTERNAL, str(exc)) from exc
client = AsyncOpenAI(api_key=api_key, base_url=base_url)
adapter = OpenAICompatAdapter(provider=route.provider, client=client)
return Gateway(
adapters={route.provider: adapter},
ledger=SqlAlchemyLedgerSink(session),
resolver=resolve_route,
)
async def build_writer_gateway(session: AsyncSession, store: CredentialStore) -> Gateway:
"""据 writer 档位路由解密对应 provider 凭据 → 建网关。"""
return await build_gateway_for_tier(session, store, "writer")
async def get_writer_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""draft SSE 的可注入网关缝。测试覆盖此依赖注入 mock产 `Delta`,绝不联网)。"""
store = SqlCredentialStore(session)
return await build_writer_gateway(session, store)
async def get_review_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""续审analyst 档位)的可注入网关缝。测试经 override 注 mock产 `parsed`,绝不联网)。"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "analyst")
async def get_digest_gateway(
session: Annotated[AsyncSession, Depends(get_session)],
) -> Gateway:
"""验收终稿 digest 提炼light 档位)的可注入网关缝。测试经 override 注 mock。"""
store = SqlCredentialStore(session)
return await build_gateway_for_tier(session, store, "light")