feat(api): C2 多章链 服务+端点+schema+checkpointer 接线
承 C1 链图(build_chain_graph),落地多章工作流链的 apps/api 壳:
- 3 端点 routers/chain.py:POST .../chains/{key}/run→202 ChainRunAccepted;
POST .../chains/runs/{job_id}/resume→202;GET /jobs/{id} 复用。校验:
count 1..50→422、未知 chain_key→404、resume 非 awaiting→409、无凭据→503。
- schemas/chain.py:ChainRunRequest/ChainRunAccepted/ChainResumeRequest
(ConflictDecision 复用 schemas/projects)。
- services/chain_runner.py:run_chain_job 仿 run_job 壳自建独立 session 驱动链图
(set_running→ainvoke→据 __interrupt__ 置 awaiting_input/done/failed);
build_accept_op 在 apps/api 装配验收事务闭包注入图节点(守 #3/#4);
token 不入 result/日志。
- services/chain_deps.py:get_checkpointer_factory(运行时 AsyncPostgresSaver
上下文 / 测试 MemorySaver)。
- 零迁移(设计 §7):复用 jobs,新增 status="awaiting_input" + JobRepo.set_awaiting,
awaiting 章经 result.awaiting_chapter;新错误码 ErrorCode.CONFLICT(409)。
- project_deps:build_chain_gateway/get_chain_gateway(按请求 tier writer/analyst/light
分派——单档网关恒返该档会错路由 review/digest)+ get_digest_gateway_builder。
单测 apps/api/tests/test_chain.py 12 用例(mock 网关 + MemorySaver + fake session/
accept_op,无 DB/无网络/无真 LLM):run/resume→202、未知 key 404、count 越界 422、
resume 非 awaiting 409、run_chain_job 无冲突→done、冲突→awaiting→resume→done、
错误脱敏、accept_op 冲突缺判→CONFLICT_UNRESOLVED。
门禁绿:ruff/format 干净 · mypy 193 Success · alembic 无漂移 · pytest 600 passed。
守不变量 #1/#3/#4/#5/#9。唯一新增 DDL(langgraph 检查点表)= C3 迁移。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
249
apps/api/ww_api/routers/chain.py
Normal file
249
apps/api/ww_api/routers/chain.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""多章工作流链端点(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 (
|
||||
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 (
|
||||
GatewayDigestBuilder,
|
||||
get_chain_gateway,
|
||||
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)。未知 key → 404(系统边界,fail fast)。
|
||||
SUPPORTED_CHAINS = frozenset({"draft_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)]
|
||||
|
||||
_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)
|
||||
session: SessionDep,
|
||||
session_factory: SessionFactoryDep,
|
||||
checkpointer_factory: CheckpointerFactoryDep,
|
||||
digest_gateway_builder: DigestBuilderDep,
|
||||
) -> 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,
|
||||
gateway=gateway,
|
||||
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,
|
||||
session: SessionDep,
|
||||
session_factory: SessionFactoryDep,
|
||||
checkpointer_factory: CheckpointerFactoryDep,
|
||||
digest_gateway_builder: DigestBuilderDep,
|
||||
) -> ChainRunAccepted:
|
||||
"""裁决续跑:仅当 job=awaiting_input,带裁决经 BackgroundTask resume。
|
||||
|
||||
项目不存在 → 404;job 不存在 → 404;非 awaiting 态 → 409(CONFLICT)。
|
||||
resume 经 `Command(resume=decisions)` 从 interrupt 续跑(同 thread_id=job_id)。
|
||||
"""
|
||||
request_id = getattr(request.state, "request_id", None)
|
||||
|
||||
await _require_project(project_repo, project_id)
|
||||
job = await job_repo.get(job_id)
|
||||
if job is None:
|
||||
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},
|
||||
)
|
||||
|
||||
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=job.kind if job.kind in SUPPORTED_CHAINS else "draft_volume",
|
||||
start_chapter_no=1, # resume 不重置区间:图从检查点续,初值不再使用。
|
||||
count=1,
|
||||
gateway=gateway,
|
||||
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 ChainRunAccepted(
|
||||
job_id=job_id,
|
||||
chain_key="draft_volume",
|
||||
start_chapter_no=0,
|
||||
count=0,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user