Files

254 lines
10 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.
"""报告流水线的阶段契约与编排(第四批 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 collections.abc import Sequence
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, *, paths: Sequence[str] | None = None) -> list[Stage]:
"""按阶段配置(dotted path 清单)动态装配阶段列表。
默认读 ``settings.report_pipeline_stages``(basic 报告);tier=full 的
深度诊断由调用方显式传 ``settings.report_full_pipeline_stages``——两条
流水线共用装配与失败语义,只是阶段清单不同。
动态 import 是刻意的插件式装配(见模块 docstring):T16/T17 各自建阶段
模块,不碰本文件;import-linter 看不到这条边,阶段模块自身的依赖仍受
分层契约约束,这里只做装配不做业务。
模块/类不存在、实例化失败、或不符合 Stage 协议时,收集**全部**缺失项
抛 ``CONFIG_ERROR`` —— 绝不静默跳过:静默等于流水线空跑还扣用户配额。
"""
stage_paths = list(paths) if paths is not None else list(settings.report_pipeline_stages)
if not stage_paths:
raise AppError(
ErrorCode.CONFIG_ERROR,
message="报告流水线阶段配置为空",
data={"missing": list(DEFAULT_STAGE_PATHS)},
)
missing: list[str] = []
stages: list[Stage] = []
for dotted in stage_paths:
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
)