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:
@@ -10,7 +10,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import Depends
|
||||
@@ -399,6 +399,53 @@ async def build_writer_gateway(session: AsyncSession, store: CredentialStore) ->
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
# 多章链一条 run 内跨 writer(write)/analyst(review)/light(digest) 三档——单档网关的
|
||||
# chain_resolver 恒返该档链、忽略请求 tier,会把 review/digest 错路由到 writer。故链需一个
|
||||
# **按请求 tier 分派**的网关:并 union 三档所有 provider 的适配器 + 逐 tier 解析其链。
|
||||
_CHAIN_TIERS: tuple[Tier, ...] = ("writer", "analyst", "light")
|
||||
|
||||
|
||||
def _chain_for_tier(stored_by_tier: dict[Tier, Any], tier: Tier) -> list[Route]:
|
||||
"""据该 tier 的 DB 路由行构链;无行则退回全局默认(单 provider,M1 兼容)。"""
|
||||
stored = stored_by_tier.get(tier)
|
||||
if stored is None:
|
||||
return [resolve_route(tier)]
|
||||
primary_spec = f"{stored.provider}:{stored.model}"
|
||||
return chain_from_routing(tier, primary_spec, list(stored.fallback))
|
||||
|
||||
|
||||
async def build_chain_gateway(session: AsyncSession, store: CredentialStore) -> Gateway:
|
||||
"""装配多章链网关:按请求 tier(writer/analyst/light)分派,union 三档适配器 + 回退链。
|
||||
|
||||
与 `build_gateway_for_tier` 同凭据/适配器装配,但 `chain_resolver` 据 `req.tier` 返回
|
||||
对应档的链(不锁死单档)。至少一个可用适配器,否则 `LLM_UNAVAILABLE`(无任何凭据)。
|
||||
"""
|
||||
ledger = SqlAlchemyLedgerSink(session)
|
||||
routing_rows = await store.list_routing()
|
||||
stored_by_tier: dict[Tier, Any] = {r.tier: r for r in routing_rows if r.tier in _CHAIN_TIERS}
|
||||
|
||||
# union 三档链里所有 provider,逐个建可用适配器(未配凭据/未知 base_url 的跳过)。
|
||||
adapters: dict[str, ProviderAdapter] = {}
|
||||
for tier in _CHAIN_TIERS:
|
||||
for route in _chain_for_tier(stored_by_tier, tier):
|
||||
if route.provider in adapters:
|
||||
continue
|
||||
built = await _build_provider_adapter(store, route.provider)
|
||||
if built is not None:
|
||||
adapters[route.provider] = built
|
||||
if not adapters:
|
||||
raise AppError(
|
||||
ErrorCode.LLM_UNAVAILABLE,
|
||||
"多章链无任何已配置凭据的 provider,请先在设置中配置",
|
||||
{"tiers": list(_CHAIN_TIERS)},
|
||||
)
|
||||
|
||||
def _resolver(tier: Tier) -> list[Route]:
|
||||
return _chain_for_tier(stored_by_tier, tier)
|
||||
|
||||
return Gateway(adapters=adapters, ledger=ledger, chain_resolver=_resolver)
|
||||
|
||||
|
||||
async def get_writer_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
@@ -451,3 +498,35 @@ async def get_refine_gateway(
|
||||
"""回炉(writer 档位)的可注入网关缝。测试经 override 注 mock(产纯文本重写段)。"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_gateway_for_tier(session, store, "writer")
|
||||
|
||||
|
||||
async def get_chain_gateway(
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
) -> Gateway:
|
||||
"""多章链(write writer + review analyst + digest light,按请求 tier 分派)的可注入网关缝。
|
||||
|
||||
`POST .../chains/{key}/run` 在 dep 解析阶段构建网关 → 无凭据时这里抛 `LLM_UNAVAILABLE`
|
||||
(503,调度 job 之前拦下,避免凭空写注定失败的 job)。链本体在 BackgroundTask 里用
|
||||
`run_chain_job` 自建独立 session 重新构网关跑(请求 session 已关闭);本依赖只做请求阶段
|
||||
凭据探测。测试经 `app.dependency_overrides[get_chain_gateway]` 注 mock(绝不联网)。
|
||||
"""
|
||||
store = SqlCredentialStore(session)
|
||||
return await build_chain_gateway(session, store)
|
||||
|
||||
|
||||
# 「按 session 建 digest 网关」缝类型(chain accept_op 终稿提炼按节点自建短事务的 session 建)。
|
||||
GatewayDigestBuilder = Callable[[AsyncSession], Awaitable[Gateway]]
|
||||
|
||||
|
||||
def _digest_gateway_builder(session: AsyncSession) -> Awaitable[Gateway]:
|
||||
"""链 accept 节点在自建短事务里建 light 档 digest 网关(终稿提炼)。"""
|
||||
return build_gateway_for_tier(session, SqlCredentialStore(session), "light")
|
||||
|
||||
|
||||
def get_digest_gateway_builder() -> GatewayDigestBuilder:
|
||||
"""返回「按 session 建 digest 网关」的缝(chain accept_op 终稿提炼用)。
|
||||
|
||||
BackgroundTask 自建短事务 → 节点内据该 session 现建 light 档网关。测试经
|
||||
`app.dependency_overrides[get_digest_gateway_builder]` 注返回 mock 的 builder(绝不联网)。
|
||||
"""
|
||||
return _digest_gateway_builder
|
||||
|
||||
Reference in New Issue
Block a user