Files
xiaoxia-saas/tests/unit/test_mediakit_video_understand.py
xiaoxia 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
feat: #1209 AI智能选片段接入MediaKit视频理解
- MediaKitClient 新增 analyze_videos() 方法,调用视频理解智能策略 API
- _call_ai_recommend_service 支持 asset_analyses 参数,将视频内容分析注入 prompt
- API 路由层自动获取素材 URL 并调用 MediaKit 分析视频内容
- LLM 可根据视频实际内容做智能编排(叙事连贯、场景匹配等)
- 降级策略:MediaKit 不可用时保持原有 fallback 行为
- 15 个新单元测试全部通过
2026-08-03 09:43:34 +08:00

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