"""大纲生成端点(C3 扩 / ARCH §5.4 outliner / §7.2 POST /outline;不变量 #2/#3)。 POST /projects/:id/outline:作者显式发起大纲生成 + 持久化。 流程:组确定性上下文(设定 + 已登记伏笔 + 人物 + 世界观)→ `run_outline`(analyst 网关, 结构化 `OutlineResult`)→ 逐章 upsert `outline` 表 → 端点末尾一次 `commit()`。 提交边界:网关 ledger 只 flush(run_outline 产 1 条 usage)、`OutlineWriteRepo.upsert_chapter` 只 flush → 端点末尾 `await session.commit()`(否则 usage_ledger + outline 行静默丢失,见 gotcha)。 不变量 #2:经 `build_gateway_for_tier(..., "analyst")` 注入;outliner_spec 只声明档位不传 model。 不变量 #3:outliner `writes=["outline"]` 经此**作者发起**端点落地,非 AI 静默写其它表; run_outline 节点本身只读不写库。无凭据 → `LLM_UNAVAILABLE`(友好提示,仿 draft/review)。 """ from __future__ import annotations import uuid from typing import Annotated from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from ww_agents import outliner_spec from ww_core.domain import ForeshadowLedgerRepo, OutlineWriteRepo, ProjectRepo from ww_core.domain.repositories import MemoryRepos, OutlineRepo from ww_core.orchestrator import run_outline from ww_db import get_session from ww_llm_gateway import Gateway from ww_shared import AppError, ErrorCode from ww_api.logging_config import get_logger from ww_api.schemas.outline import ( ForeshadowWindowView, OutlineChapterView, OutlineGenerateRequest, OutlineResponse, ) from ww_api.services.credentials import STUB_OWNER_ID from ww_api.services.outline_context import build_outline_context from ww_api.services.project_deps import ( get_foreshadow_repo, get_memory_repos, get_outline_gateway, get_outline_read_repo, get_outline_write_repo, get_project_repo, ) log = get_logger("ww.api.outline") router = APIRouter(prefix="/projects", tags=["outline"]) ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)] ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)] MemoryReposDep = Annotated[MemoryRepos, Depends(get_memory_repos)] OutlineWriteRepoDep = Annotated[OutlineWriteRepo, Depends(get_outline_write_repo)] OutlineReadRepoDep = Annotated[OutlineRepo, Depends(get_outline_read_repo)] OutlineGatewayDep = Annotated[Gateway, Depends(get_outline_gateway)] SessionDep = Annotated[AsyncSession, Depends(get_session)] @router.post("/{project_id}/outline") async def generate_outline( project_id: uuid.UUID, body: OutlineGenerateRequest, request: Request, project_repo: ProjectRepoDep, foreshadow_repo: ForeshadowRepoDep, memory_repos: MemoryReposDep, outline_repo: OutlineWriteRepoDep, gateway: OutlineGatewayDep, session: SessionDep, ) -> OutlineResponse: """生成大纲并逐章持久化。无凭据 → LLM_UNAVAILABLE;项目不存在 → NOT_FOUND。""" request_id = getattr(request.state, "request_id", None) project = await project_repo.get(STUB_OWNER_ID, project_id) if project is None: raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") foreshadow = await foreshadow_repo.list_by_status(project_id, None) characters = await memory_repos.character.list_for_project(project_id) world_entities = await memory_repos.world_entity.list_for_project(project_id) context = build_outline_context( project=project, foreshadow=foreshadow, characters=characters, world_entities=world_entities, ) log.info( "outline_generate_start", project_id=str(project_id), request_id=request_id, volume=body.volume, context_len=len(context), foreshadow_count=len(foreshadow), ) # run_outline 产结构化大纲(analyst 网关;网关失败上抛,无凭据 → LLM_UNAVAILABLE)。 result = await run_outline( outliner_spec, context=context, gateway=gateway, user_id=STUB_OWNER_ID, project_id=project_id, ) chapters: list[OutlineChapterView] = [] for chapter in result.chapters: windows = [w.model_dump() for w in chapter.foreshadow_windows] await outline_repo.upsert_chapter( project_id, volume=body.volume, chapter_no=chapter.no, beats=chapter.beats, foreshadow_windows=windows, ) chapters.append( OutlineChapterView( no=chapter.no, volume=body.volume, beats=list(chapter.beats), foreshadow_windows=[ForeshadowWindowView(**w) for w in windows], ) ) # 提交边界:网关 ledger + outline upsert 均只 flush → 端点末尾一次 commit。 await session.commit() log.info( "outline_generate_done", project_id=str(project_id), request_id=request_id, chapter_count=len(chapters), ) return OutlineResponse(chapters=chapters) @router.get("/{project_id}/outline") async def get_outline( project_id: uuid.UUID, request: Request, project_repo: ProjectRepoDep, outline_repo: OutlineReadRepoDep, volume: int | None = None, ) -> OutlineResponse: """读取已持久化的大纲(逐章,按 chapter_no 升序)。 可选 `?volume=N`:只返回该卷的章节(前端按卷切换);不传 → 全部章节(向后兼容)。 项目不存在 → 404;项目存在但尚无大纲(或该卷无章节)→ 200 空列表(非 404)。 DB `outline.beats` 是 JSONB `{"beats": [...]}` → 解包成裸 `list[str]`(与 POST 响应同形, 前端 OpenAPI 类型对齐)。读侧复用 C5 assemble 的 `OutlineRepo`,不写库。 """ request_id = getattr(request.state, "request_id", None) project = await project_repo.get(STUB_OWNER_ID, project_id) if project is None: raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}") views = await outline_repo.list_for_project(project_id, volume=volume) chapters = [ OutlineChapterView( no=view.chapter_no, volume=view.volume, beats=list(view.beats.get("beats", [])), foreshadow_windows=[ForeshadowWindowView(**w) for w in view.foreshadow_windows], ) for view in views ] log.info( "outline_read", project_id=str(project_id), request_id=request_id, volume=volume, chapter_count=len(chapters), ) return OutlineResponse(chapters=chapters)