- core write_chapter 支持续写模式:chain_key==continue_volume 时经注入的 chapter_repo.latest_accepted 读上一章已验收终稿,build_continuation_context 改写 volatile(仅断点后块,守不变量 #9;core 不 import apps/api)。 - ChapterDraftRepo 协议加 latest_accepted;draft_volume 行为不变(无回归)。 - apps/api SUPPORTED_CHAINS 加 continue_volume;run 端点透传 chain_key 选模式 (chain_runner 经 initial_chain_state 把 chain_key 落入 ChainState)。 - 单测(mock 网关+MemorySaver+fake session):续写第二章请求含第一章正文; draft_volume 不注入前文;端点 202 透传 chain_key。守不变量 #1/#5。
266 lines
10 KiB
Python
266 lines
10 KiB
Python
"""多章工作流链端点(chain-workflow 设计 §6)——发起 / 续跑,进度走 `GET /jobs/{id}`。
|
||
|
||
三端点:
|
||
- `POST /projects/{pid}/chains/{chain_key}/run` → 202 `ChainRunAccepted{job_id,...}`:
|
||
写一行 jobs(queued, kind=chain) 立即返 202,链经 BackgroundTask `run_chain_job` 跑
|
||
(**自建独立 session**,请求 session 已关闭)。无凭据 → 流前 `LLM_UNAVAILABLE`(503,
|
||
调度 job 前拦下)。未知 chain_key → 404;`count` 1..50 越界 → 422(schema Field 约束)。
|
||
- `GET /jobs/{job_id}`:复用现有 jobs 端点轮询进度/awaiting(不在本 router)。
|
||
- `POST /projects/{pid}/chains/runs/{job_id}/resume` → 202:仅当 job=awaiting_input;
|
||
带裁决经 BackgroundTask resume 续跑。非 awaiting 态 → 409。
|
||
|
||
不变量:链是**编排**新增、非业务重写——复用同一 `assemble`/审稿 spec/`run_accept_transaction`
|
||
(经 `accept_op` 闭包注入),守 #1/#3/#4/#5/#9。token 绝不入 job result/状态/日志(§5)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import uuid
|
||
from typing import Annotated, Any
|
||
|
||
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from ww_core.domain import JobRepo, ProjectRepo
|
||
from ww_core.domain.job_repo import STATUS_AWAITING
|
||
from ww_db import get_session
|
||
from ww_llm_gateway import Gateway
|
||
from ww_shared import AppError, ErrorCode, ErrorEnvelope
|
||
|
||
from ww_api.logging_config import get_logger
|
||
from ww_api.schemas.chain import (
|
||
ChainResumeAccepted,
|
||
ChainResumeRequest,
|
||
ChainRunAccepted,
|
||
ChainRunRequest,
|
||
)
|
||
from ww_api.services.chain_deps import (
|
||
CheckpointerFactory,
|
||
get_checkpointer_factory,
|
||
)
|
||
from ww_api.services.chain_runner import (
|
||
JOB_KIND_CHAIN,
|
||
build_accept_op,
|
||
run_chain_job,
|
||
)
|
||
from ww_api.services.credentials import STUB_OWNER_ID
|
||
from ww_api.services.foreshadow_scan import SessionFactory
|
||
from ww_api.services.project_deps import (
|
||
GatewayChainBuilder,
|
||
GatewayDigestBuilder,
|
||
get_chain_gateway,
|
||
get_chain_gateway_builder,
|
||
get_digest_gateway_builder,
|
||
get_job_repo,
|
||
get_project_repo,
|
||
get_session_factory,
|
||
)
|
||
|
||
log = get_logger("ww.api.chain")
|
||
|
||
router = APIRouter(prefix="/projects", tags=["chain"])
|
||
|
||
# 内置链(§1.3/§3):draft_volume=从 start 章按记忆量产;continue_volume=每章以上一章已验收
|
||
# 正文末尾作前文引子续写(F2)。未知 key → 404(系统边界,fail fast)。
|
||
SUPPORTED_CHAINS = frozenset({"draft_volume", "continue_volume"})
|
||
|
||
ProjectRepoDep = Annotated[ProjectRepo, Depends(get_project_repo)]
|
||
JobRepoDep = Annotated[JobRepo, Depends(get_job_repo)]
|
||
ChainGatewayDep = Annotated[Gateway, Depends(get_chain_gateway)]
|
||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||
SessionFactoryDep = Annotated[SessionFactory, Depends(get_session_factory)]
|
||
CheckpointerFactoryDep = Annotated[CheckpointerFactory, Depends(get_checkpointer_factory)]
|
||
DigestBuilderDep = Annotated[GatewayDigestBuilder, Depends(get_digest_gateway_builder)]
|
||
ChainBuilderDep = Annotated[GatewayChainBuilder, Depends(get_chain_gateway_builder)]
|
||
|
||
_RUN_ERRORS: dict[int | str, dict[str, Any]] = {
|
||
404: {"model": ErrorEnvelope, "description": "项目或链 key 不存在"},
|
||
422: {"model": ErrorEnvelope, "description": "请求参数非法(count 越界等)"},
|
||
503: {"model": ErrorEnvelope, "description": "无可用 LLM 凭据"},
|
||
}
|
||
_RESUME_ERRORS: dict[int | str, dict[str, Any]] = {
|
||
404: {"model": ErrorEnvelope, "description": "项目或 job 不存在"},
|
||
409: {"model": ErrorEnvelope, "description": "job 非 awaiting_input 态,不可续跑"},
|
||
}
|
||
|
||
|
||
async def _require_project(project_repo: ProjectRepo, project_id: uuid.UUID) -> None:
|
||
if await project_repo.get(STUB_OWNER_ID, project_id) is None:
|
||
raise AppError(ErrorCode.NOT_FOUND, f"project not found: {project_id}")
|
||
|
||
|
||
@router.post(
|
||
"/{project_id}/chains/{chain_key}/run",
|
||
status_code=202,
|
||
responses=_RUN_ERRORS,
|
||
)
|
||
async def run_chain(
|
||
project_id: uuid.UUID,
|
||
chain_key: str,
|
||
body: ChainRunRequest,
|
||
request: Request,
|
||
response: Response,
|
||
background_tasks: BackgroundTasks,
|
||
project_repo: ProjectRepoDep,
|
||
job_repo: JobRepoDep,
|
||
gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503);不用于实际 LLM 调用
|
||
session: SessionDep,
|
||
session_factory: SessionFactoryDep,
|
||
checkpointer_factory: CheckpointerFactoryDep,
|
||
digest_gateway_builder: DigestBuilderDep,
|
||
chain_gateway_builder: ChainBuilderDep,
|
||
) -> ChainRunAccepted:
|
||
"""发起多章链:写一行 job 返 202,链经 BackgroundTask 异步跑。
|
||
|
||
项目不存在 → 404;未知 chain_key → 404;无凭据 → 503(dep 解析阶段拦下);
|
||
`count` 越界由 schema Field 在解析阶段 → 422。`gateway` 仅做请求阶段凭据探测——
|
||
链本体在 BackgroundTask 里用独立 session 重建网关跑(请求 session 即将关闭)。
|
||
"""
|
||
request_id = getattr(request.state, "request_id", None)
|
||
|
||
await _require_project(project_repo, project_id)
|
||
if chain_key not in SUPPORTED_CHAINS:
|
||
raise AppError(ErrorCode.NOT_FOUND, f"unknown chain key: {chain_key}")
|
||
|
||
job = await job_repo.create(project_id, JOB_KIND_CHAIN)
|
||
await session.commit() # job 行须在 202 返回前持久化(供前端立即轮询)。
|
||
|
||
accept_op = build_accept_op(
|
||
session_factory=session_factory,
|
||
digest_gateway_builder=digest_gateway_builder,
|
||
request_id=request_id,
|
||
)
|
||
background_tasks.add_task(
|
||
run_chain_job,
|
||
session_factory,
|
||
job.id,
|
||
project_id=project_id,
|
||
user_id=STUB_OWNER_ID,
|
||
chain_key=chain_key,
|
||
start_chapter_no=body.start_chapter_no,
|
||
count=body.count,
|
||
chain_gateway_builder=chain_gateway_builder,
|
||
checkpointer_ctx=checkpointer_factory,
|
||
accept_op=accept_op,
|
||
chapter_repo_factory=_chapter_repo_factory,
|
||
review_repo_factory=_review_repo_factory,
|
||
request_id=request_id,
|
||
)
|
||
|
||
log.info(
|
||
"chain_run_scheduled",
|
||
project_id=str(project_id),
|
||
request_id=request_id,
|
||
job_id=str(job.id),
|
||
chain_key=chain_key,
|
||
start_chapter_no=body.start_chapter_no,
|
||
count=body.count,
|
||
)
|
||
response.status_code = 202
|
||
return ChainRunAccepted(
|
||
job_id=job.id,
|
||
chain_key=chain_key,
|
||
start_chapter_no=body.start_chapter_no,
|
||
count=body.count,
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/{project_id}/chains/runs/{job_id}/resume",
|
||
status_code=202,
|
||
responses=_RESUME_ERRORS,
|
||
)
|
||
async def resume_chain(
|
||
project_id: uuid.UUID,
|
||
job_id: uuid.UUID,
|
||
body: ChainResumeRequest,
|
||
request: Request,
|
||
response: Response,
|
||
background_tasks: BackgroundTasks,
|
||
project_repo: ProjectRepoDep,
|
||
job_repo: JobRepoDep,
|
||
gateway: ChainGatewayDep, # 凭据探测(无凭据 → dep 解析阶段 503);不用于实际 LLM 调用
|
||
session: SessionDep,
|
||
session_factory: SessionFactoryDep,
|
||
checkpointer_factory: CheckpointerFactoryDep,
|
||
digest_gateway_builder: DigestBuilderDep,
|
||
chain_gateway_builder: ChainBuilderDep,
|
||
) -> ChainResumeAccepted:
|
||
"""裁决续跑:仅当 job=awaiting_input,带裁决经 BackgroundTask resume。
|
||
|
||
项目不存在 → 404;job 不存在 / 不属于该项目 → 404(同案,不枚举);非 awaiting 态 →
|
||
409(CONFLICT)。`awaiting→running` 在 HTTP 处理器内**原子抢占**(条件 UPDATE),避免两个
|
||
并发 resume 都过守卫后双 `Command(resume=...)` 损坏图(审评 #3);抢不到(已被并发抢走/非
|
||
awaiting)→ 409。resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id)。
|
||
"""
|
||
request_id = getattr(request.state, "request_id", None)
|
||
|
||
await _require_project(project_repo, project_id)
|
||
job = await job_repo.get(job_id)
|
||
# 所有权校验(审评 #4):job 不存在 **或** 不属于该项目 → 404(同案,避免跨项目枚举 job)。
|
||
if job is None or job.project_id != project_id:
|
||
raise AppError(ErrorCode.NOT_FOUND, f"job not found: {job_id}")
|
||
if job.status != STATUS_AWAITING:
|
||
raise AppError(
|
||
ErrorCode.CONFLICT,
|
||
f"job {job_id} 非 awaiting_input 态(当前 {job.status}),不可续跑",
|
||
{"job_status": job.status},
|
||
)
|
||
|
||
# 原子抢占 awaiting→running(审评 #3):抢不到(并发竞态/已非 awaiting)→ 409,绝不调度。
|
||
claimed = await job_repo.claim_awaiting_to_running(job_id)
|
||
await session.commit() # 抢占须在 202 返回前持久化(防并发 resume 双调度)。
|
||
if claimed is None:
|
||
raise AppError(
|
||
ErrorCode.CONFLICT,
|
||
f"job {job_id} 已被并发续跑抢占或非 awaiting_input 态,不可重复续跑",
|
||
)
|
||
|
||
chain_key = claimed.kind if claimed.kind in SUPPORTED_CHAINS else "draft_volume"
|
||
|
||
accept_op = build_accept_op(
|
||
session_factory=session_factory,
|
||
digest_gateway_builder=digest_gateway_builder,
|
||
request_id=request_id,
|
||
)
|
||
background_tasks.add_task(
|
||
run_chain_job,
|
||
session_factory,
|
||
job_id,
|
||
project_id=project_id,
|
||
user_id=STUB_OWNER_ID,
|
||
chain_key=chain_key,
|
||
start_chapter_no=1, # resume 不重置区间:图从检查点续,初值不再使用。
|
||
count=1,
|
||
chain_gateway_builder=chain_gateway_builder,
|
||
checkpointer_ctx=checkpointer_factory,
|
||
accept_op=accept_op,
|
||
chapter_repo_factory=_chapter_repo_factory,
|
||
review_repo_factory=_review_repo_factory,
|
||
resume_decisions=body.decisions,
|
||
request_id=request_id,
|
||
)
|
||
|
||
log.info(
|
||
"chain_resume_scheduled",
|
||
project_id=str(project_id),
|
||
request_id=request_id,
|
||
job_id=str(job_id),
|
||
decision_count=len(body.decisions),
|
||
)
|
||
response.status_code = 202
|
||
return ChainResumeAccepted(job_id=job_id, chain_key=chain_key)
|
||
|
||
|
||
def _chapter_repo_factory(session: AsyncSession) -> object:
|
||
"""链节点(review 重读草稿)按 session 建 chapter draft repo。"""
|
||
from ww_core.domain.chapter_repo import SqlChapterRepo
|
||
|
||
return SqlChapterRepo(session)
|
||
|
||
|
||
def _review_repo_factory(session: AsyncSession) -> object:
|
||
"""链节点(review 落留痕 / decide 重读)按 session 建审稿 repo。"""
|
||
from ww_core.domain.review_repo import SqlReviewRepo
|
||
|
||
return SqlReviewRepo(session)
|