Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c5600cfc9 |
@@ -48,7 +48,7 @@ from packages.adapters.sqlalchemy_impl.template_repository import (
|
||||
SQLAlchemyTemplateRepository,
|
||||
)
|
||||
from packages.domain.plan_generator_utils import _calc_random_start_time
|
||||
from packages.domain.smart_match import score_asset
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
|
||||
from packages.shared.mediakit_client import get_mediakit_client
|
||||
|
||||
from .dependencies import get_draft_plan_id, get_editor_services
|
||||
@@ -744,14 +744,15 @@ def create_clips_from_assets_editor(
|
||||
clip_duration = 0.0
|
||||
start_time: float | None = None
|
||||
# 动态按使用次数排序:优先选使用最少的素材,同次数随机打散
|
||||
asset_use_counts = {
|
||||
aid: len(used_segments.get(aid, []))
|
||||
for aid in asset_ids
|
||||
}
|
||||
asset_use_counts = {aid: len(used_segments.get(aid, [])) for aid in asset_ids}
|
||||
# 排序键:smart_match 评分(注入随机噪声)→ 使用次数 → 纯随机。
|
||||
# 噪声让得分接近的素材排名每次浮动,避免同一批素材反复选出相同组合,
|
||||
# 从素材组合层面降低成片查重率;分差 > SCORE_RANDOM_NOISE_MAX 时排名稳定,
|
||||
# 质量差距显著的素材仍保持优先级。
|
||||
sorted_candidates = sorted(
|
||||
asset_ids,
|
||||
key=lambda aid: (
|
||||
-asset_smart_scores.get(aid, 0.0),
|
||||
-(asset_smart_scores.get(aid, 0.0) + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX)),
|
||||
asset_use_counts.get(aid, 0),
|
||||
random.random(),
|
||||
),
|
||||
@@ -847,15 +848,15 @@ def create_clips_from_assets_editor(
|
||||
duplicate_warning = f"查重率 {dup_rate:.1f}% 超过50%,建议更换素材或模板"
|
||||
logger.warning(
|
||||
"from-assets 成片查重率超标: plan_id=%s dup_rate=%.1f%%",
|
||||
plan_id, dup_rate,
|
||||
plan_id,
|
||||
dup_rate,
|
||||
)
|
||||
|
||||
# 7. 素材耗尽提示
|
||||
exhaustion_warning = None
|
||||
if all_assets_exhausted and created_count < len(segments):
|
||||
exhaustion_warning = (
|
||||
"素材可切区间不足,部分片段使用了复用素材。"
|
||||
"建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
|
||||
"素材可切区间不足,部分片段使用了复用素材。" "建议:1) 补充更多素材到素材库 2) 使用不同的素材组合生成"
|
||||
)
|
||||
|
||||
# 8. 立即返回响应
|
||||
@@ -1022,9 +1023,11 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
if cid != clip_id_inner and cid not in updated_clip_ids:
|
||||
segs.append((c.start_time, c.start_time + c.duration))
|
||||
segs.extend(updated_segments.get(asset_id_inner, []))
|
||||
|
||||
# 并入历史已用区间
|
||||
def _norm(segs_in):
|
||||
return {(round(float(a), 3), round(float(b), 3)) for a, b in segs_in}
|
||||
|
||||
return list(_norm(segs) | _norm(historical_segments.get(asset_id_inner, [])))
|
||||
|
||||
# 优先使用 SceneChange 策略
|
||||
@@ -1035,7 +1038,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
scene_segments = _build_scene_segments(scene_changes, asset_total)
|
||||
logger.info(
|
||||
"后台任务: 素材场景检测完成: asset_id=%s scenes=%d",
|
||||
asset_id, len(scene_segments),
|
||||
asset_id,
|
||||
len(scene_segments),
|
||||
)
|
||||
|
||||
# SceneChange 未获得有效结果 → 尝试 analyze_videos 作为 fallback
|
||||
@@ -1047,7 +1051,8 @@ def _update_mediakit_recommendations_async( # pragma: no cover
|
||||
scene_segments = [(rec_start, asset_total)]
|
||||
logger.info(
|
||||
"后台任务: 使用 analyze_videos fallback: asset_id=%s start=%.2f",
|
||||
asset_id, rec_start,
|
||||
asset_id,
|
||||
rec_start,
|
||||
)
|
||||
|
||||
if not scene_segments:
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Any, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -32,7 +33,7 @@ from packages.domain.plan_generator_utils import (
|
||||
generate_default_clips,
|
||||
map_clip_types_for_mode,
|
||||
)
|
||||
from packages.domain.smart_match import score_asset
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset
|
||||
from packages.domain.template_clip_config import TemplateClipConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -235,9 +236,12 @@ class PlanGeneratorService:
|
||||
)
|
||||
|
||||
def _sort_assets_by_smart_score(self, asset_ids: List[str]) -> List[str]:
|
||||
"""按 smart_match 综合评分降序排列素材 ID。
|
||||
"""按 smart_match 综合评分降序排列素材 ID(注入随机噪声)。
|
||||
|
||||
评分高的素材(质量好、时长合适、新鲜、使用次数少)排在前面。
|
||||
评分高的素材(质量好、时长合适、新鲜、使用次数少)倾向排在前面;
|
||||
排序时给每个素材的得分注入 0~SCORE_RANDOM_NOISE_MAX 的随机噪声,
|
||||
使得分接近的素材排名每次浮动,避免一键生成反复选出相同素材组合,
|
||||
从素材组合层面降低成片查重率。分差大于噪声上限时排名保持稳定。
|
||||
"""
|
||||
scored: list[tuple[str, float]] = []
|
||||
for asset_id in asset_ids:
|
||||
@@ -247,8 +251,11 @@ class PlanGeneratorService:
|
||||
scored.append((asset_id, score))
|
||||
else:
|
||||
scored.append((asset_id, 0.0))
|
||||
# 按评分降序排列
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
# 评分 + 随机噪声后按降序排列
|
||||
scored.sort(
|
||||
key=lambda x: x[1] + random.uniform(0.0, SCORE_RANDOM_NOISE_MAX),
|
||||
reverse=True,
|
||||
)
|
||||
return [aid for aid, _ in scored]
|
||||
|
||||
def _fetch_asset_durations(self, asset_ids: List[str]) -> dict[str, float]:
|
||||
|
||||
@@ -14,6 +14,13 @@ 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:
|
||||
|
||||
@@ -38,6 +38,28 @@ def _segments(count: int, dur_min: float = 3.0, dur_max: float = 5.0):
|
||||
return [(i, dur_min, dur_max) for i in range(count)]
|
||||
|
||||
|
||||
def _patch_zero_noise():
|
||||
"""消除 clips.py 排序随机噪声,用于确定性断言(如均衡分配)。
|
||||
|
||||
排序噪声(random.uniform(0, SCORE_RANDOM_NOISE_MAX))返回 0;
|
||||
其他 uniform 调用(片段时长随机)委托给独立 Random 实例,行为不变。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
from app.api.routes.templates_editor import clips as clips_module
|
||||
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
|
||||
return patch.object(clips_module.random, "uniform", _fake_uniform)
|
||||
|
||||
|
||||
def _patch_segments(segments=None):
|
||||
return patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
@@ -130,7 +152,8 @@ class TestEditorClipsBySegments:
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"], required_clips_count=2)
|
||||
|
||||
with _patch_segments(DEFAULT_SEGMENTS):
|
||||
# 均衡分配由 use_count 贪心保证,消除排序噪声后确定性断言
|
||||
with _patch_zero_noise(), _patch_segments(DEFAULT_SEGMENTS):
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
@@ -795,7 +818,8 @@ class TestClipsFromAssetsInvalidIds:
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", "a2"]) # type: ignore[list-item]
|
||||
|
||||
with _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)):
|
||||
# 消除排序噪声,确定性断言两条合法素材各被使用
|
||||
with _patch_zero_noise(), _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)):
|
||||
result = create_clips_from_assets_editor(
|
||||
template_id="tpl-001",
|
||||
body=body,
|
||||
|
||||
@@ -306,6 +306,27 @@ class TestGetTemplateSegments:
|
||||
# ── from-assets 端点集成测试 ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _patch_zero_noise():
|
||||
"""消除 clips.py 排序随机噪声(SCORE_RANDOM_NOISE_MAX 噪声返回 0)。
|
||||
|
||||
用于均衡分配等确定性断言;其他 uniform 调用(片段时长随机)行为不变。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
from app.api.routes.templates_editor import clips as clips_module
|
||||
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
|
||||
return patch.object(clips_module.random, "uniform", _fake_uniform)
|
||||
|
||||
|
||||
def _make_auth_user():
|
||||
auth = MagicMock()
|
||||
auth.user.id = "user-001"
|
||||
@@ -470,16 +491,18 @@ class TestFromAssetsByTemplateSegments:
|
||||
mock_asset_repo.get.side_effect = get_asset
|
||||
|
||||
body = ClipsFromAssetsRequest(asset_ids=["a1", "a2"])
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
# 消除排序噪声,确定性断言贪心均衡分配
|
||||
with _patch_zero_noise():
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id="plan-1",
|
||||
services=(mock_tpl_svc, mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
|
||||
clips_data = _get_clips_data(mock_plan_svc)
|
||||
asset_ids = [c["asset_id"] for c in clips_data]
|
||||
|
||||
@@ -23,7 +23,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
import pytest
|
||||
|
||||
from packages.domain.smart_match import score_asset, smart_select_assets
|
||||
from packages.domain.smart_match import SCORE_RANDOM_NOISE_MAX, score_asset, smart_select_assets
|
||||
|
||||
# ── 辅助工厂 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -158,6 +158,38 @@ def _make_auth_user():
|
||||
return auth
|
||||
|
||||
|
||||
def _make_zero_noise_patcher(module):
|
||||
"""构造 patch(module.random.uniform):噪声调用(上界=SCORE_RANDOM_NOISE_MAX)返回 0。
|
||||
|
||||
其他 uniform 调用(如片段时长随机)委托给一个独立的 Random 实例,
|
||||
避免递归回已 patch 的全局函数。
|
||||
"""
|
||||
import random as _stdlib_random
|
||||
|
||||
_fallback = _stdlib_random.Random()
|
||||
|
||||
def _fake_uniform(a, b):
|
||||
if b == SCORE_RANDOM_NOISE_MAX:
|
||||
return 0.0
|
||||
return _fallback.uniform(a, b)
|
||||
|
||||
return patch.object(module.random, "uniform", _fake_uniform)
|
||||
|
||||
|
||||
def _patch_zero_noise_clips():
|
||||
"""消除 clips.py 排序噪声,其他 uniform 调用不受影响。"""
|
||||
from app.api.routes.templates_editor import clips as clips_module
|
||||
|
||||
return _make_zero_noise_patcher(clips_module)
|
||||
|
||||
|
||||
def _patch_zero_noise_plan_service():
|
||||
"""消除 plan_generator_service.py 排序噪声,其他 uniform 调用不受影响。"""
|
||||
from app.services import plan_generator_service as svc_module
|
||||
|
||||
return _make_zero_noise_patcher(svc_module)
|
||||
|
||||
|
||||
class TestFromAssetsSmartMatchIntegration:
|
||||
"""验证 clips.py 中 sorted_candidates 使用 smart_match 评分。"""
|
||||
|
||||
@@ -180,6 +212,7 @@ class TestFromAssetsSmartMatchIntegration:
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
|
||||
|
||||
with (
|
||||
_patch_zero_noise_clips(),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
return_value=segments,
|
||||
@@ -219,6 +252,63 @@ class TestFromAssetsSmartMatchIntegration:
|
||||
first_clip_asset == "a_fresh"
|
||||
), f"第一个片段应分配给 smart_match 分更高的 a_fresh,实际是 {first_clip_asset}"
|
||||
|
||||
def test_score_noise_causes_varied_selection(self):
|
||||
"""得分接近(差距 < SCORE_RANDOM_NOISE_MAX)的素材,多次生成的素材组合应有变化。
|
||||
|
||||
两条同等质量/时长/新鲜度的素材(use_count 相同),smart_match 得分一致,
|
||||
噪声让两者的相对排名随机浮动,多次调用首个片段的素材分布应两者都出现。
|
||||
"""
|
||||
from app.api.routes.templates_editor.clips import create_clips_from_assets_editor
|
||||
from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest
|
||||
|
||||
def _get_asset(aid):
|
||||
return _make_mock_asset_for_clips(aid, 30.0, 0)
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
|
||||
segments = [(0, 3.0, 5.0), (1, 3.0, 5.0)]
|
||||
|
||||
first_assets: set[str] = set()
|
||||
for _ in range(30):
|
||||
mock_plan_svc = MagicMock()
|
||||
mock_plan_svc.replace_all_clips_transactional = MagicMock(return_value=2)
|
||||
with (
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips._get_template_segments",
|
||||
return_value=segments,
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.get_used_segments",
|
||||
return_value={},
|
||||
),
|
||||
patch(
|
||||
"app.api.routes.templates_editor.clips.record_used_segments",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
body = ClipsFromAssetsRequest(
|
||||
asset_ids=["a_x", "a_y"],
|
||||
required_clips_count=2,
|
||||
)
|
||||
create_clips_from_assets_editor(
|
||||
template_id="tmpl-1",
|
||||
body=body,
|
||||
background_tasks=MagicMock(),
|
||||
plan_id=f"plan-noise-{len(first_assets)}-{_}",
|
||||
services=(MagicMock(), mock_plan_svc),
|
||||
asset_repo=mock_asset_repo,
|
||||
db=MagicMock(),
|
||||
current_user=_make_auth_user(),
|
||||
)
|
||||
clips_data = mock_plan_svc.replace_all_clips_transactional.call_args.args[1]
|
||||
first_assets.add(clips_data[0]["asset_id"])
|
||||
|
||||
assert first_assets == {
|
||||
"a_x",
|
||||
"a_y",
|
||||
}, f"噪声应使两条等分素材的排名浮动,30 次调用首个片段应覆盖两者,实际 {first_assets}"
|
||||
|
||||
|
||||
# ── 一键生成路径集成测试 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -247,7 +337,8 @@ class TestPlanGeneratorSmartMatchIntegration:
|
||||
db = MagicMock()
|
||||
svc = PlanGeneratorService(db, asset_repo=mock_asset_repo)
|
||||
|
||||
sorted_ids = svc._sort_assets_by_smart_score(["high_use", "low_use", "mid_use"])
|
||||
with _patch_zero_noise_plan_service():
|
||||
sorted_ids = svc._sort_assets_by_smart_score(["high_use", "low_use", "mid_use"])
|
||||
|
||||
# low_use (0次) 应排第一,high_use (10次) 应排最后
|
||||
assert sorted_ids[0] == "low_use"
|
||||
@@ -284,7 +375,10 @@ class TestPlanGeneratorSmartMatchIntegration:
|
||||
EditPlanClip(id="c2", plan_id="p1", clip_type="main", duration=5.0, order=1),
|
||||
]
|
||||
|
||||
with patch("app.services.plan_generator_service.distribute_assets") as mock_dist:
|
||||
with (
|
||||
_patch_zero_noise_plan_service(),
|
||||
patch("app.services.plan_generator_service.distribute_assets") as mock_dist,
|
||||
):
|
||||
svc._distribute_assets(
|
||||
clips,
|
||||
["old_asset", "new_asset"],
|
||||
@@ -322,3 +416,60 @@ class TestPlanGeneratorSmartMatchIntegration:
|
||||
)
|
||||
# random_selection=True 时不应调用 asset_repo.get(不执行排序)
|
||||
mock_asset_repo.get.assert_not_called()
|
||||
|
||||
|
||||
class TestPlanGeneratorScoreNoise:
|
||||
"""验证一键生成路径的评分排序注入了随机噪声。"""
|
||||
|
||||
def test_equal_scores_produce_varied_order(self):
|
||||
"""两条 smart_match 得分相同的素材,多次排序的首位应覆盖两者。"""
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
def _get_asset(aid):
|
||||
asset = MagicMock()
|
||||
asset.id = aid
|
||||
asset.duration = 15.0
|
||||
asset.quality_score = None
|
||||
asset.created_at = None
|
||||
asset.metadata = {"generation_use_count": 0}
|
||||
return asset
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
svc = PlanGeneratorService(MagicMock(), asset_repo=mock_asset_repo)
|
||||
|
||||
first_ids: set[str] = set()
|
||||
for _ in range(30):
|
||||
order = svc._sort_assets_by_smart_score(["equal_a", "equal_b"])
|
||||
first_ids.add(order[0])
|
||||
|
||||
assert first_ids == {
|
||||
"equal_a",
|
||||
"equal_b",
|
||||
}, f"噪声应使等分素材排名浮动,30 次排序首位应覆盖两者,实际 {first_ids}"
|
||||
|
||||
def test_large_score_gap_not_flipped(self):
|
||||
"""得分差距远大于噪声上限时,低分素材不会因噪声超过高分素材。
|
||||
|
||||
quality 100 vs 0 → quality 维度差距 40 分 > 噪声上限 20,
|
||||
其余维度完全一致,50 次排序高质量素材必须始终排第一。
|
||||
"""
|
||||
from app.services.plan_generator_service import PlanGeneratorService
|
||||
|
||||
def _get_asset(aid):
|
||||
quality = {"top": 100.0, "bad": 0.0}[aid]
|
||||
asset = MagicMock()
|
||||
asset.id = aid
|
||||
asset.duration = 15.0
|
||||
asset.quality_score = quality
|
||||
asset.created_at = None
|
||||
asset.metadata = {"generation_use_count": 0}
|
||||
return asset
|
||||
|
||||
mock_asset_repo = MagicMock()
|
||||
mock_asset_repo.get = MagicMock(side_effect=_get_asset)
|
||||
svc = PlanGeneratorService(MagicMock(), asset_repo=mock_asset_repo)
|
||||
|
||||
for _ in range(50):
|
||||
order = svc._sort_assets_by_smart_score(["top", "bad"])
|
||||
assert order[0] == "top", f"质量差距 40 分 > 噪声上限,top 应始终排第一,实际 {order}"
|
||||
|
||||
Reference in New Issue
Block a user