diff --git a/apps/api/app/services/ai_service.py b/apps/api/app/services/ai_service.py index 24dd1b645..0d1bca0a1 100755 --- a/apps/api/app/services/ai_service.py +++ b/apps/api/app/services/ai_service.py @@ -298,11 +298,13 @@ def _semantic_match_fallback( score = min(1.0, base_score * 0.8 + name_bonus) score = round(score, 3) - results.append({ - **asset, - "match_score": score, - "match_reason": "fallback_keyword", - }) + results.append( + { + **asset, + "match_score": score, + "match_reason": "fallback_keyword", + } + ) # 按匹配度降序 results.sort(key=lambda x: x["match_score"], reverse=True) @@ -414,9 +416,7 @@ def semantic_match_assets( tags = asset.get("tags", []) tags_str = ",".join(str(t) for t in tags[:5]) desc = str(asset.get("description", ""))[:80] - asset_summaries.append( - f"ID:{aid} | 名称:{name} | 标签:[{tags_str}] | 描述:{desc}" - ) + asset_summaries.append(f"ID:{aid} | 名称:{name} | 标签:[{tags_str}] | 描述:{desc}") asset_ids = [str(a.get("id", "")) for a in assets[:50]] @@ -434,8 +434,7 @@ def semantic_match_assets( user_prompt = ( f"目标视频描述:{description}\n\n" - f"素材列表:\n" + "\n".join(asset_summaries) + - f"\n\n请返回每个素材的匹配分数JSON:" + f"素材列表:\n" + "\n".join(asset_summaries) + "\n\n请返回每个素材的匹配分数JSON:" ) messages = [ @@ -457,11 +456,13 @@ def semantic_match_assets( for asset in assets: aid = str(asset.get("id", "")) score = scores.get(aid, 0.3) # 没评分的给默认偏低分 - matched.append({ - **asset, - "match_score": round(score, 3), - "match_reason": "doubao_semantic", - }) + matched.append( + { + **asset, + "match_score": round(score, 3), + "match_reason": "doubao_semantic", + } + ) matched.sort(key=lambda x: x["match_score"], reverse=True) logger.info( diff --git a/apps/worker/worker_app/tasks/ai_tasks.py b/apps/worker/worker_app/tasks/ai_tasks.py index 560acdf4c..6ef25d7b0 100755 --- a/apps/worker/worker_app/tasks/ai_tasks.py +++ b/apps/worker/worker_app/tasks/ai_tasks.py @@ -142,23 +142,25 @@ def _parse_recommend_response( return None clips: List[Dict[str, Any]] = [] - for i, clip in enumerate(clips_data): + for _, clip in enumerate(clips_data): if not isinstance(clip, dict): continue asset_id = str(clip.get("asset_id", "")) # 校验 asset_id 是否在输入列表中 if asset_id and asset_id not in asset_ids: asset_id = "" - clips.append({ - "clip_type": clip.get("clip_type", "showcase"), - "order": clip.get("order", len(clips)), - "text_content": str(clip.get("text_content", "")), - "duration": max(1.0, min(30.0, float(clip.get("duration", 3.0)))), - "transition_effect": clip.get("transition_effect", "cut"), - "asset_id": asset_id, - "start_time": max(0.0, float(clip.get("start_time", 0.0))), - "config": clip.get("config", {}) or {}, - }) + clips.append( + { + "clip_type": clip.get("clip_type", "showcase"), + "order": clip.get("order", len(clips)), + "text_content": str(clip.get("text_content", "")), + "duration": max(1.0, min(30.0, float(clip.get("duration", 3.0)))), + "transition_effect": clip.get("transition_effect", "cut"), + "asset_id": asset_id, + "start_time": max(0.0, float(clip.get("start_time", 0.0))), + "config": clip.get("config", {}) or {}, + } + ) if not clips: return None @@ -219,7 +221,7 @@ def _call_ai_recommend_service( "3. 总时长接近 target_duration,每个素材至少用一次\n" "4. 转场效果合理分配,不要全用cut\n" "5. 返回纯JSON,不要其他文字\n" - "返回格式:{\"clips\": [...], \"title\": \"视频标题\", \"confidence\": 0.85}" + '返回格式:{"clips": [...], "title": "视频标题", "confidence": 0.85}' ) assets_desc = "\n".join([f" - 素材ID: {aid}" for i, aid in enumerate(asset_ids[:30])]) diff --git a/tests/unit/test_ai_tasks.py b/tests/unit/test_ai_tasks.py index 1d447767f..369f68d69 100755 --- a/tests/unit/test_ai_tasks.py +++ b/tests/unit/test_ai_tasks.py @@ -121,22 +121,41 @@ class TestRecommendResponseParsing(unittest.TestCase): """解析正常响应.""" data = { "clips": [ - {"clip_type": "intro", "order": 0, "text_content": "开场", - "duration": 3.0, "transition_effect": "fade", - "asset_id": "a1", "start_time": 0.0, "config": {}}, - {"clip_type": "showcase", "order": 1, "text_content": "展示", - "duration": 5.0, "transition_effect": "cut", - "asset_id": "a2", "start_time": 1.0, "config": {}}, - {"clip_type": "outro", "order": 2, "text_content": "结尾", - "duration": 2.0, "transition_effect": "fade", - "asset_id": "", "start_time": 0.0, "config": {}}, + { + "clip_type": "intro", + "order": 0, + "text_content": "开场", + "duration": 3.0, + "transition_effect": "fade", + "asset_id": "a1", + "start_time": 0.0, + "config": {}, + }, + { + "clip_type": "showcase", + "order": 1, + "text_content": "展示", + "duration": 5.0, + "transition_effect": "cut", + "asset_id": "a2", + "start_time": 1.0, + "config": {}, + }, + { + "clip_type": "outro", + "order": 2, + "text_content": "结尾", + "duration": 2.0, + "transition_effect": "fade", + "asset_id": "", + "start_time": 0.0, + "config": {}, + }, ], "title": "精彩视频", "confidence": 0.85, } - result = _parse_recommend_response( - json.dumps(data), self._asset_ids(), 30.0 - ) + result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) self.assertEqual(len(result["clips"]), 3) self.assertEqual(result["clips"][0]["clip_type"], "intro") @@ -145,11 +164,21 @@ class TestRecommendResponseParsing(unittest.TestCase): def test_parse_markdown_code_block(self): """解析markdown代码块.""" - data = {"clips": [ - {"clip_type": "showcase", "order": 0, "text_content": "t", - "duration": 3, "transition_effect": "cut", - "asset_id": "a1", "start_time": 0, "config": {}} - ], "confidence": 0.7} + data = { + "clips": [ + { + "clip_type": "showcase", + "order": 0, + "text_content": "t", + "duration": 3, + "transition_effect": "cut", + "asset_id": "a1", + "start_time": 0, + "config": {}, + } + ], + "confidence": 0.7, + } content = "```json\n" + json.dumps(data) + "\n```" result = _parse_recommend_response(content, self._asset_ids(), 30.0) self.assertIsNotNone(result) @@ -167,51 +196,78 @@ class TestRecommendResponseParsing(unittest.TestCase): def test_parse_no_clips(self): """无clips字段返回None.""" - result = _parse_recommend_response( - json.dumps({"title": "abc"}), self._asset_ids(), 30.0 - ) + result = _parse_recommend_response(json.dumps({"title": "abc"}), self._asset_ids(), 30.0) self.assertIsNone(result) def test_parse_filters_invalid_asset_ids(self): """过滤不在输入列表中的asset_id.""" - data = {"clips": [ - {"clip_type": "showcase", "order": 0, "text_content": "t", - "duration": 3, "transition_effect": "cut", - "asset_id": "fake-id", "start_time": 0, "config": {}} - ], "confidence": 0.7} - result = _parse_recommend_response( - json.dumps(data), self._asset_ids(), 30.0 - ) + data = { + "clips": [ + { + "clip_type": "showcase", + "order": 0, + "text_content": "t", + "duration": 3, + "transition_effect": "cut", + "asset_id": "fake-id", + "start_time": 0, + "config": {}, + } + ], + "confidence": 0.7, + } + result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) # 非法asset_id被清空 self.assertEqual(result["clips"][0]["asset_id"], "") def test_parse_clamps_duration(self): """时长被限制在合理范围.""" - data = {"clips": [ - {"clip_type": "showcase", "order": 0, "text_content": "t", - "duration": 100, "transition_effect": "cut", - "asset_id": "a1", "start_time": 0, "config": {}} - ]} - result = _parse_recommend_response( - json.dumps(data), self._asset_ids(), 30.0 - ) + data = { + "clips": [ + { + "clip_type": "showcase", + "order": 0, + "text_content": "t", + "duration": 100, + "transition_effect": "cut", + "asset_id": "a1", + "start_time": 0, + "config": {}, + } + ] + } + result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) self.assertLessEqual(result["clips"][0]["duration"], 30.0) def test_parse_reorders_clips(self): """clips按order排序并重新编号.""" - data = {"clips": [ - {"clip_type": "showcase", "order": 5, "text_content": "b", - "duration": 3, "transition_effect": "cut", - "asset_id": "a2", "start_time": 0, "config": {}}, - {"clip_type": "intro", "order": 0, "text_content": "a", - "duration": 3, "transition_effect": "fade", - "asset_id": "a1", "start_time": 0, "config": {}}, - ]} - result = _parse_recommend_response( - json.dumps(data), self._asset_ids(), 30.0 - ) + data = { + "clips": [ + { + "clip_type": "showcase", + "order": 5, + "text_content": "b", + "duration": 3, + "transition_effect": "cut", + "asset_id": "a2", + "start_time": 0, + "config": {}, + }, + { + "clip_type": "intro", + "order": 0, + "text_content": "a", + "duration": 3, + "transition_effect": "fade", + "asset_id": "a1", + "start_time": 0, + "config": {}, + }, + ] + } + result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) # 第一个应该是order=0的intro self.assertEqual(result["clips"][0]["clip_type"], "intro") @@ -221,14 +277,22 @@ class TestRecommendResponseParsing(unittest.TestCase): def test_parse_confidence_clamped(self): """confidence被限制在0-1.""" - data = {"clips": [ - {"clip_type": "showcase", "order": 0, "text_content": "t", - "duration": 3, "transition_effect": "cut", - "asset_id": "a1", "start_time": 0, "config": {}} - ], "confidence": 2.5} - result = _parse_recommend_response( - json.dumps(data), self._asset_ids(), 30.0 - ) + data = { + "clips": [ + { + "clip_type": "showcase", + "order": 0, + "text_content": "t", + "duration": 3, + "transition_effect": "cut", + "asset_id": "a1", + "start_time": 0, + "config": {}, + } + ], + "confidence": 2.5, + } + result = _parse_recommend_response(json.dumps(data), self._asset_ids(), 30.0) self.assertIsNotNone(result) self.assertLessEqual(result["confidence"], 1.0) @@ -260,12 +324,26 @@ class TestRunAIRecommend(unittest.TestCase): mock_client.is_available = True mock_response = { "clips": [ - {"clip_type": "intro", "order": 0, "text_content": "开场", - "duration": 3.0, "transition_effect": "fade", - "asset_id": "a1", "start_time": 0.0, "config": {}}, - {"clip_type": "outro", "order": 1, "text_content": "结尾", - "duration": 2.0, "transition_effect": "fade", - "asset_id": "a2", "start_time": 0.0, "config": {}}, + { + "clip_type": "intro", + "order": 0, + "text_content": "开场", + "duration": 3.0, + "transition_effect": "fade", + "asset_id": "a1", + "start_time": 0.0, + "config": {}, + }, + { + "clip_type": "outro", + "order": 1, + "text_content": "结尾", + "duration": 2.0, + "transition_effect": "fade", + "asset_id": "a2", + "start_time": 0.0, + "config": {}, + }, ], "title": "AI生成标题", "confidence": 0.9,