From f2a9b0868b8c006146bf4e80af2c11abb4b04b2f Mon Sep 17 00:00:00 2001 From: Xiaoxia AI Date: Tue, 23 Jun 2026 21:52:47 +0800 Subject: [PATCH] feat(assets): track generation usage review state --- apps/api/app/api/routes/asset_diagnosis.py | 9 +++++ apps/api/app/schemas/asset_diagnosis.py | 3 ++ apps/web/src/api/assets.ts | 3 ++ .../web/src/pages/workspace/ProjectAssets.tsx | 12 ++++++ apps/worker/worker_app/core/asset_usage.py | 10 +++++ apps/worker/worker_app/tasks/generation.py | 4 ++ tests/unit/test_asset_diagnosis.py | 12 +++++- tests/unit/test_generation_asset_usage.py | 40 +++++++++++++++++++ 8 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 apps/worker/worker_app/core/asset_usage.py create mode 100644 tests/unit/test_generation_asset_usage.py diff --git a/apps/api/app/api/routes/asset_diagnosis.py b/apps/api/app/api/routes/asset_diagnosis.py index 9b1ce607e..26a8c6456 100644 --- a/apps/api/app/api/routes/asset_diagnosis.py +++ b/apps/api/app/api/routes/asset_diagnosis.py @@ -45,6 +45,9 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> 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] + 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"] total_duration = round(sum(float(asset.duration or 0) for asset in video_assets), 2) estimated_video_count = max(0, min(len(video_assets), int(total_duration // 5) if total_duration else len(video_assets))) @@ -124,6 +127,9 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> AssetSmartViewItem(key="needs_attention", label="慎用素材", count=len(problem_assets) + 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 素材"), + AssetSmartViewItem(key="used", label="已使用素材", count=len(used_assets), description="已经参与过生成的素材"), + AssetSmartViewItem(key="pending_review", label="待复核素材", count=len(pending_review_assets), description="生成后待人工复核的素材"), AssetSmartViewItem(key="voice", label="配音素材", count=len(voice_assets), description="可用于后续配音/旁白工作流的素材"), ] @@ -139,6 +145,9 @@ def _build_diagnosis(workspace_id: str, project_id: str, assets: list[Asset]) -> voice_assets=len(voice_assets), total_duration_seconds=total_duration, estimated_video_count=estimated_video_count, + used_assets=len(used_assets), + unused_assets=len(unused_assets), + pending_review_assets=len(pending_review_assets), smart_views=smart_views, gaps=gaps, ) diff --git a/apps/api/app/schemas/asset_diagnosis.py b/apps/api/app/schemas/asset_diagnosis.py index a167ed556..95b8684a8 100644 --- a/apps/api/app/schemas/asset_diagnosis.py +++ b/apps/api/app/schemas/asset_diagnosis.py @@ -27,5 +27,8 @@ class ProjectAssetDiagnosisResponse(BaseModel): voice_assets: int total_duration_seconds: float estimated_video_count: int + used_assets: int + unused_assets: int + pending_review_assets: int smart_views: list[AssetSmartViewItem] gaps: list[AssetGapItem] diff --git a/apps/web/src/api/assets.ts b/apps/web/src/api/assets.ts index 249f3c2e7..e8e8961fc 100644 --- a/apps/web/src/api/assets.ts +++ b/apps/web/src/api/assets.ts @@ -62,6 +62,9 @@ export interface ProjectAssetDiagnosis { voice_assets: number; total_duration_seconds: number; estimated_video_count: number; + used_assets: number; + unused_assets: number; + pending_review_assets: number; smart_views: Array<{ key: string; label: string; count: number; description: string }>; gaps: Array<{ key: string; severity: 'critical' | 'warning' | 'info'; message: string; recommendation: string }>; } diff --git a/apps/web/src/pages/workspace/ProjectAssets.tsx b/apps/web/src/pages/workspace/ProjectAssets.tsx index 0903e3db8..28a63ec20 100644 --- a/apps/web/src/pages/workspace/ProjectAssets.tsx +++ b/apps/web/src/pages/workspace/ProjectAssets.tsx @@ -290,6 +290,12 @@ const ProjectAssets: React.FC = () => { items = items.filter((item) => !item.metadata?.classification || item.classification_status === 'failed'); } else if (smartViewFilter === 'voice') { items = items.filter((item) => item.mime_type?.startsWith('audio')); + } else if (smartViewFilter === 'unused') { + items = items.filter((item) => item.status === 'ready' && Number(item.metadata?.generation_use_count || 0) === 0); + } else if (smartViewFilter === 'used') { + items = items.filter((item) => Number(item.metadata?.generation_use_count || 0) > 0); + } else if (smartViewFilter === 'pending_review') { + items = items.filter((item) => item.metadata?.review_status === 'pending_review'); } if (classificationFilter !== 'all') { @@ -505,6 +511,9 @@ const ProjectAssets: React.FC = () => { { label: '慎用素材', value: 'needs_attention' }, { label: '未分类素材', value: 'unclassified' }, { label: '最近上传', value: 'recent' }, + { label: '未使用素材', value: 'unused' }, + { label: '已使用素材', value: 'used' }, + { label: '待复核素材', value: 'pending_review' }, { label: '配音素材', value: 'voice' }, ]} onChange={setSmartViewFilter} @@ -608,6 +617,9 @@ const ProjectAssets: React.FC = () => { 图片 {diagnosisQuery.data.image_assets} 配音 {diagnosisQuery.data.voice_assets} 预计可生成 {diagnosisQuery.data.estimated_video_count} + 未使用 {diagnosisQuery.data.unused_assets} + 已使用 {diagnosisQuery.data.used_assets} + 待复核 {diagnosisQuery.data.pending_review_assets}
diff --git a/apps/worker/worker_app/core/asset_usage.py b/apps/worker/worker_app/core/asset_usage.py new file mode 100644 index 000000000..891e0a24c --- /dev/null +++ b/apps/worker/worker_app/core/asset_usage.py @@ -0,0 +1,10 @@ +from datetime import datetime, timezone + + +def mark_asset_used_for_generation(asset) -> None: + asset.metadata = { + **asset.metadata, + "generation_use_count": int(asset.metadata.get("generation_use_count") or 0) + 1, + "last_used_at": datetime.now(timezone.utc).isoformat(), + "review_status": asset.metadata.get("review_status") or "pending_review", + } diff --git a/apps/worker/worker_app/tasks/generation.py b/apps/worker/worker_app/tasks/generation.py index aee7c589f..47e3e38fb 100644 --- a/apps/worker/worker_app/tasks/generation.py +++ b/apps/worker/worker_app/tasks/generation.py @@ -8,6 +8,7 @@ from pathlib import Path from urllib.parse import urlparse import oss2 +from worker_app.core.asset_usage import mark_asset_used_for_generation from worker_app.celery_app import celery_app from worker_app.db import SessionLocal @@ -214,6 +215,9 @@ def generate_video(task_id: str) -> dict: if source_downloaded: _compose_from_asset(input_path, output_path) + source_asset = assets[0] + mark_asset_used_for_generation(source_asset) + asset_repo.update(source_asset) else: _create_fallback_clip(output_path, f"Xiaoxia Generated Video {task.id[:8]}") diff --git a/tests/unit/test_asset_diagnosis.py b/tests/unit/test_asset_diagnosis.py index ed62c46ad..07633de97 100644 --- a/tests/unit/test_asset_diagnosis.py +++ b/tests/unit/test_asset_diagnosis.py @@ -36,11 +36,13 @@ def test_asset_diagnosis_reports_missing_video_gap(): def test_asset_diagnosis_scores_ready_video_assets(): + used_asset = _asset("video-1.mp4", "video/mp4", duration=8) + used_asset.metadata = {"generation_use_count": 1, "review_status": "pending_review"} diagnosis = _build_diagnosis( "workspace-1", "project-1", [ - _asset("video-1.mp4", "video/mp4", duration=8), + used_asset, _asset("video-2.mp4", "video/mp4", duration=8), _asset("video-3.mov", "video/quicktime", duration=8), _asset("voice.mp3", "audio/mpeg"), @@ -51,7 +53,13 @@ def test_asset_diagnosis_scores_ready_video_assets(): assert diagnosis.video_assets == 3 assert diagnosis.voice_assets == 1 assert diagnosis.estimated_video_count == 3 - assert {item.key: item.count for item in diagnosis.smart_views}["recommended"] == 3 + assert diagnosis.used_assets == 1 + assert diagnosis.unused_assets == 3 + assert diagnosis.pending_review_assets == 1 + smart_view_counts = {item.key: item.count for item in diagnosis.smart_views} + assert smart_view_counts["recommended"] == 3 + assert smart_view_counts["used"] == 1 + assert smart_view_counts["pending_review"] == 1 def test_asset_diagnosis_flags_unready_and_low_quality_assets(): diff --git a/tests/unit/test_generation_asset_usage.py b/tests/unit/test_generation_asset_usage.py new file mode 100644 index 000000000..c8970825b --- /dev/null +++ b/tests/unit/test_generation_asset_usage.py @@ -0,0 +1,40 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "worker")) + +from packages.domain import Asset, AssetStatus +from worker_app.core.asset_usage import mark_asset_used_for_generation + + +def _asset() -> Asset: + return 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, + ) + + +def test_mark_asset_used_for_generation_sets_first_review_state(): + asset = _asset() + + mark_asset_used_for_generation(asset) + + assert asset.metadata["generation_use_count"] == 1 + assert asset.metadata["review_status"] == "pending_review" + assert asset.metadata["last_used_at"] + + +def test_mark_asset_used_for_generation_increments_without_overwriting_review_state(): + asset = _asset() + asset.metadata = {"generation_use_count": 2, "review_status": "approved"} + + mark_asset_used_for_generation(asset) + + assert asset.metadata["generation_use_count"] == 3 + assert asset.metadata["review_status"] == "approved"