- 薄自建 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 走通闭环
163 lines
6.0 KiB
Python
163 lines
6.0 KiB
Python
"""项目/章节端点的依赖装配(运行时实现)。
|
||
|
||
- `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 单用户 stub(owner_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 FK(projects/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:
|
||
"""审稿留痕 repo(review 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")
|