"""项目/章节端点的依赖装配(运行时实现)。 - `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 import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo 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, get_sessionmaker 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.foreshadow_scan import SessionFactory 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) def get_foreshadow_repo( session: Annotated[AsyncSession, Depends(get_session)], ) -> ForeshadowLedgerRepo: """伏笔账本写侧 repo(登记/状态变更端点;只 flush,端点提交)。测试经 override 注 fake。""" return SqlForeshadowLedgerRepo(session) def get_outline_write_repo( session: Annotated[AsyncSession, Depends(get_session)], ) -> OutlineWriteRepo: """大纲写侧 repo(大纲生成端点逐章 upsert;只 flush,端点提交)。测试经 override 注 fake。""" return SqlOutlineWriteRepo(session) def get_session_factory() -> SessionFactory: """验收后到期扫描的**独立 session 工厂**缝。 BackgroundTask 在请求 session 关闭后才跑,必须自建 session(不复用 `get_session`)。 返回的工厂 `()` → `async with` 得一个新 `AsyncSession`。测试经 `app.dependency_overrides` 注入 fake 工厂(避免真起后台线程/真连 DB)。 """ return get_sessionmaker() 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") async def get_outline_gateway( session: Annotated[AsyncSession, Depends(get_session)], ) -> Gateway: """大纲生成(analyst 档位)的可注入网关缝。测试经 override 注 mock(产 OutlineResult)。""" store = SqlCredentialStore(session) return await build_gateway_for_tier(session, store, "analyst")