Files
writer-work-flow/apps/api/ww_api/routers/outline.py
Yaojia Wang 765dbdfbd4 feat: M4 文风 + M5 生成/多provider/Skill + Kimi Code 订阅接入 + 本地联调修复
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>
2026-06-20 10:39:58 +02:00

177 lines
6.4 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.

"""大纲生成端点C3 扩 / ARCH §5.4 outliner / §7.2 POST /outline不变量 #2/#3
POST /projects/:id/outline作者显式发起大纲生成 + 持久化。
流程:组确定性上下文(设定 + 已登记伏笔 + 人物 + 世界观)→ `run_outline`analyst 网关,
结构化 `OutlineResult`)→ 逐章 upsert `outline` 表 → 端点末尾一次 `commit()`。
提交边界:网关 ledger 只 flushrun_outline 产 1 条 usage、`OutlineWriteRepo.upsert_chapter`
只 flush → 端点末尾 `await session.commit()`(否则 usage_ledger + outline 行静默丢失,见 gotcha
不变量 #2经 `build_gateway_for_tier(..., "analyst")` 注入outliner_spec 只声明档位不传 model。
不变量 #3outliner `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)