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:
Yaojia Wang
2026-06-18 14:21:17 +02:00
parent 68f194a043
commit 5fb7bfb1de
74 changed files with 6529 additions and 126 deletions

View File

@@ -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

View 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)

View 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 只 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
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)

View File

@@ -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.5gate事务前→ 终稿提炼 digest事务外R2
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit
单原子事务(晋升 + digest + 裁决留痕)→ 一次 commit → 登记**验收后伏笔到期扫描**
BackgroundTaskM3-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,
)
# 验收**提交成功后**登记伏笔到期扫描 BackgroundTaskM3-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,

View File

@@ -0,0 +1,66 @@
"""伏笔登记 / 状态变更端点的请求/响应 schemaC3 扩 / 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)

View File

@@ -0,0 +1,43 @@
"""大纲生成端点的请求/响应 schemaC3 扩 / ARCH §5.4, §7.2)。
snake_case前端经 OpenAPI 生成 TS 类型消费(改字段 → 前端 `pnpm gen:api`)。
大纲生成是**作者显式动作**(不变量 #3outliner `writes=["outline"]` 声明经此端点落地,
非 AI 静默写其它表)。窗口字段供前端 T3.6 渲染伏笔窗口徽标。
"""
from __future__ import annotations
from pydantic import BaseModel, Field
class OutlineGenerateRequest(BaseModel):
"""POST /projects/:id/outline触发大纲生成 + 持久化。
M3 简单分卷:`volume` 缺省=1schema 无逐章卷号;本端点把整批章节落到同一卷)。
"""
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)

View File

@@ -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 同形)

View File

@@ -0,0 +1,90 @@
"""验收后伏笔到期扫描M3-b/dARCH §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

View 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),
]
)

View File

@@ -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")