Files
xiaoxia-saas/packages/domain/smart_match.py
T
xiaoxia 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
feat(#2035): 语义标签增强 + 质量评分自动计算 + atom_clip caption/embedding (#2036)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-25 12:44:37 +08:00

308 lines
12 KiB
Python
Executable File
Raw 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.
"""统一智能选素材算法 — 合并 _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