Files
xiaoxia 2c5600cfc9
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 1s
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 6s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 3m30s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m58s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 35s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m49s
CI/CD Pipeline / Validate - Style (push) Successful in 6m35s
CI/CD Pipeline / Integration Tests (push) Successful in 6m37s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m58s
CI/CD Pipeline / Validate - Security (push) Successful in 8m18s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m53s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m33s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m35s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 14m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (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
feat: 素材选取评分注入随机噪声,降低成片查重率 (#1620)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-02 01:40:34 +08:00

215 lines
7.3 KiB
Python
Executable File

"""统一智能选素材算法 — 合并 _helpers / generation_tasks / auto_clip_service 的重叠逻辑。
设计目标:
- 单一入口,替代 3 套分散的选素材代码
- 多维度加权评分:质量分 + 时长均衡 + 新鲜度 + 未使用偏好
- 多样性保障:按时长分桶(短/中/长)均衡选取,避免同质化
- 可扩展:后续接入 AI 模型时只需替换 score_asset()
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from datetime import datetime, timezone
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 _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,
) -> tuple[float, dict[str, float]]:
"""为单个素材计算综合得分(0-100)。
维度权重:
- quality_score (40%):素材质量分(0-100),无质量分按 50 计
- duration_fitness (30%):时长适配度,5-30s 为最优区间
- recency (20%):新鲜度,30 天内衰减
- unused_bonus (10%):未被使用过的素材加分
Returns:
(total_score, breakdown_dict)
"""
if now is None:
now = datetime.now(timezone.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.4
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.3
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=timezone.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.2
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.1
breakdown["unused"] = round(unused_component, 2)
total = quality_component + duration_component + recency_component + unused_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,
) -> list[SmartMatchResult]:
"""从素材列表中智能选取素材。
Args:
assets: 候选素材列表(Asset 实体)
limit: 最大返回数量,None 表示不限制
kind: 按文件类型过滤(video/image/audio),None 表示不过滤
now: 当前时间(用于测试注入)
Returns:
按得分降序排列的 SmartMatchResult 列表
"""
# 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)
scored.append(SmartMatchResult(asset=a, score=total, breakdown=breakdown))
# Step 4: 按得分降序排序
scored.sort(key=lambda r: r.score, reverse=True)
# Step 5: 多样性保障 — 时长分桶均衡选取
if limit and limit > 0 and len(scored) > limit:
scored = _diversity_select(scored, limit)
elif limit and limit > 0:
scored = scored[:limit]
return scored
def _diversity_select(scored: list[SmartMatchResult], limit: int) -> list[SmartMatchResult]:
"""从已排序的候选中按分桶均衡选取,避免全选中同一时长档。
策略:轮流从 short/medium/long 桶中按得分顺序取,直到凑满 limit。
"""
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
# 按原始得分降序输出
selected.sort(key=lambda r: r.score, reverse=True)
return selected