Files
writer-work-flow/apps/api/ww_api/routers/foreshadow.py
Yaojia Wang 5fb7bfb1de 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
2026-06-18 14:21:17 +02:00

160 lines
5.9 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 §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)