a1f25a4426
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 6s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (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) Successful in 42s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 49s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
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 / Deploy Staging (Watchtower auto-deploy) (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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (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 / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 37s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m17s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m37s
CI/CD Pipeline / Integration Tests (push) Successful in 2m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m14s
CI/CD Pipeline / Validate - Style (push) Successful in 3m39s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (push) Successful in 4m7s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 46s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m23s
CI/CD Pipeline / Validate - Security (push) Successful in 5m39s
AI Code Review / AI Code Review (pull_request) Successful in 6m42s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m30s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 2m50s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m22s
CI/CD Pipeline / Unit Tests (push) Successful in 10m29s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
139 lines
5.4 KiB
Python
139 lines
5.4 KiB
Python
"""素材原子片段仓储 SQLAlchemy 实现。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
|
||
from sqlalchemy.orm import Session
|
||
|
||
from packages.adapters.sqlalchemy_impl.models import AssetAtomClipModel
|
||
from packages.domain.asset_atom_clip import AssetAtomClip
|
||
|
||
|
||
class SQLAlchemyAssetAtomClipRepository:
|
||
def __init__(self, session: Session):
|
||
self.session = session
|
||
|
||
def create(self, clip: AssetAtomClip) -> AssetAtomClip:
|
||
model = self._to_model(clip)
|
||
self.session.add(model)
|
||
self.session.flush()
|
||
self.session.commit()
|
||
return clip
|
||
|
||
def batch_create(self, clips: list[AssetAtomClip]) -> list[AssetAtomClip]:
|
||
if not clips:
|
||
return []
|
||
models = [self._to_model(c) for c in clips]
|
||
self.session.add_all(models)
|
||
self.session.flush()
|
||
self.session.commit()
|
||
return clips
|
||
|
||
def find_by_asset(self, asset_id: str) -> list[AssetAtomClip]:
|
||
models = (
|
||
self.session.query(AssetAtomClipModel)
|
||
.filter(AssetAtomClipModel.asset_id == asset_id)
|
||
.order_by(AssetAtomClipModel.clip_index.asc())
|
||
.all()
|
||
)
|
||
return [self._to_domain(m) for m in models]
|
||
|
||
def find_by_id(self, clip_id: str) -> AssetAtomClip | None:
|
||
model = self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id == clip_id).first()
|
||
if model is None:
|
||
return None
|
||
return self._to_domain(model)
|
||
|
||
def find_by_ids(self, clip_ids: list[str]) -> list[AssetAtomClip]:
|
||
if not clip_ids:
|
||
return []
|
||
models = self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id.in_(clip_ids)).all()
|
||
return [self._to_domain(m) for m in models]
|
||
|
||
def delete_by_asset(self, asset_id: str) -> int:
|
||
count = (
|
||
self.session.query(AssetAtomClipModel)
|
||
.filter(AssetAtomClipModel.asset_id == asset_id)
|
||
.delete(synchronize_session=False)
|
||
)
|
||
self.session.commit()
|
||
return count
|
||
|
||
def count_by_asset(self, asset_id: str) -> int:
|
||
return self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.asset_id == asset_id).count()
|
||
|
||
def find_candidates_for_selection(
|
||
self,
|
||
asset_ids: list[str],
|
||
*,
|
||
min_duration: float | None = None,
|
||
max_duration: float | None = None,
|
||
limit: int = 100,
|
||
) -> list[AssetAtomClip]:
|
||
"""按筛选条件查找候选原子片段,按时长排序。用于选片逻辑。"""
|
||
query = self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.asset_id.in_(asset_ids))
|
||
if min_duration is not None:
|
||
query = query.filter(AssetAtomClipModel.duration >= min_duration)
|
||
if max_duration is not None:
|
||
query = query.filter(AssetAtomClipModel.duration <= max_duration)
|
||
query = query.order_by(AssetAtomClipModel.clip_index.asc())
|
||
if limit > 0:
|
||
query = query.limit(limit)
|
||
models = query.all()
|
||
return [self._to_domain(m) for m in models]
|
||
|
||
def update_ai_tags(self, clip_id: str, ai_tags: dict) -> bool:
|
||
"""更新指定片段的 ai_tags 字段."""
|
||
count = (
|
||
self.session.query(AssetAtomClipModel).filter(AssetAtomClipModel.id == clip_id).update({"ai_tags": ai_tags})
|
||
)
|
||
self.session.commit()
|
||
return count > 0
|
||
|
||
def find_untagged(self, limit: int = 100, include_downgraded: bool = False) -> list[AssetAtomClip]:
|
||
"""查找未完成 AI 打标的片段,用于回填.
|
||
|
||
默认仅匹配 ai_tags IS NULL;include_downgraded=True 时额外包含
|
||
只有 inherited_tags 的降级记录(视觉 API 失败时写入,无 has_text 字段),
|
||
供强制回填(#1970 force backfill)使用。
|
||
"""
|
||
query = self.session.query(AssetAtomClipModel)
|
||
if include_downgraded:
|
||
# as_string() → JSON/JSONB ->> 取值;NULL 记录或缺 has_text 键
|
||
# (降级记录)均为 NULL,has_text 为 true/false 的完整记录被排除
|
||
query = query.filter(AssetAtomClipModel.ai_tags["has_text"].as_string().is_(None))
|
||
else:
|
||
query = query.filter(AssetAtomClipModel.ai_tags.is_(None))
|
||
models = query.order_by(AssetAtomClipModel.created_at.asc()).limit(limit).all()
|
||
return [self._to_domain(m) for m in models]
|
||
|
||
def _to_model(self, clip: AssetAtomClip) -> AssetAtomClipModel:
|
||
return AssetAtomClipModel(
|
||
id=clip.id,
|
||
asset_id=clip.asset_id,
|
||
start_time=clip.start_time,
|
||
end_time=clip.end_time,
|
||
duration=clip.duration,
|
||
clip_index=clip.clip_index,
|
||
tags=clip.tags,
|
||
ai_tags=clip.ai_tags,
|
||
scene_change_at=clip.scene_change_at,
|
||
is_fallback=clip.is_fallback,
|
||
created_at=clip.created_at or datetime.now(UTC),
|
||
)
|
||
|
||
def _to_domain(self, model: AssetAtomClipModel) -> AssetAtomClip:
|
||
return AssetAtomClip(
|
||
id=model.id,
|
||
asset_id=model.asset_id,
|
||
start_time=model.start_time,
|
||
end_time=model.end_time,
|
||
duration=model.duration,
|
||
clip_index=model.clip_index,
|
||
tags=model.tags or [],
|
||
scene_change_at=model.scene_change_at,
|
||
is_fallback=model.is_fallback,
|
||
created_at=model.created_at,
|
||
)
|