From 229f9dddebb2d28490e79a854818283b043fbc2f Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Thu, 3 Sep 2026 20:01:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=AD=A3=E5=BC=8F=E7=94=9F=E6=88=90?= =?UTF-8?q?=E6=97=B6=E7=89=87=E6=AE=B5=E9=9A=8F=E6=9C=BA=E9=87=8D=E6=8E=92?= =?UTF-8?q?=EF=BC=88=E9=99=8D=E9=87=8D=EF=BC=8C=E9=BB=98=E8=AE=A4=E5=BC=80?= =?UTF-8?q?=E5=90=AF=E6=97=A0=E5=BC=80=E5=85=B3=EF=BC=89#1663?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 _distribute_assets 方法中,smart_match 评分排序完成后、 distribute_assets 之前,对 asset_ids 做 random.shuffle。 - smart_match 决定选哪些素材(评分排序保留) - shuffle 只改变最终分配到 clips 的顺序 - scene_points 缓存不受影响(shuffle 之前已读取) - asset_ids 先 list() 复制再 shuffle,不修改调用方原列表 - 新增 3 个单元测试验证 shuffle 行为 Closes #1663 --- .../app/services/plan_generator_service.py | 5 + tests/unit/test_plan_generator.py | 159 ++++++++++++++++++ 2 files changed, 164 insertions(+) diff --git a/apps/api/app/services/plan_generator_service.py b/apps/api/app/services/plan_generator_service.py index de0b9b938..758510c0f 100755 --- a/apps/api/app/services/plan_generator_service.py +++ b/apps/api/app/services/plan_generator_service.py @@ -234,6 +234,11 @@ class PlanGeneratorService: # 有缓存的素材片段起点从随机镜头段选取,无缓存走随机起点兜底 asset_scene_points = self._fetch_asset_scene_points(asset_ids) + # 正式生成也随机重排片段顺序(降重,默认开启无开关) + # smart_match 决定选哪些素材,shuffle 只改变分配到 clips 的顺序 + asset_ids = list(asset_ids) # 复制避免修改调用方原列表 + random.shuffle(asset_ids) + distribute_assets( clips, asset_ids, diff --git a/tests/unit/test_plan_generator.py b/tests/unit/test_plan_generator.py index 37544c067..2d328cbc3 100755 --- a/tests/unit/test_plan_generator.py +++ b/tests/unit/test_plan_generator.py @@ -1007,3 +1007,162 @@ class TestAssetDurationsAlwaysFetched: call_kwargs = mock_distribute.call_args asset_durations = call_kwargs.kwargs.get("asset_durations", call_kwargs[1].get("asset_durations")) assert asset_durations is None + + +# --------------------------------------------------------------------------- +# 测试:正式生成片段随机重排(Issue #1663) +# --------------------------------------------------------------------------- + + +class TestFormalGenerationShuffle: + """验证正式生成时片段顺序随机化。 + + Issue #1663: 正式生成时 smart_match 排序后对 asset_ids 做 random.shuffle, + 使得同一批素材每次生成的视频片段顺序不同,有利于查重降重。 + """ + + def _make_service_with_asset_repo(self): + """创建带 mock asset_repo 的 PlanGeneratorService(复用 TestAssetDurationsAlwaysFetched 模式)""" + from apps.api.app.services.plan_generator_service import PlanGeneratorService + + plan_repo = StubEditPlanRepository() + clip_repo = StubEditPlanClipRepository() + + asset_repo = MagicMock() + + def fake_get(asset_id): + mock_asset = MagicMock() + mock_asset.duration = 30.0 + mock_asset.quality_score = None + mock_asset.created_at = None + mock_asset.metadata = {} + return mock_asset + + asset_repo.get = MagicMock(side_effect=fake_get) + + with ( + patch( + "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanRepository", + return_value=plan_repo, + ), + patch( + "apps.api.app.services.plan_generator_service.SQLAlchemyEditPlanClipRepository", + return_value=clip_repo, + ), + ): + db = MagicMock() + svc = PlanGeneratorService(db, asset_repo=asset_repo) + svc._plan_repo = plan_repo + svc._clip_repo = clip_repo + + return svc, asset_repo + + def test_formal_generation_shuffles_asset_ids(self): + """正式生成路径下 asset_ids 应被打乱,多次调用顺序应不同""" + svc, _ = self._make_service_with_asset_repo() + + template = _make_template("one_take") + # 6 个 clip 容纳 6 个素材 + clip_configs = _make_clip_configs( + template_id=template.id, + specs=[ + {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(6) + ], + ) + + asset_ids = ["a1", "a2", "a3", "a4", "a5", "a6"] + + # 收集多次调用中 distribute_assets 收到的 asset_ids 顺序 + captured_orders = [] + with patch( + "apps.api.app.services.plan_generator_service.distribute_assets", + side_effect=lambda clips, asset_ids, *a, **kw: captured_orders.append(list(asset_ids)), + ): + # mock _sort_assets_by_smart_score 返回固定顺序,验证 shuffle 会打乱 + with patch.object( + svc, + "_sort_assets_by_smart_score", + side_effect=lambda ids: list(ids), # 原样返回 + ): + with patch.object( + svc, + "_fetch_asset_scene_points", + return_value={}, + ): + for _ in range(10): + svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=list(asset_ids), # 每次传新列表 + random_preview=False, # 正式生成 + ) + + assert len(captured_orders) == 10 + # 每次 order 应该是 asset_ids 的一个排列 + expected_set = set(asset_ids) + for order in captured_orders: + assert set(order) == expected_set + + # 10 次调用中应至少出现 2 种不同顺序(概率 > 99.9%) + unique_orders = set(tuple(o) for o in captured_orders) + assert ( + len(unique_orders) >= 2 + ), f"Expected shuffled orders to vary, but got only {len(unique_orders)} unique order(s): {unique_orders}" + + def test_formal_generation_does_not_mutate_original_list(self): + """shuffle 不应修改调用方的原始 asset_ids 列表""" + svc, _ = self._make_service_with_asset_repo() + + template = _make_template("one_take") + clip_configs = _make_clip_configs( + template_id=template.id, + specs=[ + {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(4) + ], + ) + + original = ["a1", "a2", "a3", "a4"] + original_copy = list(original) + + with patch("apps.api.app.services.plan_generator_service.distribute_assets"): + with patch.object(svc, "_sort_assets_by_smart_score", side_effect=lambda ids: list(ids)): + with patch.object(svc, "_fetch_asset_scene_points", return_value={}): + svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=original, + random_preview=False, + ) + + assert original == original_copy, "Original asset_ids list should not be mutated" + + def test_preview_random_mode_unaffected_by_shuffle(self): + """预览随机模式不走 shuffle 路径,行为不变""" + svc, _ = self._make_service_with_asset_repo() + + template = _make_template("one_take") + clip_configs = _make_clip_configs( + template_id=template.id, + specs=[ + {"clip_type": ClipType.MAIN, "order": i, "min_duration": 3.0, "max_duration": 5.0} for i in range(4) + ], + ) + + asset_ids = ["a1", "a2", "a3", "a4"] + + captured_orders = [] + with patch( + "apps.api.app.services.plan_generator_service.distribute_assets", + side_effect=lambda clips, asset_ids, *a, **kw: captured_orders.append(list(asset_ids)), + ): + for _ in range(5): + svc.generate_from_template( + template=template, + clip_configs=clip_configs, + asset_ids=list(asset_ids), + random_preview=True, # 预览随机模式 + ) + + assert len(captured_orders) == 5 + # 预览模式下 random.shuffle 不应被调用(在 _distribute_assets 的 if not random_selection 块内) + # 所以 asset_ids 应该保持调用方传入的顺序(可能已由上层 shuffle 过) -- 2.54.0