Files
writer-work-flow/apps/api/ww_api/services/project_deps.py
Yaojia Wang 5fb7bfb1de feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)
- 伏笔账本:纯函数状态机(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
2026-06-18 14:21:17 +02:00

198 lines
7.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""项目/章节端点的依赖装配(运行时实现)。
- `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 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 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)
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")