Files
Yaojia Wang 1921b037a0 fix(backlog): 修评审 3 HIGH — resume chain_key 真值 / 模板空白校验 / 续写链 resume E2E
1. resume_chain 回报真 chain_key:从 awaiting job.result 读回(run 时 _chain_result
   持久化),不再按 job.kind 推断(kind 恒为通用常量 "chain",从不在 SUPPORTED_CHAINS
   → 总错回退 draft_volume)。continue_volume 的 resume 回执现报真值。
2. 模板 title/body 拒纯空白:StringConstraints(strip_whitespace, min_length=1),
   "   " strip 后为空 → 422。
3. 续写链 resume 路径 E2E:continue_volume 第 1 章冲突 → interrupt → awaiting →
   resume 带裁决 → done,断言 resume 回执 / job result chain_key="continue_volume"
   (回归守卫 #1)。

测试:+resume continue_volume 单测(chain_key 真值)+2 模板空白 422 单测 +1 续写链
resume E2E。重生成 TS 客户端(仅 description 文案变化)。
2026-06-23 20:45:06 +02:00

279 lines
11 KiB
Python
Raw Permalink 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 (
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/§3draft_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无凭据 → 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,
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。
项目不存在 → 404job 不存在 / 不属于该项目 → 404同案不枚举非 awaiting 态 →
409CONFLICT。`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)
# 所有权校验(审评 #4job 不存在 **或** 不属于该项目 → 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_keyrun 时由 _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)