751f8ad84e
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 4s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 1m17s
CI/CD Pipeline / Build Staging API Image (push) Successful in 1m15s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m22s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m13s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m15s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m28s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m11s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m48s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 42s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m27s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 4m28s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m14s
CI/CD Pipeline / Validate - Security (push) Successful in 6m45s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m19s
AI Code Review / AI Code Review (pull_request) Successful in 6m52s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m38s
CI/CD Pipeline / Unit Tests (push) Successful in 10m3s
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
308 lines
12 KiB
Python
Executable File
308 lines
12 KiB
Python
Executable File
"""统一智能选素材算法 — 合并 _helpers / generation_tasks / auto_clip_service 的重叠逻辑。
|
||
|
||
设计目标:
|
||
- 单一入口,替代 3 套分散的选素材代码
|
||
- 多维度加权评分:质量分 + 时长均衡 + 新鲜度 + 未使用偏好
|
||
- 多样性保障:按时长分桶(短/中/长)均衡选取,避免同质化
|
||
- 可扩展:后续接入 AI 模型时只需替换 score_asset()
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import random
|
||
from dataclasses import dataclass, field
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
# 素材选取排序时注入的随机噪声上限(分)。
|
||
# score_asset 综合得分范围为 0-100,噪声 0~20 意味着:
|
||
# - 素材间得分差距 > 20 分时,排名不受影响(质量差异显著的素材保持稳定优先级)
|
||
# - 得分接近(差距 <= 20 分)的素材排名会随机浮动,使每次生成选出的素材组合不同,
|
||
# 从素材组合层面降低成片重复率;排名靠后的低分素材也有机会入选。
|
||
SCORE_RANDOM_NOISE_MAX = 20.0
|
||
|
||
|
||
@dataclass
|
||
class SmartMatchResult:
|
||
"""单条素材的匹配结果。"""
|
||
|
||
asset: Any # Asset entity
|
||
score: float # 综合得分 0-100
|
||
breakdown: dict[str, float] = field(default_factory=dict) # 各维度得分明细
|
||
|
||
|
||
def _get_enum_value(obj: Any, attr: str) -> str:
|
||
"""安全获取属性值,兼容 StrEnum / 普通字符串。"""
|
||
val = getattr(obj, attr, None)
|
||
if val is None:
|
||
return ""
|
||
return val.value if hasattr(val, "value") else str(val)
|
||
|
||
|
||
def normalize_tag(tag) -> str:
|
||
"""标准化标签:去两端空白、小写;非 str 转 str。返回空串表示应丢弃。"""
|
||
if tag is None:
|
||
return ""
|
||
s = str(tag).strip().lower()
|
||
return s
|
||
|
||
|
||
def _duration_bucket(duration: float | None) -> str:
|
||
"""将素材时长分为 3 档:short(<10s) / medium(10-30s) / long(>30s)。"""
|
||
if duration is None or duration <= 0:
|
||
return "unknown"
|
||
if duration < 10:
|
||
return "short"
|
||
if duration <= 30:
|
||
return "medium"
|
||
return "long"
|
||
|
||
|
||
def score_asset(
|
||
asset: Any,
|
||
now: datetime | None = None,
|
||
script_tags: set | None = None,
|
||
ai_tags_by_asset: dict | None = None,
|
||
expected_categories: set[str] | None = None,
|
||
) -> tuple[float, dict[str, float]]:
|
||
"""为单个素材计算综合得分(0-100)。
|
||
|
||
维度权重(#2035 加入 AI 语义匹配 + 素材分类维度):
|
||
- quality_score (28%):素材质量分(0-100),无质量分按 50 计
|
||
- duration_fitness (22%):时长适配度,5-30s 为最优区间
|
||
- recency (12%):新鲜度,30 天内衰减
|
||
- unused (8%):未被/少被使用过的素材加分
|
||
- ai_semantic (20%):AI 标签(scene/objects/action)与文案标签重合度;无数据给 50 中性分
|
||
- category_match (10%):FFmpeg 自动分类结果(scenic/product/person/animal/food/tech/sport/music)
|
||
与期望类别重合度;无分类或无期望类别时给 60 中性分
|
||
|
||
Args:
|
||
script_tags: 标准化后的文案标签集合,用于 AI 语义匹配维度打分。
|
||
ai_tags_by_asset: asset_id → ai_tags dict 映射,ai_tags 含 scene/objects/action 字段。
|
||
|
||
Returns:
|
||
(total_score, breakdown_dict)
|
||
"""
|
||
if now is None:
|
||
now = datetime.now(UTC)
|
||
|
||
breakdown: dict[str, float] = {}
|
||
|
||
# 1. 质量分 (0-100) → 权重 40%
|
||
raw_quality = asset.quality_score if asset.quality_score is not None else 50.0
|
||
quality_component = raw_quality * 0.28
|
||
breakdown["quality"] = round(quality_component, 2)
|
||
|
||
# 2. 时长适配度 (0-100) → 权重 30%
|
||
# 最优区间 5-30s 得满分,越偏离越低
|
||
duration = getattr(asset, "duration", None) or 0.0
|
||
if duration <= 0:
|
||
duration_fitness = 30.0 # 未知时长给中等分
|
||
elif 5 <= duration <= 30:
|
||
duration_fitness = 100.0
|
||
elif duration < 5:
|
||
# 0-5s: 线性增长 20→100
|
||
duration_fitness = 20.0 + (duration / 5) * 80
|
||
else:
|
||
# >30s: 指数衰减,60s 时约 50 分
|
||
duration_fitness = 100.0 * math.exp(-0.02 * (duration - 30))
|
||
duration_fitness = max(duration_fitness, 10.0)
|
||
duration_component = duration_fitness * 0.22
|
||
breakdown["duration"] = round(duration_component, 2)
|
||
|
||
# 3. 新鲜度 (0-100) → 权重 20%
|
||
# 30 天半衰期
|
||
created_at = getattr(asset, "created_at", None)
|
||
if created_at is None:
|
||
recency = 50.0
|
||
else:
|
||
if created_at.tzinfo is None:
|
||
created_at = created_at.replace(tzinfo=UTC)
|
||
age_days = max(0, (now - created_at).total_seconds() / 86400)
|
||
recency = 100.0 * math.exp(-0.05 * age_days) # ~14天半衰期
|
||
recency_component = recency * 0.12
|
||
breakdown["recency"] = round(recency_component, 2)
|
||
|
||
# 4. 未使用偏好 (0-100) → 权重 10%
|
||
metadata = getattr(asset, "metadata", None) or {}
|
||
try:
|
||
use_count = int(metadata.get("generation_use_count") or 0)
|
||
except (ValueError, TypeError):
|
||
use_count = 0 # 脏数据时按未使用处理(保守策略:给未使用加分)
|
||
if use_count == 0:
|
||
unused_score = 100.0
|
||
elif use_count <= 3:
|
||
unused_score = 70.0
|
||
else:
|
||
unused_score = 30.0
|
||
unused_component = unused_score * 0.08
|
||
breakdown["unused"] = round(unused_component, 2)
|
||
|
||
# 5. AI 语义匹配 (0-100) → 权重 20%
|
||
if script_tags and ai_tags_by_asset:
|
||
asset_ai = ai_tags_by_asset.get(getattr(asset, "id", "")) or {}
|
||
ai_terms: set = set()
|
||
for key in ("scene", "objects", "action"):
|
||
vals = asset_ai.get(key) or []
|
||
if isinstance(vals, list):
|
||
for v in vals:
|
||
norm = normalize_tag(v)
|
||
if norm:
|
||
ai_terms.add(norm)
|
||
if ai_terms:
|
||
norm_script = {normalize_tag(t) for t in script_tags if normalize_tag(t)}
|
||
overlap = ai_terms & norm_script
|
||
union = ai_terms | norm_script
|
||
ratio = (len(overlap) / len(union)) if union else 0.0
|
||
if overlap:
|
||
ai_score = 50.0 + 50.0 * ratio
|
||
else:
|
||
ai_score = 20.0
|
||
else:
|
||
ai_score = 50.0
|
||
else:
|
||
ai_score = 50.0
|
||
ai_component = ai_score * 0.20
|
||
breakdown["ai_semantic"] = round(ai_component, 2)
|
||
|
||
# 6. 分类匹配 (0-100) → 权重 10%
|
||
asset_meta = getattr(asset, "metadata", None) or {}
|
||
asset_cat = (asset_meta.get("classification") or "").strip().lower()
|
||
if expected_categories and asset_cat:
|
||
norm_expected = {c.strip().lower() for c in expected_categories if c and c.strip()}
|
||
if asset_cat == "other":
|
||
cat_score = 50.0 # other 类不给额外加分也不扣分
|
||
elif asset_cat in norm_expected:
|
||
cat_score = 100.0
|
||
else:
|
||
cat_score = 30.0 # 分类明确但不匹配,略扣分
|
||
elif expected_categories:
|
||
cat_score = 60.0 # 无分类结果,中性
|
||
else:
|
||
cat_score = 60.0 # 无期望类别,中性
|
||
cat_component = cat_score * 0.10
|
||
breakdown["category_match"] = round(cat_component, 2)
|
||
|
||
total = quality_component + duration_component + recency_component + unused_component + ai_component + cat_component
|
||
return round(total, 2), breakdown
|
||
|
||
|
||
def smart_select_assets(
|
||
assets: list[Any],
|
||
*,
|
||
limit: int | None = None,
|
||
kind: str | None = None,
|
||
now: datetime | None = None,
|
||
rng: random.Random | None = None,
|
||
script_tags: set | None = None,
|
||
ai_tags_by_asset: dict | None = None,
|
||
expected_categories: set[str] | None = None,
|
||
) -> list[SmartMatchResult]:
|
||
"""从素材列表中智能选取素材。
|
||
|
||
Args:
|
||
assets: 候选素材列表(Asset 实体)
|
||
limit: 最大返回数量,None 表示不限制
|
||
kind: 按文件类型过滤(video/image/audio),None 表示不过滤
|
||
now: 当前时间(用于测试注入)
|
||
rng: 随机数生成器(用于测试注入,控制排序噪声可复现)
|
||
|
||
Returns:
|
||
按有效得分(综合得分 + 随机噪声)降序排列的 SmartMatchResult 列表。
|
||
r.score 始终为无噪声的原始综合得分;噪声仅用于排序/分桶顺序。
|
||
"""
|
||
# Step 1: 过滤 ready 状态
|
||
ready_assets = [a for a in assets if _get_enum_value(a, "status") == "ready"]
|
||
|
||
# Step 2: 按 kind 过滤
|
||
if kind:
|
||
ready_assets = [a for a in ready_assets if a.file_type == kind]
|
||
|
||
if not ready_assets:
|
||
return []
|
||
|
||
# Step 3: 评分
|
||
scored: list[SmartMatchResult] = []
|
||
for a in ready_assets:
|
||
total, breakdown = score_asset(
|
||
a,
|
||
now=now,
|
||
script_tags=script_tags,
|
||
ai_tags_by_asset=ai_tags_by_asset,
|
||
expected_categories=expected_categories,
|
||
)
|
||
scored.append(SmartMatchResult(asset=a, score=total, breakdown=breakdown))
|
||
|
||
# Step 4: 按「得分 + 随机噪声」降序排序
|
||
# 同分/近分素材(分差 <= SCORE_RANDOM_NOISE_MAX)每次选出的顺序与组合不同,
|
||
# 从素材组合层面降低成片重复率;分差显著(>20)的高质量素材排名不受影响。
|
||
# 噪声以 asset.id 为 key 缓存,保证同一次调用内排序与分桶轮询顺序一致。
|
||
rng = rng or random.Random()
|
||
noise_by_asset: dict[str, float] = {
|
||
getattr(a, "id", ""): rng.uniform(0.0, SCORE_RANDOM_NOISE_MAX) for a in ready_assets
|
||
}
|
||
|
||
def _effective(r: SmartMatchResult) -> float:
|
||
return r.score + noise_by_asset.get(getattr(r.asset, "id", ""), 0.0)
|
||
|
||
scored.sort(key=_effective, reverse=True)
|
||
|
||
# Step 5: 多样性保障 — 时长分桶均衡选取(桶内同样按含噪声顺序)
|
||
if limit and limit > 0 and len(scored) > limit:
|
||
scored = _diversity_select(scored, limit, effective_key=_effective)
|
||
elif limit and limit > 0:
|
||
scored = scored[:limit]
|
||
|
||
return scored
|
||
|
||
|
||
def _diversity_select(
|
||
scored: list[SmartMatchResult],
|
||
limit: int,
|
||
effective_key: Any | None = None,
|
||
) -> list[SmartMatchResult]:
|
||
"""从已排序的候选中按分桶均衡选取,避免全选中同一时长档。
|
||
|
||
策略:轮流从 short/medium/long 桶中按顺序取,直到凑满 limit。
|
||
scored 已按含噪声的有效得分排序,桶内直接继承该顺序;
|
||
effective_key 给出时最终输出也按有效得分排序(同一次调用内噪声一致)。
|
||
"""
|
||
buckets: dict[str, list[SmartMatchResult]] = {
|
||
"short": [],
|
||
"medium": [],
|
||
"long": [],
|
||
"unknown": [],
|
||
}
|
||
for r in scored:
|
||
bucket = _duration_bucket(getattr(r.asset, "duration", None))
|
||
buckets.setdefault(bucket, []).append(r)
|
||
|
||
selected: list[SmartMatchResult] = []
|
||
selected_ids: set[str] = set()
|
||
bucket_order = ["medium", "short", "long", "unknown"] # medium 优先
|
||
bucket_idx = {b: 0 for b in bucket_order}
|
||
|
||
while len(selected) < limit:
|
||
added = False
|
||
for b in bucket_order:
|
||
if len(selected) >= limit:
|
||
break
|
||
items = buckets.get(b, [])
|
||
idx = bucket_idx[b]
|
||
while idx < len(items):
|
||
candidate = items[idx]
|
||
idx += 1
|
||
if candidate.asset.id not in selected_ids:
|
||
selected.append(candidate)
|
||
selected_ids.add(candidate.asset.id)
|
||
added = True
|
||
break
|
||
bucket_idx[b] = idx
|
||
if not added:
|
||
break
|
||
|
||
# 按有效得分(含噪声)降序输出;未传 effective_key 时退回原始得分
|
||
selected.sort(key=effective_key or (lambda r: r.score), reverse=True)
|
||
return selected
|