- 伏笔账本:纯函数状态机(OPEN/PARTIAL/CLOSED/OVERDUE) + ForeshadowLedger repo;验收后到期扫描(BackgroundTask 自建 session 置 OVERDUE);登记/状态变更端点 - 节奏 + 三审齐:foreshadow-analyst + pace-checker 并入 LangGraph 并行审(REVIEW_SPECS),collect 分列落 chapter_reviews(conflicts/foreshadow_sug/pace),review SSE 加 foreshadow/pace 事件 - 大纲:outliner Agent 产 OutlineResult(含 foreshadow_windows),POST /outline 逐章 upsert outline 表;GET /foreshadow?status= 看板 - 前端:伏笔四泳道看板(OVERDUE 琥珀) + 大纲编辑器(窗口徽标) + 节奏节拍图(▁▃▅) + 审稿页消费 foreshadow/pace SSE - bugfix(T3.8):并行三审共用请求 session 记账触发 'Session is already flushing' → foreshadow/pace 静默丢失;SqlAlchemyLedgerSink.record 改 add-only(靠端点/事务 commit),加并发回归测试 - M3 E2E:真实 DB + mock 网关零 token 走通 埋设→进展→验收后扫描 OVERDUE→看板 + 大纲含窗口 + 三审齐 SSE/留痕;E2E 暴露并钉住上述 bug - 门禁绿:mypy 111 / pytest 228(0 xfailed) / alembic 无漂移;前端 gen:api/lint/tsc/vitest 69/build
198 lines
7.5 KiB
Python
198 lines
7.5 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 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")
|