Files
xiaoxia-saas/packages/domain/narrative_match.py
xiaoxia 7e88440ca9
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
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 (pull_request) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
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 / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m27s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m25s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m11s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 3m40s
AI Code Review / AI Code Review (pull_request) Successful in 6m41s
CI/CD Pipeline / Integration Tests (push) Failing after 7m5s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 7m20s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Style (push) Successful in 8m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Failing after 13m44s
CI/CD Pipeline / Unit Tests (push) Successful in 14m12s
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
feat: #1970 片段级 AI 标签 + 叙事加权匹配 + 冗余核查 (#1981)
feat: #1970 片段级 AI 标签 + 叙事加权匹配

- atom_clip_tagger.py: MediaKit 抽帧 + 豆包视觉 API 识别
- narrative_match.py: AI 标签加权匹配 (2.0 vs 1.0)
- Celery 链式触发 + 批量回填脚本
- migration 081 加 ai_tags 列
- 42 新测试,全量 15796 passed
2026-09-18 21:08:01 +08:00

261 lines
9.2 KiB
Python
Raw Permalink 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.
"""叙事剪辑素材标签匹配 — #1970 PR3 + P2 AI 标签加权.
叙事模式下,选片在现有评分(smart_match / atom_clip_selector)之前先做一层
文案标签匹配:
- 文案 tags 与素材 tag 名归一化后求交集;
- 命中任一标签的素材作为「优先候选池」,未命中的作为普通池;
- 调用方对优先池跑现有 smart_select_assets,数量不足时用普通池补足
(无任何匹配 → 完全降级为现有随机逻辑,行为与改造前一致)。
P2 AI 标签加权(#1970 fragment-level AI tagging):
- 片段级 AI 标签(scene/objects/action)与文案标签做交集时权重 2.0
- 素材级标签(tag_ids 映射名)与文案标签交集时权重 1.0
- 综合得分 = sum(命中权重) / max(可能权重)
- 有 AI 标签的片段命中时优先于仅素材标签命中的片段
纯函数模块:标签 id→名称映射由调用方查 TagModel 后注入,不直接碰 DB。
"""
from __future__ import annotations
from typing import Any, Iterable
# 标签归一化后仍短于此长度的标签不参与匹配(避免「的」「是」这类噪声短词)
MIN_TAG_LEN = 2
# 标签匹配权重
AI_TAG_WEIGHT = 2.0 # AI 标签命中权重
ASSET_TAG_WEIGHT = 1.0 # 素材标签命中权重
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 _extract_ai_tag_names(ai_tags: dict) -> set[str]:
"""从 AI 标签 dict 中提取所有标签名(scene + objects + action).
Args:
ai_tags: 片段级 AI 标签 dict,如 {"scene": [...], "objects": [...], "action": [...], ...}
Returns:
归一化后的标签名集合。
"""
names: set[str] = set()
for key in ("scene", "objects", "action"):
values = ai_tags.get(key)
if isinstance(values, list):
names |= _normalize_tags(values)
return names
def _compute_ai_score(
asset_id: str,
wanted: set[str],
clip_ai_tags_by_asset: dict[str, list[dict]] | None,
) -> float:
"""计算单个素材的 AI 标签加权得分.
对该素材的所有片段 AI 标签,求各片段标签名与文案标签交集的加权总和。
每个片段的命中权重 = 命中数 × AI_TAG_WEIGHT。
最终取所有片段的最高得分(而非累加,避免片段数多的素材不公平占优)。
Args:
asset_id: 素材 ID。
wanted: 归一化后的文案标签集合。
clip_ai_tags_by_asset: {asset_id: [ai_tag_dict, ...]} 每个片段一个。
Returns:
AI 标签加权得分(≥0)。
"""
if not clip_ai_tags_by_asset or not wanted:
return 0.0
clips = clip_ai_tags_by_asset.get(asset_id)
if not clips:
return 0.0
best_score = 0.0
for ai_tags in clips:
if not ai_tags or not isinstance(ai_tags, dict):
continue
ai_names = _extract_ai_tag_names(ai_tags)
hits = ai_names & wanted
score = len(hits) * AI_TAG_WEIGHT
if score > best_score:
best_score = score
return best_score
def match_assets_by_script_tags(
assets: list[Any],
*,
script_tags: Iterable[Any],
tag_names_by_id: dict[str, Any] | None = None,
clip_ai_tags_by_asset: dict[str, list[dict]] | None = None,
) -> tuple[list[Any], list[Any]]:
"""按文案标签把素材拆成「命中池 / 未命中池」,保持输入相对顺序。
P2 加权逻辑:
- AI 标签命中(scene/objects/action ∩ 文案标签)权重 2.0
- 素材标签命中(tag_ids 映射名 ∩ 文案标签)权重 1.0
- 任一权重 > 0 → 命中池,否则 → 未命中池
Args:
assets: 候选素材(domain Asset,需有 id 与 tag_ids)。
script_tags: 文案 tags(字符串数组,名称语义)。
tag_names_by_id: asset_id → 素材标签名列表。
clip_ai_tags_by_asset: #1970 P2 — {asset_id: [ai_tag_dict, ...]}。
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 "")
# P2: AI 标签加权得分
ai_score = _compute_ai_score(asset_id, wanted, clip_ai_tags_by_asset)
# 素材标签得分
names = set(name_index.get(asset_id, set()))
raw_tags = getattr(asset, "tags", None)
if raw_tags:
names |= _normalize_tags(raw_tags)
asset_score = len(names & wanted) * ASSET_TAG_WEIGHT
# 综合得分 > 0 → 命中池
if ai_score > 0 or asset_score > 0:
matched.append(asset)
else:
unmatched.append(asset)
return matched, unmatched
def compute_tag_match_score(
asset_id: str,
*,
script_tags: Iterable[Any],
tag_names_by_id: dict[str, Any] | None = None,
clip_ai_tags_by_asset: dict[str, list[dict]] | None = None,
) -> float:
"""计算单个素材的标签匹配综合得分(0.0 ~ 1.0).
综合得分 = sum(命中权重) / max(可能权重)
- AI 标签每命中一个 +2.0
- 素材标签每命中一个 +1.0
- max_possible = len(wanted) * (AI_TAG_WEIGHT + ASSET_TAG_WEIGHT)
Args:
asset_id: 素材 ID。
script_tags: 文案标签。
tag_names_by_id: 素材标签名索引。
clip_ai_tags_by_asset: AI 标签索引。
Returns:
归一化得分 0.0~1.0。
"""
wanted = _normalize_tags(script_tags)
if not wanted:
return 0.0
# AI 得分
ai_score = _compute_ai_score(asset_id, wanted, clip_ai_tags_by_asset)
# 素材标签得分
name_index = build_asset_tag_name_index(tag_names_by_id or {})
names = name_index.get(asset_id, set())
asset_score = len(names & wanted) * ASSET_TAG_WEIGHT
# 归一化:最大可能得分 = 文案标签数 × (AI权重 + 素材权重)
max_possible = len(wanted) * (AI_TAG_WEIGHT + ASSET_TAG_WEIGHT)
if max_possible <= 0:
return 0.0
return min((ai_score + asset_score) / max_possible, 1.0)
def pick_narrative_assets(
assets: list[Any],
*,
script_tags: Iterable[Any],
tag_names_by_id: dict[str, Any] | None = None,
clip_ai_tags_by_asset: dict[str, list[dict]] | None = None,
limit: int | None = None,
rng: Any = None,
) -> list[Any]:
"""叙事模式选片:标签命中池优先,不足部分从未命中池按现有评分补齐。
本函数只负责「标签优先 + 兜底降级」的顺序编排;评分仍复用
smart_match.smart_select_assets(质量/时长/新鲜度/未使用 + 随机噪声),
不重写评分维度。
P2 增强:有 AI 标签的片段命中时权重更高(2.0 vs 1.0),
命中池内部按综合标签得分排序(AI 标签命中多的排前面)。
Args:
assets: ready 视频素材候选(调用方负责状态/类型过滤)。
script_tags / tag_names_by_id: 见 match_assets_by_script_tags。
clip_ai_tags_by_asset: #1970 P2 — {asset_id: [ai_tag_dict, ...]}。
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,
clip_ai_tags_by_asset=clip_ai_tags_by_asset,
)
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