GET /outline?volume=N 原在端点 Python 侧过滤全部章节;现把 volume 条件下推到 OutlineRepo.list_for_project 的 DB WHERE,只查该卷行(少读多余卷 + 语义正确)。 Protocol/SqlOutlineRepo/各 OutlineRepo fake 同步加 keyword-only volume;补 repo 级 下推断言测试。向后兼容:不传 volume 仍返回全部(默认 None)。
180 lines
6.6 KiB
Python
180 lines
6.6 KiB
Python
"""大纲生成端点(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)
|