feat: M3 — 伏笔账本 + 节奏引擎 + 大纲(含并发记账 bugfix)
- 伏笔账本:纯函数状态机(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
This commit is contained in:
@@ -12,7 +12,14 @@ from ww_shared import AppError, ErrorBody, ErrorEnvelope
|
||||
|
||||
from ww_api.logging_config import configure_logging, get_logger
|
||||
from ww_api.middleware import request_id_middleware
|
||||
from ww_api.routers import health, jobs, projects, settings_providers
|
||||
from ww_api.routers import (
|
||||
foreshadow,
|
||||
health,
|
||||
jobs,
|
||||
outline,
|
||||
projects,
|
||||
settings_providers,
|
||||
)
|
||||
from ww_api.services.project_deps import seed_stub_user
|
||||
|
||||
configure_logging()
|
||||
@@ -48,6 +55,8 @@ def create_app() -> FastAPI:
|
||||
app.include_router(health.router)
|
||||
app.include_router(jobs.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(foreshadow.router)
|
||||
app.include_router(outline.router)
|
||||
app.include_router(settings_providers.router)
|
||||
return app
|
||||
|
||||
|
||||
159
apps/api/ww_api/routers/foreshadow.py
Normal file
159
apps/api/ww_api/routers/foreshadow.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""伏笔登记 / 状态变更端点(C3 扩 / ARCH §6.2, §7.2;不变量 #3)。
|
||||
|
||||
- POST /projects/:id/foreshadow 作者显式登记一条伏笔(status=OPEN)。
|
||||
- PATCH /projects/:id/foreshadow/:code 状态机转移 和/或 追加进展。
|
||||
|
||||
登记/改状态是**作者显式动作**(不变量 #3:伏笔入库不经 AI 静默写库;验收后到期扫描
|
||||
是确定性纯函数置位,见 `services/foreshadow_scan.py`)。
|
||||
|
||||
提交边界:`ForeshadowLedgerRepo` 写方法只 `flush()`,端点写后 `await session.commit()`。
|
||||
|
||||
**T3.5 续接点**:本 router 是 foreshadow 端点的归属处。T3.5 的看板查询
|
||||
`GET /projects/:id/foreshadow?status=` 直接加到本 router(调 `repo.list_by_status`);
|
||||
大纲 `POST /projects/:id/outline` 与伏笔无关,建议落独立 `routers/outline.py`
|
||||
(或 projects router),不混进本文件。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import ForeshadowLedgerRepo, ForeshadowLedgerView
|
||||
from ww_core.domain.foreshadow_state import ForeshadowStatus, InvalidTransition
|
||||
from ww_db import get_session
|
||||
from ww_shared import AppError, ErrorCode
|
||||
|
||||
from ww_api.logging_config import get_logger
|
||||
from ww_api.schemas.foreshadow import (
|
||||
ForeshadowBoardResponse,
|
||||
ForeshadowRegisterRequest,
|
||||
ForeshadowTransitionRequest,
|
||||
ForeshadowView,
|
||||
)
|
||||
from ww_api.services.project_deps import get_foreshadow_repo
|
||||
|
||||
log = get_logger("ww.api.foreshadow")
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["foreshadow"])
|
||||
|
||||
ForeshadowRepoDep = Annotated[ForeshadowLedgerRepo, Depends(get_foreshadow_repo)]
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
def _to_view(v: ForeshadowLedgerView) -> ForeshadowView:
|
||||
# ForeshadowLedgerView 与 ForeshadowView 字段同名,逐字段映射(snake_case 契约)。
|
||||
return ForeshadowView.model_validate(v, from_attributes=True)
|
||||
|
||||
|
||||
@router.post("/{project_id}/foreshadow", status_code=201)
|
||||
async def register_foreshadow(
|
||||
project_id: uuid.UUID,
|
||||
body: ForeshadowRegisterRequest,
|
||||
request: Request,
|
||||
repo: ForeshadowRepoDep,
|
||||
session: SessionDep,
|
||||
) -> ForeshadowView:
|
||||
"""登记一条伏笔(status=OPEN)。重复 `code` → DB 唯一约束冲突 → VALIDATION 信封。"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
try:
|
||||
view = await repo.register(
|
||||
project_id,
|
||||
code=body.code,
|
||||
title=body.title,
|
||||
planted_at=body.planted_at,
|
||||
content=body.content,
|
||||
expected_close_from=body.expected_close_from,
|
||||
expected_close_to=body.expected_close_to,
|
||||
importance=body.importance,
|
||||
)
|
||||
await session.commit()
|
||||
except IntegrityError as exc:
|
||||
# `(project_id, code)` 唯一约束冲突(register 仅 INSERT、不 upsert,见 foreshadow_repo)。
|
||||
await session.rollback()
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"伏笔代号已存在:{body.code}",
|
||||
{"field": "code", "code": body.code, "reason": "duplicate"},
|
||||
) from exc
|
||||
log.info(
|
||||
"foreshadow_registered",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
code=body.code,
|
||||
)
|
||||
return _to_view(view)
|
||||
|
||||
|
||||
@router.get("/{project_id}/foreshadow")
|
||||
async def list_foreshadow(
|
||||
project_id: uuid.UUID,
|
||||
repo: ForeshadowRepoDep,
|
||||
status: str | None = None,
|
||||
) -> ForeshadowBoardResponse:
|
||||
"""伏笔看板:按 `status` 过滤(缺省=全部),按 code 升序。
|
||||
|
||||
`status` 非法(不在 OPEN/PARTIAL/CLOSED/OVERDUE)→ VALIDATION 信封。四泳道前端
|
||||
据 `status` 分组;OVERDUE 泳道 + 逾期标记用 `expected_close_to`(看板字段已齐)。
|
||||
"""
|
||||
if status is not None and status not in {s.value for s in ForeshadowStatus}:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
f"非法状态过滤:{status}",
|
||||
{"field": "status", "reason": "invalid_status"},
|
||||
)
|
||||
views = await repo.list_by_status(project_id, status)
|
||||
return ForeshadowBoardResponse(foreshadow=[_to_view(v) for v in views])
|
||||
|
||||
|
||||
@router.patch("/{project_id}/foreshadow/{code}")
|
||||
async def update_foreshadow(
|
||||
project_id: uuid.UUID,
|
||||
code: str,
|
||||
body: ForeshadowTransitionRequest,
|
||||
request: Request,
|
||||
repo: ForeshadowRepoDep,
|
||||
session: SessionDep,
|
||||
) -> ForeshadowView:
|
||||
"""状态机转移 和/或 追加进展。非法转移 → VALIDATION 信封;不存在 → NOT_FOUND。
|
||||
|
||||
`to_status` 与 `progress_entry` 皆可选;两者都缺 → VALIDATION(无操作)。先转移、后追加。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
if body.to_status is None and body.progress_entry is None:
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
"至少提供 `to_status` 或 `progress_entry` 之一",
|
||||
{"reason": "empty_update"},
|
||||
)
|
||||
|
||||
view: ForeshadowLedgerView
|
||||
try:
|
||||
if body.to_status is not None:
|
||||
view = await repo.transition(project_id, code, to_status=body.to_status)
|
||||
if body.progress_entry is not None:
|
||||
view = await repo.record_progress(project_id, code, entry=body.progress_entry)
|
||||
await session.commit()
|
||||
except InvalidTransition as exc:
|
||||
await session.rollback()
|
||||
raise AppError(
|
||||
ErrorCode.VALIDATION,
|
||||
str(exc),
|
||||
{"field": "to_status", "code": code, "reason": "invalid_transition"},
|
||||
) from exc
|
||||
except LookupError as exc:
|
||||
await session.rollback()
|
||||
raise AppError(ErrorCode.NOT_FOUND, f"foreshadow not found: {code}") from exc
|
||||
|
||||
log.info(
|
||||
"foreshadow_updated",
|
||||
project_id=str(project_id),
|
||||
request_id=request_id,
|
||||
code=code,
|
||||
to_status=body.to_status,
|
||||
has_progress=body.progress_entry is not None,
|
||||
)
|
||||
return _to_view(view)
|
||||
135
apps/api/ww_api/routers/outline.py
Normal file
135
apps/api/ww_api/routers/outline.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""大纲生成端点(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)
|
||||
@@ -17,7 +17,7 @@ import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain.chapter_repo import ChapterRepo
|
||||
@@ -59,6 +59,7 @@ from ww_api.services.accept_service import (
|
||||
)
|
||||
from ww_api.services.credentials import STUB_OWNER_ID
|
||||
from ww_api.services.digest_extraction import extract_digest_facts
|
||||
from ww_api.services.foreshadow_scan import SessionFactory, run_overdue_scan
|
||||
from ww_api.services.project_deps import (
|
||||
get_chapter_repo,
|
||||
get_digest_append_repo,
|
||||
@@ -67,6 +68,7 @@ from ww_api.services.project_deps import (
|
||||
get_project_repo,
|
||||
get_review_gateway,
|
||||
get_review_repo,
|
||||
get_session_factory,
|
||||
get_writer_gateway,
|
||||
)
|
||||
|
||||
@@ -302,14 +304,23 @@ async def accept_chapter(
|
||||
chapter_no: int,
|
||||
body: AcceptRequest,
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
chapter_repo: ChapterRepoDep,
|
||||
digest_repo: DigestRepoDep,
|
||||
review_repo: ReviewRepoDep,
|
||||
gateway: DigestGatewayDep,
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
session_factory: Annotated[SessionFactory, Depends(get_session_factory)],
|
||||
) -> AcceptResponse:
|
||||
"""验收事务 + 冲突 gate(§5.5):gate(事务前)→ 终稿提炼 digest(事务外,R2)→
|
||||
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit。
|
||||
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
|
||||
BackgroundTask(M3-b/d,§6.2)。
|
||||
|
||||
到期扫描接 §5.5 步骤 3 的 `TODO(M3)` 占位:验收提交成功**后**,把
|
||||
`current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 OVERDUE。
|
||||
伏笔登记/回收(PARTIAL/CLOSED)的「采纳建议」经作者经 foreshadow 端点显式确认,
|
||||
不在验收事务里 AI 静默改库(不变量 #3)。**§7.4 持久性局限**:BackgroundTask 进程内
|
||||
跑、重启丢失——原型可接受(见 `services/foreshadow_scan.py`,不引入 jobs 表)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
# R3:审稿真相从领域表重读(最近一条 chapter_reviews),不依赖 checkpoint。
|
||||
@@ -350,6 +361,17 @@ async def accept_chapter(
|
||||
latest_review=latest_review,
|
||||
decisions=body.decisions,
|
||||
)
|
||||
|
||||
# 验收**提交成功后**登记伏笔到期扫描 BackgroundTask(M3-b/d)。current_chapter=刚验收章号;
|
||||
# 任务在请求 session 关闭后跑,故 run_overdue_scan 自建新 session 并 commit(§7.4 局限可接受)。
|
||||
background_tasks.add_task(
|
||||
run_overdue_scan,
|
||||
session_factory,
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
return AcceptResponse(
|
||||
project_id=project_id,
|
||||
chapter_no=chapter_no,
|
||||
|
||||
66
apps/api/ww_api/schemas/foreshadow.py
Normal file
66
apps/api/ww_api/schemas/foreshadow.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""伏笔登记 / 状态变更端点的请求/响应 schema(C3 扩 / ARCH §6.2, §7.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费。改字段 → 前端必须 `pnpm gen:api`。
|
||||
登记是**作者显式动作**(不变量 #3:伏笔入库不经 AI 静默写库),到期扫描是验收后的
|
||||
确定性纯函数置位(M3-d),二者都不在审稿/写章流水线里。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ForeshadowRegisterRequest(BaseModel):
|
||||
"""POST /projects/:id/foreshadow:作者显式登记一条伏笔(status 落 OPEN)。"""
|
||||
|
||||
code: str = Field(min_length=1, description="伏笔代号,`(project_id, code)` 唯一")
|
||||
title: str = Field(min_length=1, description="伏笔标题/一句话描述")
|
||||
planted_at: int | None = Field(default=None, description="埋设章号")
|
||||
content: str | None = Field(default=None, description="伏笔正文/线索")
|
||||
expected_close_from: int | None = Field(default=None, description="预期回收窗口起始章")
|
||||
expected_close_to: int | None = Field(
|
||||
default=None, description="预期回收窗口结束章(到期判据用)"
|
||||
)
|
||||
importance: str | None = Field(default=None, description="重要度(自由文本,看板用)")
|
||||
|
||||
|
||||
class ForeshadowTransitionRequest(BaseModel):
|
||||
"""PATCH /projects/:id/foreshadow/:code:状态机转移 和/或 追加一条进展。
|
||||
|
||||
两字段皆可选:`to_status` 走状态机(非法 → VALIDATION 信封);`progress_entry`
|
||||
append-only 追加到 progress JSONB。二者可同时给(先转移、后追加进展)。
|
||||
"""
|
||||
|
||||
to_status: str | None = Field(
|
||||
default=None, description="目标状态(OPEN/PARTIAL/CLOSED/OVERDUE)"
|
||||
)
|
||||
progress_entry: dict[str, Any] | None = Field(
|
||||
default=None, description="追加一条进展记录(append-only,不覆盖历史)"
|
||||
)
|
||||
|
||||
|
||||
class ForeshadowView(BaseModel):
|
||||
"""伏笔账本视图(登记/状态变更/看板共用;snake_case)。形对齐 `ForeshadowLedgerView`。"""
|
||||
|
||||
code: str
|
||||
title: str
|
||||
status: str
|
||||
planted_at: int | None = None
|
||||
content: str | None = None
|
||||
expected_close_from: int | None = None
|
||||
expected_close_to: int | None = None
|
||||
importance: str | None = None
|
||||
links: list[dict[str, Any]] = Field(default_factory=list)
|
||||
progress: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ForeshadowBoardResponse(BaseModel):
|
||||
"""GET /projects/:id/foreshadow?status=:伏笔看板(四泳道 + OVERDUE 字段齐)。
|
||||
|
||||
`status` 缺省返回全部;按 `code` 升序(repo `list_by_status` 已排序)。前端按
|
||||
`status` 分四泳道(OPEN/PARTIAL/CLOSED/OVERDUE),用 `expected_close_to` 标逾期。
|
||||
"""
|
||||
|
||||
foreshadow: list[ForeshadowView] = Field(default_factory=list)
|
||||
43
apps/api/ww_api/schemas/outline.py
Normal file
43
apps/api/ww_api/schemas/outline.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""大纲生成端点的请求/响应 schema(C3 扩 / ARCH §5.4, §7.2)。
|
||||
|
||||
snake_case;前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
|
||||
大纲生成是**作者显式动作**(不变量 #3:outliner `writes=["outline"]` 声明经此端点落地,
|
||||
非 AI 静默写其它表)。窗口字段供前端 T3.6 渲染伏笔窗口徽标。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OutlineGenerateRequest(BaseModel):
|
||||
"""POST /projects/:id/outline:触发大纲生成 + 持久化。
|
||||
|
||||
M3 简单分卷:`volume` 缺省=1(schema 无逐章卷号;本端点把整批章节落到同一卷)。
|
||||
"""
|
||||
|
||||
volume: int = Field(default=1, ge=1, description="本批大纲所属卷号(M3 默认全卷 1)")
|
||||
|
||||
|
||||
class ForeshadowWindowView(BaseModel):
|
||||
"""单章关联的伏笔回收窗口(snake_case;形对齐 `ww_agents.ForeshadowWindow`)。"""
|
||||
|
||||
code: str
|
||||
plant_chapter: int | None = None
|
||||
expected_close_from: int | None = None
|
||||
expected_close_to: int | None = None
|
||||
|
||||
|
||||
class OutlineChapterView(BaseModel):
|
||||
"""单章大纲视图:章号 + 卷 + 节拍 + 伏笔窗口。"""
|
||||
|
||||
no: int
|
||||
volume: int
|
||||
beats: list[str] = Field(default_factory=list)
|
||||
foreshadow_windows: list[ForeshadowWindowView] = Field(default_factory=list)
|
||||
|
||||
|
||||
class OutlineResponse(BaseModel):
|
||||
"""大纲生成响应:逐章已持久化的大纲(含窗口,供前端 T3.6)。"""
|
||||
|
||||
chapters: list[OutlineChapterView] = Field(default_factory=list)
|
||||
@@ -114,9 +114,11 @@ async def run_accept_transaction(
|
||||
)
|
||||
review_id = updated.id
|
||||
|
||||
# 步骤 3(占位):人物 latest_state / 伏笔状态更新——M3 才正式接伏笔表,
|
||||
# 这里按 §5.5 步骤 3 留占位,不引入 M3 表逻辑(避免越界写未就绪的状态机)。
|
||||
# TODO(M3): 按裁决应用 latest_state 变更 + 伏笔登记/到期扫描(§6.2)。
|
||||
# 步骤 3:伏笔到期扫描——验收提交**后**经端点登记的 BackgroundTask 跑(M3-b/d,
|
||||
# `services/foreshadow_scan.run_overdue_scan`:current_ch > expected_close_to 且未 CLOSED
|
||||
# → 置 OVERDUE,确定性纯函数非 AI,不变量 #3)。放在事务外/提交后是因为它是验收**结果**
|
||||
# 的副作用、且自建独立 session(请求 session 此时已关闭)。
|
||||
# 人物 latest_state 更新仍留后续(M4+):本事务只落晋升 + digest + 裁决留痕。
|
||||
|
||||
await session.commit() # type: ignore[attr-defined] # AsyncSession.commit()(fake 同形)
|
||||
|
||||
|
||||
90
apps/api/ww_api/services/foreshadow_scan.py
Normal file
90
apps/api/ww_api/services/foreshadow_scan.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""验收后伏笔到期扫描(M3-b/d;ARCH §6.2, §7.4,不变量 #3)。
|
||||
|
||||
接 §5.5 验收事务后的 `TODO(M3)` 占位:验收**提交成功后**触发一次伏笔到期扫描,把
|
||||
`current_ch > expected_close_to AND status≠CLOSED` 的伏笔确定性置 `OVERDUE`(M3-d 纯
|
||||
函数,非 Agent;不变量 #3:状态变更是确定性扫描而非 AI 静默写库)。
|
||||
|
||||
挂载方式(端点):FastAPI `BackgroundTasks` 在 accept 端点登记 `run_overdue_scan`。
|
||||
BackgroundTask 在请求-response 发回、请求 session 关闭**之后**才跑——故本函数**自建新
|
||||
session**(不复用请求 session)并自己 `commit()`。
|
||||
|
||||
**持久性局限(§7.4)**:BackgroundTasks 在 API 进程内跑,进程重启/崩溃会丢失未跑完的
|
||||
任务——原型可接受,这里把触发/完成/失败都记结构化日志便于查错;**不引入 `jobs` 表**
|
||||
(那是 M4/T4.1 的工作,本任务不越界)。
|
||||
|
||||
可测性:扫描逻辑抽成纯 async 函数 `run_overdue_scan(session_factory, ...)`,端点用
|
||||
BackgroundTasks 调它;单测直接 `await` 它(注入 fake session 工厂)断言行被置 OVERDUE,
|
||||
**绝不真起不可控后台线程**。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import Protocol
|
||||
|
||||
import structlog
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_core.domain import ForeshadowLedgerView, SqlForeshadowLedgerRepo
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# 新建独立 session 的工厂:调用得到一个 async 上下文管理器(`async with` → AsyncSession)。
|
||||
SessionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
|
||||
|
||||
|
||||
class OverdueScanRepo(Protocol):
|
||||
"""到期扫描对账本 repo 的最小依赖(仅 `scan_overdue`)。"""
|
||||
|
||||
async def scan_overdue(
|
||||
self, project_id: uuid.UUID, *, current_chapter: int
|
||||
) -> list[ForeshadowLedgerView]: ...
|
||||
|
||||
|
||||
# repo 工厂:从新 session 造账本 repo。默认建 SQL 实现;测试注入 fake(避免真连 DB)。
|
||||
RepoFactory = Callable[[AsyncSession], OverdueScanRepo]
|
||||
|
||||
|
||||
def _default_repo_factory(session: AsyncSession) -> OverdueScanRepo:
|
||||
return SqlForeshadowLedgerRepo(session)
|
||||
|
||||
|
||||
async def run_overdue_scan(
|
||||
session_factory: SessionFactory,
|
||||
*,
|
||||
project_id: uuid.UUID,
|
||||
chapter_no: int,
|
||||
request_id: str | None = None,
|
||||
repo_factory: RepoFactory = _default_repo_factory,
|
||||
) -> int:
|
||||
"""验收后伏笔到期扫描:新建 session → `scan_overdue` 置 OVERDUE → `commit()`。
|
||||
|
||||
返回被置 OVERDUE 的伏笔条数(供日志/测试断言)。任何异常被捕获并记错误日志后
|
||||
吞掉——后台任务失败不应冒泡崩进程,也不影响已成功的验收事务(§7.4 局限可接受)。
|
||||
`repo_factory` 是可注入缝:默认建 SQL 账本 repo,测试注 fake(直接 await、不联网)。
|
||||
"""
|
||||
try:
|
||||
async with session_factory() as session:
|
||||
repo = repo_factory(session)
|
||||
changed = await repo.scan_overdue(project_id, current_chapter=chapter_no)
|
||||
if changed:
|
||||
await session.commit()
|
||||
log.info(
|
||||
"foreshadow_overdue_scan",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
overdue_count=len(changed),
|
||||
overdue_codes=[v.code for v in changed],
|
||||
)
|
||||
return len(changed)
|
||||
except Exception as exc: # noqa: BLE001 — 后台任务边界:记错误、不冒泡崩进程(§7.4)。
|
||||
log.error(
|
||||
"foreshadow_overdue_scan_failed",
|
||||
project_id=str(project_id),
|
||||
chapter_no=chapter_no,
|
||||
request_id=request_id,
|
||||
error=str(exc),
|
||||
)
|
||||
return 0
|
||||
87
apps/api/ww_api/services/outline_context.py
Normal file
87
apps/api/ww_api/services/outline_context.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""大纲生成的注入上下文组装(确定性序列化文本;ARCH §5.4 outliner reads / §6.2)。
|
||||
|
||||
把 projects 设定 + 已登记伏笔 + 人物 + 世界观确定性序列化成纯文本喂给 outliner——
|
||||
**无时间戳/无 UUID**(缓存前缀稳定,不变量 #9),逐项排序保证同输入同输出(可单测)。
|
||||
outliner 据此排出分章节拍 + 伏笔回收窗口(关联本章与伏笔,§6.2)。
|
||||
|
||||
注:reads=["projects","foreshadow","characters","world_entities"](C6 outliner_spec),
|
||||
本组装恰好覆盖这四源,不读 digest/outline(排大纲是从设定生成、非续写既有章)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from ww_core.domain import ForeshadowLedgerView, ProjectView
|
||||
from ww_core.domain.repositories import CharacterView, WorldEntityView
|
||||
|
||||
|
||||
def _project_block(project: ProjectView) -> str:
|
||||
lines = [f"标题:{project.title}"]
|
||||
if project.genre:
|
||||
lines.append(f"类型:{project.genre}")
|
||||
if project.logline:
|
||||
lines.append(f"一句话简介:{project.logline}")
|
||||
if project.premise:
|
||||
lines.append(f"前提:{project.premise}")
|
||||
if project.theme:
|
||||
lines.append(f"主题:{project.theme}")
|
||||
if project.selling_points:
|
||||
lines.append("卖点:" + "、".join(project.selling_points))
|
||||
if project.structure:
|
||||
lines.append(f"结构:{project.structure}")
|
||||
return "【作品设定】\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _foreshadow_block(foreshadow: Sequence[ForeshadowLedgerView]) -> str:
|
||||
if not foreshadow:
|
||||
return "【已登记伏笔】\n(无)"
|
||||
rows = sorted(foreshadow, key=lambda f: f.code)
|
||||
lines = []
|
||||
for f in rows:
|
||||
window = ""
|
||||
if f.expected_close_from is not None or f.expected_close_to is not None:
|
||||
lo = f.expected_close_from if f.expected_close_from is not None else "?"
|
||||
hi = f.expected_close_to if f.expected_close_to is not None else "?"
|
||||
window = f"(回收窗口 {lo}-{hi})"
|
||||
plant = f"埋设第{f.planted_at}章 " if f.planted_at is not None else ""
|
||||
lines.append(f"- [{f.code}] {f.title} {plant}{window}".rstrip())
|
||||
return "【已登记伏笔】\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _characters_block(characters: Sequence[CharacterView]) -> str:
|
||||
if not characters:
|
||||
return "【人物】\n(无)"
|
||||
rows = sorted(characters, key=lambda c: c.name)
|
||||
lines = []
|
||||
for c in rows:
|
||||
role = f"({c.role})" if c.role else ""
|
||||
motive = f" 动机:{c.motive}" if c.motive else ""
|
||||
lines.append(f"- {c.name}{role}{motive}".rstrip())
|
||||
return "【人物】\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def _world_block(entities: Sequence[WorldEntityView]) -> str:
|
||||
if not entities:
|
||||
return "【世界观】\n(无)"
|
||||
rows = sorted(entities, key=lambda e: (e.type, e.name))
|
||||
lines = [f"- [{e.type}] {e.name}" for e in rows]
|
||||
return "【世界观】\n" + "\n".join(lines)
|
||||
|
||||
|
||||
def build_outline_context(
|
||||
*,
|
||||
project: ProjectView,
|
||||
foreshadow: Sequence[ForeshadowLedgerView],
|
||||
characters: Sequence[CharacterView],
|
||||
world_entities: Sequence[WorldEntityView],
|
||||
) -> str:
|
||||
"""组装 outliner 注入上下文(确定性文本,纯函数)。"""
|
||||
return "\n\n".join(
|
||||
[
|
||||
_project_block(project),
|
||||
_foreshadow_block(foreshadow),
|
||||
_characters_block(characters),
|
||||
_world_block(world_entities),
|
||||
]
|
||||
)
|
||||
@@ -16,13 +16,15 @@ from openai import AsyncOpenAI
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from ww_config import get_settings
|
||||
from ww_core.domain import ForeshadowLedgerRepo, SqlForeshadowLedgerRepo
|
||||
from ww_core.domain.chapter_repo import ChapterRepo, SqlChapterRepo
|
||||
from ww_core.domain.digest_repo import DigestAppendRepo, SqlDigestAppendRepo
|
||||
from ww_core.domain.outline_write_repo import OutlineWriteRepo, SqlOutlineWriteRepo
|
||||
from ww_core.domain.project_repo import ProjectRepo, SqlProjectRepo
|
||||
from ww_core.domain.repositories import MemoryRepos
|
||||
from ww_core.domain.review_repo import ReviewRepo, SqlReviewRepo
|
||||
from ww_core.memory.sql_repositories import sql_memory_repos
|
||||
from ww_db import get_session
|
||||
from ww_db import get_session, get_sessionmaker
|
||||
from ww_db.models import User
|
||||
from ww_llm_gateway import (
|
||||
Gateway,
|
||||
@@ -42,6 +44,7 @@ from ww_api.services.credentials import (
|
||||
CredentialStore,
|
||||
SqlCredentialStore,
|
||||
)
|
||||
from ww_api.services.foreshadow_scan import SessionFactory
|
||||
from ww_api.services.provider_deps import _PROVIDER_BASE_URLS
|
||||
|
||||
# 单用户 stub 的占位邮箱(多租户化时由真实主体替换)。
|
||||
@@ -95,6 +98,30 @@ def get_digest_append_repo(
|
||||
return SqlDigestAppendRepo(session)
|
||||
|
||||
|
||||
def get_foreshadow_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> ForeshadowLedgerRepo:
|
||||
"""伏笔账本写侧 repo(登记/状态变更端点;只 flush,端点提交)。测试经 override 注 fake。"""
|
||||
return SqlForeshadowLedgerRepo(session)
|
||||
|
||||
|
||||
def get_outline_write_repo(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> OutlineWriteRepo:
|
||||
"""大纲写侧 repo(大纲生成端点逐章 upsert;只 flush,端点提交)。测试经 override 注 fake。"""
|
||||
return SqlOutlineWriteRepo(session)
|
||||
|
||||
|
||||
def get_session_factory() -> SessionFactory:
|
||||
"""验收后到期扫描的**独立 session 工厂**缝。
|
||||
|
||||
BackgroundTask 在请求 session 关闭后才跑,必须自建 session(不复用 `get_session`)。
|
||||
返回的工厂 `()` → `async with` 得一个新 `AsyncSession`。测试经 `app.dependency_overrides`
|
||||
注入 fake 工厂(避免真起后台线程/真连 DB)。
|
||||
"""
|
||||
return get_sessionmaker()
|
||||
|
||||
|
||||
async def build_gateway_for_tier(
|
||||
session: AsyncSession, store: CredentialStore, tier: Tier
|
||||
) -> Gateway:
|
||||
@@ -160,3 +187,11 @@ async def get_digest_gateway(
|
||||
"""验收终稿 digest 提炼(light 档位)的可注入网关缝。测试经 override 注 mock。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "light")
|
||||
|
||||
|
||||
async def get_outline_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""大纲生成(analyst 档位)的可注入网关缝。测试经 override 注 mock(产 OutlineResult)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "analyst")
|
||||
|
||||
Reference in New Issue
Block a user