4fa3e4eb92
CI/CD Pipeline / Check if frontend-only change (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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 40s
CI/CD Pipeline / Build Staging API Image (push) Successful in 45s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m21s
CI/CD Pipeline / Validate - Style (push) Successful in 3m10s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m32s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 4m34s
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 / Deploy Staging (Watchtower auto-deploy) (push) Successful in 38s
CI/CD Pipeline / Integration Tests (push) Successful in 5m34s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m25s
CI/CD Pipeline / Validate - Security (push) Successful in 6m52s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m56s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 3m6s
CI/CD Pipeline / Unit Tests (push) Successful in 9m14s
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 / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy 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>
133 lines
4.9 KiB
Python
133 lines
4.9 KiB
Python
"""叙事剪辑素材标签匹配 — #1970 PR3.
|
|
|
|
叙事模式下,选片在现有评分(smart_match / atom_clip_selector)之前先做一层
|
|
文案标签匹配:
|
|
|
|
- 文案 tags 与素材 tag 名归一化后求交集;
|
|
- 命中任一标签的素材作为「优先候选池」,未命中的作为普通池;
|
|
- 调用方对优先池跑现有 smart_select_assets,数量不足时用普通池补足
|
|
(无任何匹配 → 完全降级为现有随机逻辑,行为与改造前一致)。
|
|
|
|
纯函数模块:标签 id→名称映射由调用方查 TagModel 后注入,不直接碰 DB。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Iterable
|
|
|
|
# 标签归一化后仍短于此长度的标签不参与匹配(避免「的」「是」这类噪声短词)
|
|
MIN_TAG_LEN = 2
|
|
|
|
|
|
def normalize_tag(tag: Any) -> str:
|
|
"""标签归一化:去空白、小写。数字/英文统一小写,中文不受影响。"""
|
|
if tag is None:
|
|
return ""
|
|
return str(tag).strip().lower()
|
|
|
|
|
|
def _normalize_tags(tags: Iterable[Any]) -> set[str]:
|
|
out: set[str] = set()
|
|
for t in tags or []:
|
|
norm = normalize_tag(t)
|
|
if len(norm) >= MIN_TAG_LEN:
|
|
out.add(norm)
|
|
return out
|
|
|
|
|
|
def build_asset_tag_name_index(tag_names_by_id: dict[str, Any]) -> dict[str, set[str]]:
|
|
"""构造 asset_id → 归一化标签名集合 的索引。
|
|
|
|
Args:
|
|
tag_names_by_id: {asset_id: [标签名或标签id, ...]},允许混入 None/空值
|
|
"""
|
|
index: dict[str, set[str]] = {}
|
|
for asset_id, names in (tag_names_by_id or {}).items():
|
|
index[asset_id] = _normalize_tags(names)
|
|
return index
|
|
|
|
|
|
def match_assets_by_script_tags(
|
|
assets: list[Any],
|
|
*,
|
|
script_tags: Iterable[Any],
|
|
tag_names_by_id: dict[str, Any] | None = None,
|
|
) -> tuple[list[Any], list[Any]]:
|
|
"""按文案标签把素材拆成「命中池 / 未命中池」,保持输入相对顺序。
|
|
|
|
Args:
|
|
assets: 候选素材(domain Asset,需有 id 与 tag_ids)。
|
|
script_tags: 文案 tags(字符串数组,名称语义)。
|
|
tag_names_by_id: asset_id → 素材标签名列表;素材只有 tag_ids 时由调用方
|
|
查 TagModel 名称后传入。为空则视为无素材命中。
|
|
|
|
Returns:
|
|
(matched, unmatched):命中任一文案标签的素材 / 其余素材。
|
|
文案无有效标签时 matched 为空(调用方直接走随机逻辑)。
|
|
"""
|
|
wanted = _normalize_tags(script_tags)
|
|
if not wanted:
|
|
return [], list(assets)
|
|
|
|
name_index = build_asset_tag_name_index(tag_names_by_id or {})
|
|
matched: list[Any] = []
|
|
unmatched: list[Any] = []
|
|
for asset in assets:
|
|
asset_id = str(getattr(asset, "id", "") or "")
|
|
names = set(name_index.get(asset_id, set()))
|
|
# 兼容素材自身带字符串 tags(旧链路/测试替身)
|
|
raw_tags = getattr(asset, "tags", None)
|
|
if raw_tags:
|
|
names |= _normalize_tags(raw_tags)
|
|
if names & wanted:
|
|
matched.append(asset)
|
|
else:
|
|
unmatched.append(asset)
|
|
return matched, unmatched
|
|
|
|
|
|
def pick_narrative_assets(
|
|
assets: list[Any],
|
|
*,
|
|
script_tags: Iterable[Any],
|
|
tag_names_by_id: dict[str, Any] | None = None,
|
|
limit: int | None = None,
|
|
rng: Any = None,
|
|
) -> list[Any]:
|
|
"""叙事模式选片:标签命中池优先,不足部分从未命中池按现有评分补齐。
|
|
|
|
本函数只负责「标签优先 + 兜底降级」的顺序编排;评分仍复用
|
|
smart_match.smart_select_assets(质量/时长/新鲜度/未使用 + 随机噪声),
|
|
不重写评分维度。
|
|
|
|
Args:
|
|
assets: ready 视频素材候选(调用方负责状态/类型过滤)。
|
|
script_tags / tag_names_by_id: 见 match_assets_by_script_tags。
|
|
limit: 需要的素材数量;None 表示全部(命中池 + 全部未命中池)。
|
|
rng: 注入 smart_select_assets 的随机源(可复现)。
|
|
|
|
Returns:
|
|
选中的素材列表。无任何标签命中时等价于对全量跑 smart_select_assets。
|
|
"""
|
|
from packages.domain.smart_match import smart_select_assets
|
|
|
|
matched, unmatched = match_assets_by_script_tags(
|
|
assets,
|
|
script_tags=script_tags,
|
|
tag_names_by_id=tag_names_by_id,
|
|
)
|
|
|
|
need = limit if (limit is not None and limit > 0) else None
|
|
|
|
if not matched:
|
|
# 完全降级:与改造前随机混剪同一逻辑
|
|
return [r.asset for r in smart_select_assets(assets, kind="video", limit=need, rng=rng)]
|
|
|
|
picked = [r.asset for r in smart_select_assets(matched, kind="video", limit=need, rng=rng)]
|
|
if need is not None and len(picked) < need and unmatched:
|
|
rest_need = need - len(picked)
|
|
picked.extend(r.asset for r in smart_select_assets(unmatched, kind="video", limit=rest_need, rng=rng))
|
|
elif need is None:
|
|
picked.extend(r.asset for r in smart_select_assets(unmatched, kind="video", rng=rng))
|
|
return picked
|