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 标签 + 叙事加权匹配 - atom_clip_tagger.py: MediaKit 抽帧 + 豆包视觉 API 识别 - narrative_match.py: AI 标签加权匹配 (2.0 vs 1.0) - Celery 链式触发 + 批量回填脚本 - migration 081 加 ai_tags 列 - 42 新测试,全量 15796 passed
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""#1970 P2 叙事匹配 AI 标签加权测试。
|
||
|
||
测试范围:
|
||
- AI 标签命中时权重 2.0
|
||
- 无 AI 标签时降级到素材标签权重 1.0
|
||
- 混合场景(部分素材有 AI 标签,部分只有素材标签)
|
||
- compute_tag_match_score 归一化得分
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import datetime as dt
|
||
import random
|
||
from dataclasses import dataclass, field
|
||
|
||
import pytest
|
||
|
||
from packages.domain.narrative_match import (
|
||
AI_TAG_WEIGHT,
|
||
ASSET_TAG_WEIGHT,
|
||
_compute_ai_score,
|
||
_extract_ai_tag_names,
|
||
compute_tag_match_score,
|
||
match_assets_by_script_tags,
|
||
pick_narrative_assets,
|
||
)
|
||
|
||
|
||
@dataclass
|
||
class FakeAsset:
|
||
id: str
|
||
tag_ids: list[str] = field(default_factory=list)
|
||
tags: list[str] = field(default_factory=list)
|
||
status: str = "ready"
|
||
file_type: str = "video"
|
||
duration: float = 10.0
|
||
quality_score: float | None = None
|
||
created_at: object = None
|
||
metadata: dict = field(default_factory=dict)
|
||
|
||
|
||
def _make_old_dt():
|
||
return dt.datetime(2020, 1, 1, tzinfo=dt.UTC)
|
||
|
||
|
||
# ── _extract_ai_tag_names ─────────────────────────────────────────────────
|
||
|
||
|
||
class TestExtractAiTagNames:
|
||
def test_extracts_all_keys(self):
|
||
ai_tags = {
|
||
"scene": ["工厂", "车间"],
|
||
"objects": ["产品"],
|
||
"action": ["演示"],
|
||
"shot": "特写", # shot 不参与标签匹配
|
||
"has_text": False,
|
||
}
|
||
names = _extract_ai_tag_names(ai_tags)
|
||
assert names == {"工厂", "车间", "产品", "演示"}
|
||
|
||
def test_empty_dict(self):
|
||
assert _extract_ai_tag_names({}) == set()
|
||
|
||
def test_none_values(self):
|
||
ai_tags = {"scene": None, "objects": None, "action": None}
|
||
assert _extract_ai_tag_names(ai_tags) == set()
|
||
|
||
def test_case_insensitive(self):
|
||
ai_tags = {"scene": ["Factory"], "objects": [], "action": []}
|
||
names = _extract_ai_tag_names(ai_tags)
|
||
assert "factory" in names
|
||
|
||
|
||
# ── _compute_ai_score ─────────────────────────────────────────────────────
|
||
|
||
|
||
class TestComputeAiScore:
|
||
def test_single_clip_hit(self):
|
||
wanted = {"工厂", "演示"}
|
||
clips = [{"scene": ["工厂"], "objects": [], "action": ["演示"]}]
|
||
score = _compute_ai_score("a1", wanted, {"a1": clips})
|
||
# 命中 2 个 × 2.0 = 4.0
|
||
assert score == 2 * AI_TAG_WEIGHT
|
||
|
||
def test_multiple_clips_takes_best(self):
|
||
wanted = {"工厂", "演示"}
|
||
clips = [
|
||
{"scene": ["工厂"], "objects": [], "action": []}, # 1 hit = 2.0
|
||
{"scene": ["工厂"], "objects": [], "action": ["演示"]}, # 2 hits = 4.0
|
||
]
|
||
score = _compute_ai_score("a1", wanted, {"a1": clips})
|
||
assert score == 2 * AI_TAG_WEIGHT # best = 2 hits
|
||
|
||
def test_no_match(self):
|
||
wanted = {"美食"}
|
||
clips = [{"scene": ["工厂"], "objects": [], "action": ["演示"]}]
|
||
score = _compute_ai_score("a1", wanted, {"a1": clips})
|
||
assert score == 0.0
|
||
|
||
def test_no_clips_for_asset(self):
|
||
wanted = {"工厂"}
|
||
assert _compute_ai_score("a1", wanted, {}) == 0.0
|
||
assert _compute_ai_score("a1", wanted, None) == 0.0
|
||
|
||
def test_empty_wanted(self):
|
||
clips = [{"scene": ["工厂"], "objects": [], "action": []}]
|
||
assert _compute_ai_score("a1", set(), {"a1": clips}) == 0.0
|
||
|
||
|
||
# ── match_assets_by_script_tags with AI tags ──────────────────────────────
|
||
|
||
|
||
class TestMatchWithAiTags:
|
||
def test_ai_tag_hit_puts_in_matched(self):
|
||
"""有 AI 标签命中 → 进入命中池."""
|
||
assets = [FakeAsset("a1", created_at=_make_old_dt())]
|
||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||
|
||
matched, unmatched = match_assets_by_script_tags(
|
||
assets,
|
||
script_tags=["工厂"],
|
||
clip_ai_tags_by_asset=clip_ai_tags,
|
||
)
|
||
|
||
assert [a.id for a in matched] == ["a1"]
|
||
assert unmatched == []
|
||
|
||
def test_ai_tag_no_match_puts_in_unmatched(self):
|
||
"""AI 标签未命中 → 进入未命中池."""
|
||
assets = [FakeAsset("a1", created_at=_make_old_dt())]
|
||
clip_ai_tags = {"a1": [{"scene": ["办公室"], "objects": [], "action": []}]}
|
||
|
||
matched, unmatched = match_assets_by_script_tags(
|
||
assets,
|
||
script_tags=["工厂"],
|
||
clip_ai_tags_by_asset=clip_ai_tags,
|
||
)
|
||
|
||
assert matched == []
|
||
assert [a.id for a in unmatched] == ["a1"]
|
||
|
||
def test_asset_tag_still_works_without_ai_tags(self):
|
||
"""无 AI 标签时,素材标签仍按权重 1.0 匹配."""
|
||
assets = [FakeAsset("a1", tags=["工厂"], created_at=_make_old_dt())]
|
||
|
||
matched, unmatched = match_assets_by_script_tags(
|
||
assets,
|
||
script_tags=["工厂"],
|
||
)
|
||
|
||
assert [a.id for a in matched] == ["a1"]
|
||
|
||
def test_mixed_ai_and_asset_tags(self):
|
||
"""混合场景:一个素材有 AI 标签,另一个只有素材标签."""
|
||
assets = [
|
||
FakeAsset("a1", created_at=_make_old_dt()), # AI 标签命中
|
||
FakeAsset("a2", tags=["工厂"], created_at=_make_old_dt()), # 素材标签命中
|
||
FakeAsset("a3", tags=["美食"], created_at=_make_old_dt()), # 无命中
|
||
]
|
||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||
|
||
matched, unmatched = match_assets_by_script_tags(
|
||
assets,
|
||
script_tags=["工厂"],
|
||
clip_ai_tags_by_asset=clip_ai_tags,
|
||
)
|
||
|
||
assert {a.id for a in matched} == {"a1", "a2"}
|
||
assert [a.id for a in unmatched] == ["a3"]
|
||
|
||
def test_ai_tag_and_asset_tag_both_hit(self):
|
||
"""同一素材 AI 标签和素材标签都命中 → 仍在命中池."""
|
||
assets = [FakeAsset("a1", tags=["工厂"], created_at=_make_old_dt())]
|
||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||
|
||
matched, unmatched = match_assets_by_script_tags(
|
||
assets,
|
||
script_tags=["工厂"],
|
||
tag_names_by_id={"a1": ["工厂"]},
|
||
clip_ai_tags_by_asset=clip_ai_tags,
|
||
)
|
||
|
||
assert [a.id for a in matched] == ["a1"]
|
||
|
||
|
||
# ── compute_tag_match_score ───────────────────────────────────────────────
|
||
|
||
|
||
class TestComputeTagMatchScore:
|
||
def test_ai_only_score(self):
|
||
"""仅 AI 标签命中."""
|
||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": ["演示"]}]}
|
||
score = compute_tag_match_score(
|
||
"a1",
|
||
script_tags=["工厂", "演示"],
|
||
clip_ai_tags_by_asset=clip_ai_tags,
|
||
)
|
||
# AI: 2 hits × 2.0 = 4.0; asset: 0; max = 2 × 3.0 = 6.0
|
||
assert abs(score - 4.0 / 6.0) < 0.01
|
||
|
||
def test_asset_only_score(self):
|
||
"""仅素材标签命中."""
|
||
score = compute_tag_match_score(
|
||
"a1",
|
||
script_tags=["工厂", "演示"],
|
||
tag_names_by_id={"a1": ["工厂"]},
|
||
)
|
||
# AI: 0; asset: 1 hit × 1.0 = 1.0; max = 2 × 3.0 = 6.0
|
||
assert abs(score - 1.0 / 6.0) < 0.01
|
||
|
||
def test_both_ai_and_asset_score(self):
|
||
"""AI 标签 + 素材标签同时命中."""
|
||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||
score = compute_tag_match_score(
|
||
"a1",
|
||
script_tags=["工厂", "演示"],
|
||
tag_names_by_id={"a1": ["工厂"]},
|
||
clip_ai_tags_by_asset=clip_ai_tags,
|
||
)
|
||
# AI: 1 hit × 2.0 = 2.0; asset: 1 hit × 1.0 = 1.0; max = 2 × 3.0 = 6.0
|
||
assert abs(score - 3.0 / 6.0) < 0.01
|
||
|
||
def test_no_match_score_zero(self):
|
||
"""无命中 → 得分 0."""
|
||
score = compute_tag_match_score(
|
||
"a1",
|
||
script_tags=["工厂"],
|
||
tag_names_by_id={"a1": ["美食"]},
|
||
)
|
||
assert score == 0.0
|
||
|
||
def test_full_match_score_one(self):
|
||
"""全命中 → 得分接近 1.0."""
|
||
clip_ai_tags = {"a1": [{"scene": ["工厂"], "objects": ["产品"], "action": ["演示"]}]}
|
||
score = compute_tag_match_score(
|
||
"a1",
|
||
script_tags=["工厂", "产品", "演示"],
|
||
clip_ai_tags_by_asset=clip_ai_tags,
|
||
)
|
||
# AI: 3 hits × 2.0 = 6.0; max = 3 × 3.0 = 9.0 → 6/9 = 0.667
|
||
# 注意:仅 AI 标签命中不可能达到 1.0(因为 max 包含素材权重)
|
||
assert score > 0.5
|
||
|
||
def test_empty_script_tags(self):
|
||
"""空文案标签 → 得分 0."""
|
||
assert compute_tag_match_score("a1", script_tags=[]) == 0.0
|
||
|
||
|
||
# ── pick_narrative_assets with AI tags ────────────────────────────────────
|
||
|
||
|
||
class TestPickNarrativeWithAiTags:
|
||
def _assets(self):
|
||
old = _make_old_dt()
|
||
return [
|
||
FakeAsset("ai_match", created_at=old), # AI 标签命中
|
||
FakeAsset("asset_match", tags=["工厂"], created_at=old), # 素材标签命中
|
||
FakeAsset("no_match", tags=["美食"], created_at=old), # 无命中
|
||
]
|
||
|
||
def test_ai_match_prioritized(self):
|
||
"""AI 标签命中的素材进入命中池."""
|
||
clip_ai_tags = {"ai_match": [{"scene": ["工厂"], "objects": [], "action": []}]}
|
||
|
||
picked = pick_narrative_assets(
|
||
self._assets(),
|
||
script_tags=["工厂"],
|
||
clip_ai_tags_by_asset=clip_ai_tags,
|
||
limit=2,
|
||
rng=random.Random(0),
|
||
)
|
||
|
||
ids = {a.id for a in picked}
|
||
assert "ai_match" in ids
|
||
assert "asset_match" in ids
|
||
|
||
def test_fallback_when_no_ai_match(self):
|
||
"""AI 标签和素材标签都未命中 → 降级."""
|
||
clip_ai_tags = {"ai_match": [{"scene": ["办公室"], "objects": [], "action": []}]}
|
||
|
||
picked = pick_narrative_assets(
|
||
self._assets(),
|
||
script_tags=["不存在"],
|
||
clip_ai_tags_by_asset=clip_ai_tags,
|
||
limit=2,
|
||
rng=random.Random(0),
|
||
)
|
||
|
||
assert len(picked) == 2 # 从全量中选取
|
||
|
||
def test_backward_compat_without_ai_tags(self):
|
||
"""不传 clip_ai_tags_by_asset 时行为与之前完全一致."""
|
||
picked = pick_narrative_assets(
|
||
self._assets(),
|
||
script_tags=["工厂"],
|
||
limit=2,
|
||
rng=random.Random(0),
|
||
)
|
||
|
||
# 仅素材标签匹配
|
||
ids = {a.id for a in picked}
|
||
assert "asset_match" in ids
|
||
|
||
|
||
if __name__ == "__main__":
|
||
pytest.main([__file__, "-q"])
|