- 伏笔账本:纯函数状态机(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
136 lines
4.9 KiB
Python
136 lines
4.9 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
|
||
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_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)]
|
||
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)
|