M4(文风): style-auditor 双轨(提取指纹/漂移第四审)+ jobs 长任务框架(zombie reaper) + 回炉 refine + GET /style read-back。 M5(生成+扩展): worldbuilder/character-gen(入库 continuity 409 gate + partition_writes 白名单 + schema→JSONB 形变); 网关多 provider 回退链/熔断/能力降级(Anthropic/Gemini 适配器);Skill registry + 表权限沙箱 + 规则; 前端 角色生成器/世界观/Codex/规则页/技能库/⌘K 命令面板。 K1(Kimi Code 订阅接入): OAuth device-flow(kimi-code)+ 静态 Console key(kimi-code-key)两路径; coding 端点 KimiCLI 伪造头(实测 UA allow-list 门禁,缺则 403)+ JSON 模式结构化(thinking ⊥ tool_choice)。 本地联调修复: CORS 中间件;assemble 注入 premise+「写第N章」指令(修空 prompt 400); GET /outline·/draft read-back + 大纲/工作台/审稿页重载;写页 client/server 常量边界 + notFound 健壮化; 字数 toLocaleString locale 水合;审稿页终稿从已存草稿 seed(修 accept 422)。 门禁: backend ruff/mypy(157)/alembic 无漂移/pytest 451 · frontend lint/tsc/vitest/build。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
177 lines
6.4 KiB
Python
177 lines
6.4 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,
|
||
) -> OutlineResponse:
|
||
"""读取已持久化的大纲(逐章,按 chapter_no 升序)。
|
||
|
||
项目不存在 → 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)
|
||
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,
|
||
chapter_count=len(chapters),
|
||
)
|
||
return OutlineResponse(chapters=chapters)
|