Files
writer-work-flow/apps/api/ww_api/routers/chain.py
Yaojia Wang 29349dc7ee 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>
2026-06-23 17:12:53 +02:00

250 lines
8.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.

"""多章工作流链端点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 越界 → 422schema 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无凭据 → 503dep 解析阶段拦下);
`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。
项目不存在 → 404job 不存在 → 404非 awaiting 态 → 409CONFLICT
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)