82bc193693
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (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 / Validate - Migration (alembic) (pull_request) Successful in 40s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 45s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 49s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m35s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m51s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 2m51s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m39s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m30s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 4m47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 4m47s
AI Code Review / AI Code Review (pull_request) Failing after 6m38s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 8m29s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 12s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 51s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m1s
- MediaKitClient 新增 analyze_videos() 方法,调用视频理解智能策略 API - _call_ai_recommend_service 支持 asset_analyses 参数,将视频内容分析注入 prompt - API 路由层自动获取素材 URL 并调用 MediaKit 分析视频内容 - LLM 可根据视频实际内容做智能编排(叙事连贯、场景匹配等) - 降级策略:MediaKit 不可用时保持原有 fallback 行为 - 15 个新单元测试全部通过
437 lines
16 KiB
Python
Executable File
437 lines
16 KiB
Python
Executable File
"""#1209 MediaKit 视频理解 + AI 智能选片段集成测试.
|
|
|
|
覆盖范围:
|
|
1. MediaKitClient.analyze_videos — 正常流程、不可用降级、空输入
|
|
2. _call_ai_recommend_service 带 asset_analyses — prompt 注入验证
|
|
3. _build_asset_analyses — API 路由层集成逻辑
|
|
4. run_ai_recommend — asset_analyses 透传
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# ── MediaKitClient.analyze_videos ─────────────────────────────────────────────
|
|
|
|
|
|
class TestMediaKitClientAnalyzeVideos:
|
|
"""MediaKitClient.analyze_videos 单元测试."""
|
|
|
|
def _make_client(self, api_key: str = "test-key") -> object:
|
|
from packages.shared.mediakit_client import MediaKitClient
|
|
|
|
with patch("packages.shared.mediakit_client.get_shared_settings") as mock_settings:
|
|
mock_settings.return_value = MagicMock(
|
|
mediakit_api_key=api_key,
|
|
mediakit_base_url="https://mock.mediakit.com/api/v1",
|
|
mediakit_timeout=30,
|
|
)
|
|
return MediaKitClient()
|
|
|
|
def test_analyze_videos_not_available(self):
|
|
"""未配置 API Key 时返回 None."""
|
|
client = self._make_client(api_key="")
|
|
result = client.analyze_videos(
|
|
video_urls=["https://example.com/video.mp4"],
|
|
prompt="describe this video",
|
|
)
|
|
assert result is None
|
|
|
|
def test_analyze_videos_empty_urls(self):
|
|
"""空 URL 列表返回 None."""
|
|
client = self._make_client()
|
|
result = client.analyze_videos(video_urls=[], prompt="describe")
|
|
assert result is None
|
|
|
|
@patch("packages.shared.mediakit_client.httpx.post")
|
|
@patch("packages.shared.mediakit_client.httpx.get")
|
|
def test_analyze_videos_success(self, mock_get, mock_post):
|
|
"""正常提交任务并获取结果."""
|
|
# 提交任务返回 task_id
|
|
mock_post.return_value = MagicMock(
|
|
status_code=200,
|
|
json=lambda: {"success": True, "task_id": "test-task-123"},
|
|
)
|
|
mock_post.return_value.raise_for_status = MagicMock()
|
|
|
|
# 轮询返回 completed
|
|
mock_get.return_value = MagicMock(
|
|
status_code=200,
|
|
json=lambda: {
|
|
"success": True,
|
|
"task_id": "test-task-123",
|
|
"status": "completed",
|
|
"result": {
|
|
"duration": 30.5,
|
|
"contents": [
|
|
"视频展示了城市日景,包含车流和行人,氛围繁忙。",
|
|
"视频展示了夜晚霓虹灯特写,色调偏暖。",
|
|
],
|
|
},
|
|
},
|
|
)
|
|
mock_get.return_value.raise_for_status = MagicMock()
|
|
|
|
client = self._make_client()
|
|
result = client.analyze_videos(
|
|
video_urls=["https://example.com/v1.mp4", "https://example.com/v2.mp4"],
|
|
prompt="描述视频内容",
|
|
level="Economy",
|
|
poll_interval=0.01, # 测试用快速轮询
|
|
)
|
|
|
|
assert result is not None
|
|
assert len(result) == 2
|
|
assert "城市日景" in result[0]
|
|
assert "霓虹灯" in result[1]
|
|
|
|
# 验证提交参数
|
|
call_args = mock_post.call_args
|
|
assert "/tools/video-understand-router" in call_args.args[0]
|
|
payload = call_args.kwargs["json"]
|
|
assert payload["video_urls"] == ["https://example.com/v1.mp4", "https://example.com/v2.mp4"]
|
|
assert payload["prompt"] == "描述视频内容"
|
|
assert payload["level"] == "Economy"
|
|
|
|
@patch("packages.shared.mediakit_client.httpx.post")
|
|
def test_analyze_videos_submit_failure(self, mock_post):
|
|
"""提交任务失败返回 None."""
|
|
mock_post.side_effect = Exception("Network error")
|
|
|
|
client = self._make_client()
|
|
result = client.analyze_videos(
|
|
video_urls=["https://example.com/v1.mp4"],
|
|
prompt="describe",
|
|
poll_interval=0.01,
|
|
)
|
|
assert result is None
|
|
|
|
@patch("packages.shared.mediakit_client.httpx.post")
|
|
@patch("packages.shared.mediakit_client.httpx.get")
|
|
def test_analyze_videos_task_failed(self, mock_get, mock_post):
|
|
"""任务状态为 failed 时返回 None."""
|
|
mock_post.return_value = MagicMock(
|
|
status_code=200,
|
|
json=lambda: {"task_id": "task-fail"},
|
|
)
|
|
mock_post.return_value.raise_for_status = MagicMock()
|
|
|
|
mock_get.return_value = MagicMock(
|
|
status_code=200,
|
|
json=lambda: {"status": "failed", "error": "model timeout"},
|
|
)
|
|
mock_get.return_value.raise_for_status = MagicMock()
|
|
|
|
client = self._make_client()
|
|
result = client.analyze_videos(
|
|
video_urls=["https://example.com/v.mp4"],
|
|
prompt="describe",
|
|
poll_interval=0.01,
|
|
)
|
|
assert result is None
|
|
|
|
@patch("packages.shared.mediakit_client.httpx.post")
|
|
@patch("packages.shared.mediakit_client.httpx.get")
|
|
def test_analyze_videos_timeout(self, mock_get, mock_post):
|
|
"""超过最大轮询次数返回 None."""
|
|
mock_post.return_value = MagicMock(
|
|
status_code=200,
|
|
json=lambda: {"task_id": "task-slow"},
|
|
)
|
|
mock_post.return_value.raise_for_status = MagicMock()
|
|
|
|
# 一直返回 processing
|
|
mock_get.return_value = MagicMock(
|
|
status_code=200,
|
|
json=lambda: {"status": "processing"},
|
|
)
|
|
mock_get.return_value.raise_for_status = MagicMock()
|
|
|
|
client = self._make_client()
|
|
result = client.analyze_videos(
|
|
video_urls=["https://example.com/v.mp4"],
|
|
prompt="describe",
|
|
poll_interval=0.001,
|
|
max_poll_attempts=3,
|
|
)
|
|
assert result is None
|
|
|
|
|
|
# ── _call_ai_recommend_service with asset_analyses ────────────────────────────
|
|
|
|
|
|
class TestCallAiRecommendWithAnalysis:
|
|
"""测试 _call_ai_recommend_service 带 asset_analyses 的行为."""
|
|
|
|
@patch("packages.shared.ai_service.get_doubao_client")
|
|
def test_analysis_included_in_prompt(self, mock_get_client):
|
|
"""有分析结果时,prompt 包含视频内容描述."""
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = True
|
|
mock_get_client.return_value = mock_client
|
|
|
|
# 模拟豆包返回一个有效 JSON
|
|
mock_client.chat_completion.return_value = (
|
|
'{"clips": [{"clip_type": "intro", "order": 0, "text_content": "开场",'
|
|
'"duration": 3.0, "transition_effect": "fade", "asset_id": "asset1",'
|
|
'"start_time": 0.0, "config": {}}, {"clip_type": "outro", "order": 1,'
|
|
'"text_content": "结尾", "duration": 3.0, "transition_effect": "fade",'
|
|
'"asset_id": "", "start_time": 0.0, "config": {}}],'
|
|
'"title": "测试视频", "confidence": 0.9}'
|
|
)
|
|
|
|
from packages.shared.ai_service import _call_ai_recommend_service
|
|
|
|
result = _call_ai_recommend_service(
|
|
plan_id="plan-1",
|
|
template_id="tmpl-1",
|
|
asset_ids=["asset1", "asset2"],
|
|
editing_mode="one_take",
|
|
target_duration=30.0,
|
|
asset_analyses={
|
|
"asset1": "室内场景,一位女性在桌前讲解产品,氛围轻松专业",
|
|
"asset2": "室外公园,阳光充足,有孩子在玩耍",
|
|
},
|
|
)
|
|
|
|
assert result is not None
|
|
assert len(result["clips"]) == 2
|
|
|
|
# 验证 prompt 中包含了视频分析内容
|
|
messages = mock_client.chat_completion.call_args.kwargs["messages"]
|
|
system_prompt = messages[0]["content"]
|
|
assert "叙事连贯" in system_prompt # 有分析时的额外指导
|
|
|
|
user_prompt = messages[1]["content"]
|
|
assert "室内场景" in user_prompt
|
|
assert "室外公园" in user_prompt
|
|
|
|
@patch("packages.shared.ai_service.get_doubao_client")
|
|
def test_no_analysis_basic_prompt(self, mock_get_client):
|
|
"""无分析结果时,prompt 保持基本格式."""
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = True
|
|
mock_get_client.return_value = mock_client
|
|
|
|
mock_client.chat_completion.return_value = (
|
|
'{"clips": [{"clip_type": "showcase", "order": 0, "text_content": "展示",'
|
|
'"duration": 5.0, "transition_effect": "cut", "asset_id": "a1",'
|
|
'"start_time": 0.0, "config": {}}],'
|
|
'"title": "简单视频", "confidence": 0.8}'
|
|
)
|
|
|
|
from packages.shared.ai_service import _call_ai_recommend_service
|
|
|
|
result = _call_ai_recommend_service(
|
|
plan_id="plan-2",
|
|
template_id="tmpl-1",
|
|
asset_ids=["a1"],
|
|
editing_mode="one_take",
|
|
target_duration=10.0,
|
|
# 不传 asset_analyses
|
|
)
|
|
|
|
assert result is not None
|
|
|
|
# 验证 prompt 中不含叙事连贯等指导
|
|
messages = mock_client.chat_completion.call_args.kwargs["messages"]
|
|
system_prompt = messages[0]["content"]
|
|
assert "叙事连贯" not in system_prompt
|
|
|
|
@patch("packages.shared.ai_service.get_doubao_client")
|
|
def test_partial_analysis_only_some_assets(self, mock_get_client):
|
|
"""部分素材有分析结果时,只有被分析的素材包含内容描述."""
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = True
|
|
mock_get_client.return_value = mock_client
|
|
|
|
mock_client.chat_completion.return_value = (
|
|
'{"clips": [{"clip_type": "showcase", "order": 0, "text_content": "展示",'
|
|
'"duration": 5.0, "transition_effect": "cut", "asset_id": "a1",'
|
|
'"start_time": 0.0, "config": {}}],'
|
|
'"title": "部分分析", "confidence": 0.85}'
|
|
)
|
|
|
|
from packages.shared.ai_service import _call_ai_recommend_service
|
|
|
|
_call_ai_recommend_service(
|
|
plan_id="plan-3",
|
|
template_id="tmpl-1",
|
|
asset_ids=["a1", "a2", "a3"],
|
|
editing_mode="one_take",
|
|
target_duration=15.0,
|
|
asset_analyses={
|
|
"a1": "海边日落,金色阳光", # 只有 a1 有分析
|
|
},
|
|
)
|
|
|
|
messages = mock_client.chat_completion.call_args.kwargs["messages"]
|
|
user_prompt = messages[1]["content"]
|
|
assert "海边日落" in user_prompt
|
|
# a2 和 a3 应该只有 ID,没有内容描述
|
|
assert "素材ID: a2" in user_prompt
|
|
assert "素材ID: a3" in user_prompt
|
|
|
|
|
|
# ── run_ai_recommend with asset_analyses ──────────────────────────────────────
|
|
|
|
|
|
class TestRunAiRecommendWithAnalysis:
|
|
"""run_ai_recommend 透传 asset_analyses."""
|
|
|
|
@patch("packages.shared.ai_service._call_ai_recommend_service")
|
|
def test_passes_through_analysis(self, mock_call):
|
|
"""asset_analyses 正确传递给底层服务."""
|
|
from packages.shared.ai_service import run_ai_recommend
|
|
|
|
mock_call.return_value = {
|
|
"clips": [
|
|
{
|
|
"clip_type": "showcase",
|
|
"order": 0,
|
|
"text_content": "t",
|
|
"duration": 3.0,
|
|
"transition_effect": "cut",
|
|
"asset_id": "a1",
|
|
"start_time": 0.0,
|
|
"config": {},
|
|
}
|
|
],
|
|
"config": {"title": {"text": "test", "ai_auto": True}},
|
|
"total_duration": 3.0,
|
|
"confidence": 0.85,
|
|
}
|
|
|
|
analyses = {"a1": "海边日落场景"}
|
|
run_ai_recommend(
|
|
plan_id="plan-1",
|
|
template_id="tmpl-1",
|
|
asset_ids=["a1"],
|
|
editing_mode="one_take",
|
|
target_duration=10.0,
|
|
asset_analyses=analyses,
|
|
)
|
|
|
|
# 验证传递
|
|
call_kwargs = mock_call.call_args.kwargs
|
|
assert call_kwargs["asset_analyses"] == analyses
|
|
|
|
@patch("packages.shared.ai_service._call_ai_recommend_service")
|
|
def test_default_no_analysis(self, mock_call):
|
|
"""默认不传 asset_analyses 时为 None."""
|
|
from packages.shared.ai_service import run_ai_recommend
|
|
|
|
mock_call.return_value = {
|
|
"clips": [
|
|
{
|
|
"clip_type": "showcase",
|
|
"order": 0,
|
|
"text_content": "t",
|
|
"duration": 3.0,
|
|
"transition_effect": "cut",
|
|
"asset_id": "a1",
|
|
"start_time": 0.0,
|
|
"config": {},
|
|
}
|
|
],
|
|
"config": {"title": {"text": "test", "ai_auto": True}},
|
|
"total_duration": 3.0,
|
|
"confidence": 0.85,
|
|
}
|
|
|
|
run_ai_recommend(
|
|
plan_id="plan-1",
|
|
template_id="tmpl-1",
|
|
asset_ids=["a1"],
|
|
)
|
|
|
|
call_kwargs = mock_call.call_args.kwargs
|
|
assert call_kwargs["asset_analyses"] is None
|
|
|
|
|
|
# ── _build_asset_analyses (API route helper) ─────────────────────────────────
|
|
|
|
|
|
class TestBuildAssetAnalyses:
|
|
"""_build_asset_analyses 集成逻辑测试."""
|
|
|
|
def test_empty_asset_ids(self):
|
|
"""空素材列表返回空 dict."""
|
|
from apps.api.app.api.routes.templates_editor.ai_features import (
|
|
_build_asset_analyses,
|
|
)
|
|
|
|
result = _build_asset_analyses([], MagicMock())
|
|
assert result == {}
|
|
|
|
@patch("packages.shared.mediakit_client.get_mediakit_client")
|
|
def test_mediakit_not_available(self, mock_get_client):
|
|
"""MediaKit 不可用时返回空 dict."""
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = False
|
|
mock_get_client.return_value = mock_client
|
|
|
|
# 需要 patch 在 packages.shared.mediakit_client 层面,因为 _build_asset_analyses 内部 from import
|
|
from apps.api.app.api.routes.templates_editor.ai_features import (
|
|
_build_asset_analyses,
|
|
)
|
|
|
|
result = _build_asset_analyses(["asset1"], MagicMock())
|
|
assert result == {}
|
|
|
|
def test_exception_returns_empty(self):
|
|
"""任何异常都返回空 dict,不阻塞主流程."""
|
|
from apps.api.app.api.routes.templates_editor.ai_features import (
|
|
_build_asset_analyses,
|
|
)
|
|
|
|
# 传一个 mock db,让内部自然失败
|
|
mock_db = MagicMock()
|
|
mock_db.side_effect = None # db 本身不抛异常,但内部操作会失败
|
|
|
|
result = _build_asset_analyses(["nonexistent-asset"], mock_db)
|
|
assert result == {}
|
|
|
|
|
|
# ── 长文本截断验证 ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestAnalysisTruncation:
|
|
"""验证过长分析文本被截断以避免 token 爆炸."""
|
|
|
|
@patch("packages.shared.ai_service.get_doubao_client")
|
|
def test_long_analysis_truncated(self, mock_get_client):
|
|
"""超过 300 字的分析文本被截断并加 ...."""
|
|
mock_client = MagicMock()
|
|
mock_client.is_available = True
|
|
mock_get_client.return_value = mock_client
|
|
|
|
mock_client.chat_completion.return_value = (
|
|
'{"clips": [{"clip_type": "showcase", "order": 0, "text_content": "t",'
|
|
'"duration": 3.0, "transition_effect": "cut", "asset_id": "a1",'
|
|
'"start_time": 0.0, "config": {}}],'
|
|
'"title": "test", "confidence": 0.8}'
|
|
)
|
|
|
|
from packages.shared.ai_service import _call_ai_recommend_service
|
|
|
|
long_analysis = "A" * 500 # 500 字符
|
|
|
|
_call_ai_recommend_service(
|
|
plan_id="plan-1",
|
|
template_id="tmpl-1",
|
|
asset_ids=["a1"],
|
|
editing_mode="one_take",
|
|
target_duration=10.0,
|
|
asset_analyses={"a1": long_analysis},
|
|
)
|
|
|
|
messages = mock_client.chat_completion.call_args.kwargs["messages"]
|
|
user_prompt = messages[1]["content"]
|
|
# 截断后应该包含 ...
|
|
assert "..." in user_prompt
|
|
# 原始 500 字符不应完整出现
|
|
assert "A" * 500 not in user_prompt
|