POST /reports 骨架:资格校验→终身配额预扣→tk_task→cpu 队列;动态装配 流水线(dotted-path 注册、缺阶段即 CONFIG_ERROR);中间态逐段落库、失败 退款、derived_features 跨重试/重生成复用(T-P3-07/09)。 实现:packages/report 的 pipeline.py(Stage 契约保持不变,新增 assemble_stages/ run_pipeline/StageFailureError,ReportContext 增可选 task_id 参数、向后兼容)、 repo.py、schemas.py、service.py(重放优先于资格校验、reserve+建行+建任务同事务、 提交后才投递、并发同键落败回滚);apps/api/routers/reports.py 填桩(send_task 按 任务名投递,不 import apps.worker);apps/worker/report_tasks.py(worker 侧自建 gateway 装配、状态归一 QUEUED→RUNNING、成功 commit 配额+成本聚合写 model_info、 基础设施异常重试一次、重试耗尽兜底失败退款)。测试:单测 6 个(装配缺失/空配置/ 协议违约/顺序执行/失败保留产物),集成 6 场景(预扣 lifetime/PENDING、成功 committed+两阶段键、同 request_key 幂等不重复扣、二次首生成 40920、阶段失败 refund+derived_features 保留、regenerate 走 m: 月配额 version+1 复用视觉中间态)。 偏差说明: 1) apps/worker/celery_app.py 的 include 只含 apps.worker.tasks(禁改文件),真 worker 进程注册 meida.report.generate 需协调者合并时把 include 补上 apps.worker.report_tasks;API 侧已按任务名 send_task,两侧只靠名字衔接,测试 不受影响。2) ReportContext 增加可选 task_id 关键字参数(失败结案要推动 tk_task),Stage 协议/DEFAULT_STAGE_PATHS/stage_names 未动,T16/T17 兼容。 3) JSONB 深变更用 flag_modified 显式标脏(重新赋相等新 dict 会被 flush 的按值 比较跳过,集成测试抓到过该坑)。4) POST 后 tk_task 保持 PENDING(任务文档 §4 场景 1 口径),QUEUED→RUNNING 由 worker 入口归一。
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""报告流水线的阶段契约与编排(第四批 T15/T16/T17 的共同接口)。
|
||
|
||
阶段契约(``ReportContext`` / ``Stage`` / ``DEFAULT_STAGE_PATHS``)由协调提交
|
||
定死,T15 实现编排(``assemble_stages`` / ``run_pipeline``)。T16 提供
|
||
vision_stage / diagnosis_stage,T17 提供 copy_stage / validation_stage——
|
||
三个任务互不改对方文件,靠 Settings.report_pipeline_stages 里的 dotted
|
||
path 动态装配。
|
||
|
||
设计动机(为什么动态 import):三个阶段模块由三个并行分支各自创建,
|
||
静态 import 会在任一分支缺文件时炸掉编排;按配置动态装配让每个分支
|
||
独立可测,合并后自动咬合。代价是 import-linter 看不到这条边——阶段模块
|
||
自身的依赖仍受契约约束,编排层注入的 session/gateway 都是既有合法依赖。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib
|
||
from typing import Any, Protocol, runtime_checkable
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm.attributes import flag_modified
|
||
|
||
from packages.billing import repo as billing_repo
|
||
from packages.billing.service import BillingService, Reservation
|
||
from packages.domain.task.state import TaskStatus
|
||
from packages.llm.gateway import LLMGateway
|
||
from packages.shared.config import Settings
|
||
from packages.shared.errors import AppError, ErrorCode
|
||
from packages.shared.logging import get_logger
|
||
from packages.shared.models import Ledger, RpInput, RpReport
|
||
from packages.task import repo as task_repo
|
||
from packages.task.service import TaskService
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
|
||
class ReportContext:
|
||
"""一个阶段的执行上下文。
|
||
|
||
不是 frozen:阶段要写 ``ctx.report.result[stage.name]`` 与
|
||
``ctx.rp_input.derived_features``(中间态落库由编排层 flush)。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
report: RpReport,
|
||
report_input: RpInput,
|
||
session: AsyncSession,
|
||
settings: Settings,
|
||
gateway: LLMGateway | None = None,
|
||
task_id: int | None = None,
|
||
) -> None:
|
||
self.report = report
|
||
self.report_input = report_input
|
||
self.session = session
|
||
self.settings = settings
|
||
self.gateway = gateway
|
||
# 编排层专用:失败结案要推动 tk_task。阶段实现不需要感知它,
|
||
# 保持可选以保证旧构造调用(T16/T17 的测试)不受影响。
|
||
self.task_id = task_id
|
||
|
||
|
||
@runtime_checkable
|
||
class Stage(Protocol):
|
||
"""流水线阶段。name 同时是 result JSONB 里的产物键。
|
||
|
||
约定:阶段把自己的全部产物写进 ``ctx.report.result[self.name]``;
|
||
抛异常即整条流水线失败(编排层负责 tk_task→failed + refund)。
|
||
"""
|
||
|
||
name: str
|
||
|
||
async def run(self, ctx: ReportContext) -> None: ...
|
||
|
||
|
||
#: T15 的 assemble_stages 按此清单 importlib 动态导入;缺失即 CONFIG_ERROR。
|
||
DEFAULT_STAGE_PATHS: tuple[str, ...] = (
|
||
"packages.report.vision_stage:VisionStage",
|
||
"packages.report.diagnosis_stage:DiagnosisStage",
|
||
"packages.report.copy_stage:CopyStage",
|
||
"packages.report.validation_stage:ValidationStage",
|
||
)
|
||
|
||
|
||
def stage_names(result: dict[str, Any]) -> list[str]:
|
||
"""result JSONB 里已出现的阶段键(诊断/调试用的小工具)。"""
|
||
return [k for k in result if isinstance(result[k], dict)]
|
||
|
||
|
||
class StageFailureError(Exception):
|
||
"""阶段异常且已按失败结案(rp_report failed + tk_task failed + 退款已提交)。
|
||
|
||
celery 包装层据此区分两类异常:``StageFailureError`` 是业务性失败,重跑
|
||
也不会好,不再重试;其它异常(DB/网络/装配)发生在结算之外,值得用
|
||
重试换恢复。
|
||
"""
|
||
|
||
def __init__(self, stage_name: str, original: BaseException) -> None:
|
||
self.stage_name = stage_name
|
||
self.original = original
|
||
super().__init__(f"stage={stage_name} error={type(original).__name__}: {original}")
|
||
|
||
|
||
def assemble_stages(settings: Settings) -> list[Stage]:
|
||
"""按 ``settings.report_pipeline_stages`` 的 dotted path 动态装配阶段列表。
|
||
|
||
动态 import 是刻意的插件式装配(见模块 docstring):T16/T17 各自建阶段
|
||
模块,不碰本文件;import-linter 看不到这条边,阶段模块自身的依赖仍受
|
||
分层契约约束,这里只做装配不做业务。
|
||
|
||
模块/类不存在、实例化失败、或不符合 Stage 协议时,收集**全部**缺失项
|
||
抛 ``CONFIG_ERROR`` —— 绝不静默跳过:静默等于流水线空跑还扣用户配额。
|
||
"""
|
||
if not settings.report_pipeline_stages:
|
||
raise AppError(
|
||
ErrorCode.CONFIG_ERROR,
|
||
message="报告流水线阶段配置为空",
|
||
data={"missing": list(DEFAULT_STAGE_PATHS)},
|
||
)
|
||
|
||
missing: list[str] = []
|
||
stages: list[Stage] = []
|
||
for dotted in settings.report_pipeline_stages:
|
||
module_path, sep, attr = dotted.partition(":")
|
||
stage: Any = None
|
||
if sep and module_path and attr:
|
||
try:
|
||
# 阶段构造约定无参。__init__ 抛错视同配置坏了(比如阶段自身
|
||
# 校验它的依赖),归入 CONFIG_ERROR 而不是让 worker 崩溃。
|
||
stage = getattr(importlib.import_module(module_path), attr)()
|
||
except Exception:
|
||
stage = None
|
||
if stage is None or not isinstance(stage, Stage):
|
||
missing.append(dotted)
|
||
else:
|
||
stages.append(stage)
|
||
|
||
if missing:
|
||
raise AppError(
|
||
ErrorCode.CONFIG_ERROR,
|
||
message="报告流水线阶段装配失败:缺失或不可用",
|
||
data={"missing": missing},
|
||
)
|
||
return stages
|
||
|
||
|
||
def reservation_from_ledger(ledger: Ledger) -> Reservation:
|
||
"""从 reserved 流水重建 Reservation(worker 成功 commit / 失败 refund 都要它)。"""
|
||
return Reservation(
|
||
tx_id=ledger.tx_id,
|
||
user_id=ledger.user_id,
|
||
quota_key=ledger.quota_key or "",
|
||
period_key=ledger.period_key or "",
|
||
amount=abs(ledger.delta),
|
||
remaining=ledger.balance_after,
|
||
idempotency_key=ledger.idempotency_key,
|
||
)
|
||
|
||
|
||
async def load_reservation(session: AsyncSession, task_id: int) -> Reservation | None:
|
||
"""按 ``tk_task.reserve_tx_id`` 取回预扣;无预扣或流水缺失返回 None。"""
|
||
task = await task_repo.get(session, task_id)
|
||
if task is None or task.reserve_tx_id is None:
|
||
return None
|
||
ledger = await billing_repo.get_ledger(session, task.reserve_tx_id)
|
||
if ledger is None:
|
||
return None
|
||
return reservation_from_ledger(ledger)
|
||
|
||
|
||
async def run_pipeline(ctx: ReportContext, stages: list[Stage]) -> None:
|
||
"""逐阶段执行,每阶段产物即刻 flush 落库(T-P3-09 中间态持久化)。
|
||
|
||
中间态的意义:阶段半程失败或 worker 崩溃后重试,已完成阶段(尤其
|
||
``rp_input.derived_features`` 里的视觉结果)不必重算——「失败不需重新
|
||
上传照片」的结构保证就落在这几次 flush 里。
|
||
|
||
阶段异常 → ``_settle_stage_failure``(事务纪律见其 docstring)后包成
|
||
:class:`StageFailureError` 上抛;正常返回即全部阶段成功,成功结案归调用方
|
||
(worker:commit 配额 + succeeded + 成本聚合)。
|
||
"""
|
||
# 防御:default=dict 只在 INSERT 时生效,测试/手工构造的瞬态对象可能为 None。
|
||
if ctx.report.result is None:
|
||
ctx.report.result = {}
|
||
if ctx.report_input.derived_features is None:
|
||
ctx.report_input.derived_features = {}
|
||
|
||
for stage in stages:
|
||
try:
|
||
await stage.run(ctx)
|
||
except Exception as exc:
|
||
await _settle_stage_failure(ctx, stage.name, exc)
|
||
raise StageFailureError(stage.name, exc) from exc
|
||
# JSONB 列没有 Mutable 追踪:阶段对 dict 的深变更 ORM 看不见。且不能
|
||
# 靠「重新赋一个相等的新 dict」——flush 按值比较会认为没变而跳过
|
||
# UPDATE。flag_modified 强制该列进入下一条 UPDATE(中间态逐段落库
|
||
# 的关键)。derived_features 同理。
|
||
ctx.report.result.setdefault(stage.name, {})
|
||
flag_modified(ctx.report, "result")
|
||
flag_modified(ctx.report_input, "derived_features")
|
||
await ctx.session.flush()
|
||
logger.info(
|
||
"report_stage_done",
|
||
report_id=ctx.report.report_id,
|
||
task_id=ctx.task_id,
|
||
stage=stage.name,
|
||
)
|
||
|
||
|
||
async def _settle_stage_failure(ctx: ReportContext, stage_name: str, exc: Exception) -> None:
|
||
"""阶段失败的结案:report failed + tk_task failed + 退款,提交后才返回。
|
||
|
||
事务纪律(对齐 AgentService):退款必须先于异常上抛提交,否则外层回滚
|
||
会把退款一起卷走(FR-BILL-05)。结案本身失败只记日志、不吞原始异常——
|
||
此时额度挂在 reserved 状态,靠流水对账兜底。
|
||
"""
|
||
summary = {"stage": stage_name, "type": type(exc).__name__, "message": str(exc)[:200]}
|
||
try:
|
||
ctx.report.status = "failed"
|
||
ctx.report.result["error"] = summary
|
||
flag_modified(ctx.report, "result") # 深变更必须显式标脏,见 run_pipeline 的注释
|
||
if ctx.task_id is not None:
|
||
await TaskService(ctx.session).transition(
|
||
task_id=ctx.task_id,
|
||
target=TaskStatus.FAILED,
|
||
stage_text=f"阶段失败:{stage_name}",
|
||
error_message=f"{type(exc).__name__}: {exc}",
|
||
)
|
||
reservation = await load_reservation(ctx.session, ctx.task_id)
|
||
if reservation is not None:
|
||
await BillingService(ctx.session).refund(
|
||
reservation, reason=f"report_stage_failed:{stage_name}"
|
||
)
|
||
await ctx.session.commit()
|
||
logger.warning(
|
||
"report_stage_failed",
|
||
report_id=ctx.report.report_id,
|
||
task_id=ctx.task_id,
|
||
stage=stage_name,
|
||
error_type=type(exc).__name__,
|
||
)
|
||
except Exception:
|
||
await ctx.session.rollback()
|
||
logger.exception(
|
||
"report_failure_settle_failed", report_id=ctx.report.report_id, stage=stage_name
|
||
)
|