feat(assets): add review actions and high risk view
This commit is contained in:
@@ -44,7 +44,12 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) ->
|
||||
voice_assets = [asset for asset in ready_assets if _asset_kind(asset) == AssetLibraryKind.VOICE]
|
||||
problem_assets = [asset for asset in assets if asset.status in {AssetStatus.ERROR, AssetStatus.UPLOADING, AssetStatus.PROCESSING}]
|
||||
unclassified_assets = [asset for asset in ready_assets if asset.classification_status.value in {"pending", "failed"}]
|
||||
risky_assets = [asset for asset in ready_assets if asset.quality_score is not None and asset.quality_score < 60]
|
||||
risky_assets = [
|
||||
asset for asset in ready_assets
|
||||
if (asset.quality_score is not None and asset.quality_score < 60)
|
||||
or asset.metadata.get("review_status") == "rejected"
|
||||
or asset.status == AssetStatus.ERROR
|
||||
]
|
||||
used_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) > 0]
|
||||
unused_assets = [asset for asset in ready_assets if int(asset.metadata.get("generation_use_count") or 0) == 0]
|
||||
pending_review_assets = [asset for asset in ready_assets if asset.metadata.get("review_status") == "pending_review"]
|
||||
@@ -125,6 +130,7 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) ->
|
||||
smart_views = [
|
||||
AssetSmartViewItem(key="recommended", label="推荐素材", count=len(video_assets), description="已导入完成、可参与生成的视频素材"),
|
||||
AssetSmartViewItem(key="needs_attention", label="慎用素材", count=len(problem_assets) + len(risky_assets), description="导入未完成、失败或质量分偏低的素材"),
|
||||
AssetSmartViewItem(key="high_risk", label="高风险素材", count=len(risky_assets), description="质量分偏低或复核拒绝的素材"),
|
||||
AssetSmartViewItem(key="unclassified", label="未分类素材", count=len(unclassified_assets), description="尚未完成分类或分类失败的 ready 素材"),
|
||||
AssetSmartViewItem(key="recent", label="最近上传", count=min(len(assets), 10), description="最近进入素材库的素材,可用于快速复核"),
|
||||
AssetSmartViewItem(key="unused", label="未使用素材", count=len(unused_assets), description="尚未参与生成的 ready 素材"),
|
||||
|
||||
@@ -8,7 +8,7 @@ from app.dependencies import (
|
||||
get_project_repository,
|
||||
get_workspace_member_repository,
|
||||
)
|
||||
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse
|
||||
from app.schemas.asset import AssetResponse, CreateAssetRequest, ListAssetsResponse, UpdateAssetReviewRequest
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from packages.application import (
|
||||
@@ -63,6 +63,31 @@ def list_assets(
|
||||
return ListAssetsResponse(items=[_to_asset_response(item) for item in items])
|
||||
|
||||
|
||||
def _apply_asset_review_status(item, review_status: str):
|
||||
item.metadata = {
|
||||
**item.metadata,
|
||||
"review_status": review_status,
|
||||
}
|
||||
return item
|
||||
|
||||
|
||||
@router.patch("/{asset_id}/review", response_model=AssetResponse)
|
||||
def update_asset_review_status(
|
||||
asset_id: str,
|
||||
request: UpdateAssetReviewRequest,
|
||||
authenticated_user: AuthenticatedUser = Depends(get_current_user),
|
||||
asset_repository: Any = Depends(get_asset_repository),
|
||||
workspace_member_repository: WorkspaceMemberRepository = Depends(get_workspace_member_repository),
|
||||
) -> AssetResponse:
|
||||
item = asset_repository.get(asset_id)
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
||||
require_workspace_member(item.workspace_id, authenticated_user, workspace_member_repository)
|
||||
_apply_asset_review_status(item, request.review_status)
|
||||
updated = asset_repository.update(item)
|
||||
return _to_asset_response(updated)
|
||||
|
||||
|
||||
@router.post("", response_model=AssetResponse)
|
||||
def create_asset(
|
||||
request: CreateAssetRequest,
|
||||
|
||||
@@ -22,6 +22,10 @@ class CreateAssetRequest(BaseModel):
|
||||
uploaded_by_user_id: str = Field(default="", max_length=100)
|
||||
|
||||
|
||||
class UpdateAssetReviewRequest(BaseModel):
|
||||
review_status: str = Field(..., pattern="^(pending_review|approved|rejected)$")
|
||||
|
||||
|
||||
class AssetResponse(BaseModel):
|
||||
id: str
|
||||
workspace_id: str
|
||||
|
||||
@@ -112,6 +112,9 @@ test.describe('Core media upload flow', () => {
|
||||
await expect(page.getByText(/推荐素材:1/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/视频素材数量偏少|素材准备度良好/)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByRole('cell', { name: 'e2e-sample.MOV', exact: true })).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByRole('row', { name: /e2e-sample\.MOV/ }).getByRole('button', { name: '通过' }).click();
|
||||
await expect(page.getByText('复核状态已更新')).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText('已通过')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/素材列表加载失败|上传失败/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,6 +98,16 @@ export const getAssets = async (libraryId: string): Promise<AssetItem[]> => {
|
||||
return response.data.items;
|
||||
};
|
||||
|
||||
export const updateAssetReviewStatus = async (
|
||||
assetId: string,
|
||||
reviewStatus: 'pending_review' | 'approved' | 'rejected'
|
||||
): Promise<AssetItem> => {
|
||||
const response = await apiClient.patch(`/assets/${assetId}/review`, {
|
||||
review_status: reviewStatus,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const uploadAsset = async (
|
||||
formData: FormData
|
||||
): Promise<{ storage_key: string; ingest_job_id: string; url: string }> => {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
getClassificationJob,
|
||||
getIngestJob,
|
||||
submitClassificationJob,
|
||||
updateAssetReviewStatus,
|
||||
uploadAsset,
|
||||
uploadAssetDirect,
|
||||
type AssetItem,
|
||||
@@ -274,6 +275,17 @@ const ProjectAssets: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const reviewMutation = useMutation({
|
||||
mutationFn: ({ assetId, reviewStatus }: { assetId: string; reviewStatus: 'pending_review' | 'approved' | 'rejected' }) =>
|
||||
updateAssetReviewStatus(assetId, reviewStatus),
|
||||
onSuccess: () => {
|
||||
message.success('复核状态已更新');
|
||||
assetsQuery.refetch();
|
||||
diagnosisQuery.refetch();
|
||||
},
|
||||
onError: (error: any) => message.error(error.response?.data?.detail || '复核状态更新失败'),
|
||||
});
|
||||
|
||||
const libraryOptions = useMemo(
|
||||
() => (librariesQuery.data || []).map((item) => ({ label: `${item.name} (${item.kind})`, value: item.id })),
|
||||
[librariesQuery.data]
|
||||
@@ -286,6 +298,8 @@ const ProjectAssets: React.FC = () => {
|
||||
items = items.filter((item) => item.status === 'ready' && item.mime_type?.startsWith('video'));
|
||||
} else if (smartViewFilter === 'needs_attention') {
|
||||
items = items.filter((item) => item.status !== 'ready' || (typeof item.quality_score === 'number' && item.quality_score < 60));
|
||||
} else if (smartViewFilter === 'high_risk') {
|
||||
items = items.filter((item) => (typeof item.quality_score === 'number' && item.quality_score < 60) || item.metadata?.review_status === 'rejected' || item.status === 'error');
|
||||
} else if (smartViewFilter === 'unclassified') {
|
||||
items = items.filter((item) => !item.metadata?.classification || item.classification_status === 'failed');
|
||||
} else if (smartViewFilter === 'voice') {
|
||||
@@ -410,10 +424,29 @@ const ProjectAssets: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderReviewStatus = (asset: AssetItem) => {
|
||||
const status = String(asset.metadata?.review_status || 'unreviewed');
|
||||
const labels: Record<string, string> = {
|
||||
pending_review: '待复核',
|
||||
approved: '已通过',
|
||||
rejected: '已拒绝',
|
||||
unreviewed: '未复核',
|
||||
};
|
||||
const colors: Record<string, string> = {
|
||||
pending_review: 'orange',
|
||||
approved: 'green',
|
||||
rejected: 'red',
|
||||
unreviewed: 'default',
|
||||
};
|
||||
return <Tag color={colors[status] || 'default'}>{labels[status] || status}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '类型', dataIndex: 'mime_type', key: 'mime_type', render: (value: string) => <Tag>{value}</Tag> },
|
||||
{ title: '分类结果', key: 'classification', render: (_: unknown, record: AssetItem) => renderClassification(record) },
|
||||
{ title: '复核状态', key: 'review_status', render: (_: unknown, record: AssetItem) => renderReviewStatus(record) },
|
||||
{ title: '使用次数', key: 'generation_use_count', render: (_: unknown, record: AssetItem) => Number(record.metadata?.generation_use_count || 0) },
|
||||
{ title: '存储键', dataIndex: 'storage_key', key: 'storage_key', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -423,21 +456,25 @@ const ProjectAssets: React.FC = () => {
|
||||
const isCurrentClassifying = classifyingAssetId === record.id;
|
||||
const isAutoClassifying = record.metadata?.auto_classification === 'queued';
|
||||
return (
|
||||
<Button
|
||||
icon={<TagsOutlined />}
|
||||
loading={isCurrentClassifying && classificationMutation.isPending}
|
||||
disabled={isAutoClassifying || (!!classifyingAssetId && classifyingAssetId !== record.id) || batchProgress.running}
|
||||
onClick={() => {
|
||||
setClassifyingAssetId(record.id);
|
||||
classificationMutation.mutate({
|
||||
workspace_id: record.workspace_id,
|
||||
project_id: record.project_id,
|
||||
asset_id: record.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isAutoClassifying ? '自动分类中...' : isCurrentClassifying ? '分类中...' : hasClassification ? '重新分类' : '发起分类'}
|
||||
</Button>
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<TagsOutlined />}
|
||||
loading={isCurrentClassifying && classificationMutation.isPending}
|
||||
disabled={isAutoClassifying || (!!classifyingAssetId && classifyingAssetId !== record.id) || batchProgress.running}
|
||||
onClick={() => {
|
||||
setClassifyingAssetId(record.id);
|
||||
classificationMutation.mutate({
|
||||
workspace_id: record.workspace_id,
|
||||
project_id: record.project_id,
|
||||
asset_id: record.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{isAutoClassifying ? '自动分类中...' : isCurrentClassifying ? '分类中...' : hasClassification ? '重新分类' : '发起分类'}
|
||||
</Button>
|
||||
<Button size="small" disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate({ assetId: record.id, reviewStatus: 'approved' })}>通过</Button>
|
||||
<Button size="small" danger disabled={reviewMutation.isPending} onClick={() => reviewMutation.mutate({ assetId: record.id, reviewStatus: 'rejected' })}>拒绝</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -509,6 +546,7 @@ const ProjectAssets: React.FC = () => {
|
||||
{ label: '全部素材', value: 'all' },
|
||||
{ label: '推荐素材', value: 'recommended' },
|
||||
{ label: '慎用素材', value: 'needs_attention' },
|
||||
{ label: '高风险素材', value: 'high_risk' },
|
||||
{ label: '未分类素材', value: 'unclassified' },
|
||||
{ label: '最近上传', value: 'recent' },
|
||||
{ label: '未使用素材', value: 'unused' },
|
||||
|
||||
@@ -75,4 +75,6 @@ def test_asset_diagnosis_flags_unready_and_low_quality_assets():
|
||||
gap_keys = {gap.key for gap in diagnosis.gaps}
|
||||
assert "not_ready_assets" in gap_keys
|
||||
assert "low_quality_assets" in gap_keys
|
||||
assert {item.key: item.count for item in diagnosis.smart_views}["needs_attention"] == 2
|
||||
smart_view_counts = {item.key: item.count for item in diagnosis.smart_views}
|
||||
assert smart_view_counts["needs_attention"] == 2
|
||||
assert smart_view_counts["high_risk"] == 1
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
|
||||
|
||||
from app.api.routes.assets import _apply_asset_review_status
|
||||
from packages.domain import Asset, AssetStatus
|
||||
|
||||
|
||||
def test_apply_asset_review_status_preserves_existing_metadata():
|
||||
asset = Asset.create(
|
||||
workspace_id="workspace-1",
|
||||
project_id="project-1",
|
||||
library_id="library-1",
|
||||
name="video.mp4",
|
||||
storage_key="uploads/video.mp4",
|
||||
mime_type="video/mp4",
|
||||
file_size=1024,
|
||||
status=AssetStatus.READY,
|
||||
metadata={"generation_use_count": 2},
|
||||
)
|
||||
|
||||
_apply_asset_review_status(asset, "approved")
|
||||
|
||||
assert asset.metadata["generation_use_count"] == 2
|
||||
assert asset.metadata["review_status"] == "approved"
|
||||
Reference in New Issue
Block a user