"""多章工作流链端点(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 真值:run 时 interrupt 把图 state 的 chain_key 持久进 job.result(_chain_result)。 # 读回它,而非按 job.kind 推断(kind 恒为通用常量 JOB_KIND_CHAIN,从不在 SUPPORTED_CHAINS)。 chain_key = _chain_key_from_result(claimed.result) 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 _chain_key_from_result(result: dict[str, Any] | None) -> str: """从 awaiting job 的 result 取真 chain_key(run 时由 _chain_result 持久化)。 缺失/非受支持 key(旧行/异常)→ 回退 "draft_volume"(保守默认,不崩)。 """ key = (result or {}).get("chain_key") if isinstance(key, str) and key in SUPPORTED_CHAINS: return key return "draft_volume" 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)