"""与服务商无关的生成领域模型。 **本模块不得 import 任何服务商代码,也不得 import httpx。** 这不是洁癖,是本层 存在的唯一理由——Java 侧、未来的 gpt-image-2、以及任何第三家供应商都对着这一层 编程。一旦这里漏进一个厂商概念,「换供应商不动上层」就不再成立,而那正是分层 想买到的东西。 判断某个概念该放这里还是放 `providers/<厂商>/`:问「换一家供应商,这个概念还在 吗」。`Retry`/`UserFacing` 描述的是 **Worker 与客户端该怎么做**,与谁家供应商 无关,故在此;`expired` 状态、`QuotaExceeded` 这类码是火山特有的,故不在此。 """ from __future__ import annotations from dataclasses import dataclass from enum import Enum class MediaKind(Enum): """生成产物的类型,与 `creation.generation_jobs.media_kind` 同值。""" IMAGE = "image" VIDEO = "video" class Retry(Enum): """失败后该怎么办。由服务商适配层判定,由 Java 侧的 Worker 执行。""" #: 不可重试:同样的输入必然得到同样的结果(参数错、内容被拒、鉴权失败)。 NEVER = "never" #: 可重试且应尽快:限流、突发流量保护、服务过载。退避几秒到几十秒。 BACKOFF = "backoff" #: 可重试但要等很久:额度/配额耗尽,通常要等到下一个计费周期或充值之后。 #: Worker 不该把重试预算浪费在这上面,应直接终态失败并让用户看到出口。 QUOTA = "quota" class UserFacing(Enum): """给终端用户的呈现类别,对应客户端的两个 429 分支(ADR-023 §9)。 「配额耗尽」与「频率过快」必须分开:前者等的是明天或充值(弹 sheet 给出口), 后者等的是几秒(弹 SnackBar 可自动重试)。合成一类必然有一半场景的文案和 出口是错的。 """ QUOTA_EXHAUSTED = "quota_exhausted" RATE_LIMITED = "rate_limited" CONTENT_REJECTED = "content_rejected" SERVER_ERROR = "server_error" UPSTREAM_UNAVAILABLE = "upstream_unavailable" @dataclass(frozen=True) class Failure: """一次失败的完整描述。 :param provider_code: 原始厂商错误码,**原样保留、绝不丢弃**——排查时它往往 是唯一线索。领域层不理解它的含义,只负责把它带到日志和响应里。 """ retry: Retry user_facing: UserFacing provider_code: str message: str = "" class TaskState(Enum): """与服务商无关的任务状态。 刻意比火山的状态集**更小**:火山有 queued / running / cancelled / succeeded / failed / expired 六个,这里把 queued 与 running 合成 PENDING/RUNNING 两态、 把 expired 归入 FAILED(它是超时,属于失败的一种,重试策略由 `Failure` 单独表达)。映射的取舍放在适配层,领域层只保留上层真正需要区分的状态。 """ PENDING = "pending" RUNNING = "running" SUCCEEDED = "succeeded" FAILED = "failed" CANCELLED = "cancelled" @property def is_terminal(self) -> bool: return self in {TaskState.SUCCEEDED, TaskState.FAILED, TaskState.CANCELLED} @dataclass(frozen=True) class GenerationRequest: """一次生成请求,服务商无关。 这是 Java 侧发过来的内容。**刻意不含 `user_id`**:本服务干活只需要 `job_id`、生成参数与上传目标,传用户标识属于不必要的扩散(ADR-023 §5 数据最小化)。需要给厂商做滥用检测时传的是 `safety_identifier`——一个哈希, 不是明文用户 ID。 :param model: 厂商侧的模型标识,由 Java 侧从目录表的 `provider_model_name` 快照取值后传入。**适配层不自行选模型**——选型是目录的职责,且任务行里 冻结的快照必须与实际调用的模型一致。 :param input_url: 参考图/首帧的公网可达 URL。为空即文生视频(参考图选填, 决策 A2)。 :param upload_target: 产物的预签名 PUT 地址。当前未启用(转存阻塞于存储 决策,ADR-023 §7),保留字段使将来启用无需改契约。 """ job_id: str media_kind: MediaKind model: str prompt: str | None = None input_url: str | None = None ratio: str | None = None resolution: str | None = None duration_sec: int | None = None generate_audio: bool = True watermark: bool = False safety_identifier: str | None = None callback_url: str | None = None upload_target: str | None = None @dataclass(frozen=True) class GenerationResult: """一次查询的结果,服务商无关。 :param asset_url: 产物 URL。**可能是有时效的厂商地址**——火山 Seedance 的 有效期是 24 小时,因此调用方必须在有效期内转存,不可长期保存此值。 适配层通过 `asset_url_expires_in_sec` 告知时效,避免上层把它当永久地址。 :param cover_url: 封面图 URL(视频场景)。火山的尾帧图可免费充当此用途。 :param width_px: 像素宽高。**火山不直接返回像素值**,只返回 `ratio` 与 `resolution`;换算需要一张随模型family变化的对照表。故此处在 V7 目录 种子定下模型族之前保持 None,并把 `ratio`/`resolution` 原样透出, 由上层或后续实现换算——编一个可能错的尺寸比留空更糟。 :param billing_tokens: 计费对账依据(火山为 `usage.completion_tokens`)。 """ provider_request_id: str state: TaskState progress: int = 0 asset_url: str | None = None asset_url_expires_in_sec: int | None = None cover_url: str | None = None ratio: str | None = None resolution: str | None = None width_px: int | None = None height_px: int | None = None duration_ms: int | None = None output_format: str | None = None failure: Failure | None = None billing_tokens: int | None = None def __post_init__(self) -> None: # 失败态必须带 Failure,否则上层无法决定重试策略,只能盲目重试或盲目 # 放弃——两者都错。宁可在这里炸掉也不要把半个失败态放出去。 if self.state is TaskState.FAILED and self.failure is None: raise ValueError("FAILED 状态必须携带 Failure") if self.state is TaskState.SUCCEEDED and not self.asset_url: raise ValueError("SUCCEEDED 状态必须携带 asset_url")