feat: 完善代理商端业务逻辑与前后端框架

主要更新:
- 更新代理商端文档,明确项目由品牌方分配流程
- 新增Brief配置详情页(已配置)设计稿
- 完善工作台紧急待办中品牌新任务功能
- 整理Pencil设计文件中代理商端页面顺序
- 新增后端FastAPI框架及核心API
- 新增前端Next.js页面和组件库
- 添加.gitignore排除构建和缓存文件

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-02-05 19:27:31 +08:00
co-authored by Claude Opus 4.5
parent d52509d630
commit e4959d584f
132 changed files with 58539 additions and 21353 deletions
+54
View File
@@ -0,0 +1,54 @@
"""服务层模块"""
from typing import Optional, Any
_openai_import_error: Optional[Exception] = None
try:
from app.services.ai_client import OpenAICompatibleClient, AIResponse, ConnectionTestResult
from app.services.ai_service import AIServiceFactory, get_ai_client_for_tenant
except ModuleNotFoundError as exc: # openai 依赖缺失时允许非 AI 路径正常导入
_openai_import_error = exc
OpenAICompatibleClient = None
AIResponse = None
ConnectionTestResult = None
AIServiceFactory = None
def get_ai_client_for_tenant(*_args: Any, **_kwargs: Any) -> Any:
raise ModuleNotFoundError(
"Optional dependency 'openai' is required for AI client usage."
) from _openai_import_error
# 视频处理服务(无外部依赖)
from app.services.video_download import VideoDownloadService, DownloadResult, get_download_service
from app.services.keyframe import KeyFrameExtractor, KeyFrame, ExtractionResult, get_keyframe_extractor
from app.services.asr import ASRService, VideoASRService, TranscriptionResult
from app.services.vision import VisionAnalysisService, CompetitorLogoDetector, VideoOCRService
from app.services.video_review import VideoReviewService
__all__ = [
# AI 客户端
"OpenAICompatibleClient",
"AIResponse",
"ConnectionTestResult",
"AIServiceFactory",
"get_ai_client_for_tenant",
# 视频下载
"VideoDownloadService",
"DownloadResult",
"get_download_service",
# 关键帧提取
"KeyFrameExtractor",
"KeyFrame",
"ExtractionResult",
"get_keyframe_extractor",
# ASR
"ASRService",
"VideoASRService",
"TranscriptionResult",
# 视觉分析
"VisionAnalysisService",
"CompetitorLogoDetector",
"VideoOCRService",
# 视频审核
"VideoReviewService",
]
+335
View File
@@ -0,0 +1,335 @@
"""
OpenAI 兼容 AI 客户端
支持多种 AI 提供商的统一接口
"""
import asyncio
import time
from typing import Optional
from dataclasses import dataclass
import httpx
from openai import AsyncOpenAI
from app.schemas.ai_config import AIProvider, ModelCapability
@dataclass
class AIResponse:
"""AI 响应"""
content: str
model: str
usage: dict
finish_reason: str
@dataclass
class ConnectionTestResult:
"""连接测试结果"""
success: bool
latency_ms: int
error: Optional[str] = None
class OpenAICompatibleClient:
"""
OpenAI 兼容 API 客户端
支持:
- OpenAI
- Azure OpenAI
- Anthropic (通过 OpenAI 兼容层)
- DeepSeek
- Qwen (通义千问)
- Doubao (豆包)
- 各种中转服务 (OneAPI, OpenRouter)
"""
def __init__(
self,
base_url: str,
api_key: str,
provider: str = "openai",
timeout: float = 60.0,
):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.provider = provider
self.timeout = timeout
# 创建 OpenAI 客户端
self.client = AsyncOpenAI(
base_url=self.base_url,
api_key=self.api_key,
timeout=timeout,
)
async def chat_completion(
self,
messages: list[dict],
model: str,
temperature: float = 0.7,
max_tokens: int = 2000,
**kwargs,
) -> AIResponse:
"""
聊天补全
Args:
messages: 消息列表 [{"role": "user", "content": "..."}]
model: 模型名称
temperature: 温度参数
max_tokens: 最大 token 数
Returns:
AIResponse 包含生成的内容
"""
response = await self.client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
**kwargs,
)
choice = response.choices[0]
return AIResponse(
content=choice.message.content or "",
model=response.model,
usage={
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
"total_tokens": response.usage.total_tokens if response.usage else 0,
},
finish_reason=choice.finish_reason or "stop",
)
async def vision_analysis(
self,
image_urls: list[str],
prompt: str,
model: str,
temperature: float = 0.3,
max_tokens: int = 2000,
) -> AIResponse:
"""
视觉分析(图像理解)
Args:
image_urls: 图像 URL 列表
prompt: 分析提示
model: 视觉模型名称
Returns:
AIResponse 包含分析结果
"""
# 构建多模态消息
content = [{"type": "text", "text": prompt}]
for url in image_urls:
content.append({
"type": "image_url",
"image_url": {"url": url},
})
messages = [{"role": "user", "content": content}]
return await self.chat_completion(
messages=messages,
model=model,
temperature=temperature,
max_tokens=max_tokens,
)
async def audio_transcription(
self,
audio_url: str,
model: str = "whisper-1",
language: str = "zh",
) -> AIResponse:
"""
音频转写 (ASR)
Args:
audio_url: 音频文件 URL
model: 转写模型
language: 语言代码
Returns:
AIResponse 包含转写文本
"""
# 下载音频文件
async with httpx.AsyncClient() as http_client:
response = await http_client.get(audio_url, timeout=30)
response.raise_for_status()
audio_data = response.content
# 调用 Whisper API
transcription = await self.client.audio.transcriptions.create(
model=model,
file=("audio.mp3", audio_data, "audio/mpeg"),
language=language,
)
return AIResponse(
content=transcription.text,
model=model,
usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
finish_reason="stop",
)
async def test_connection(
self,
model: str,
capability: ModelCapability = ModelCapability.TEXT,
) -> ConnectionTestResult:
"""
测试模型连接
Args:
model: 模型名称
capability: 模型能力类型
Returns:
ConnectionTestResult 包含测试结果
"""
start_time = time.time()
try:
if capability == ModelCapability.AUDIO:
# 音频模型无法简单测试,只验证 API 可达
async with httpx.AsyncClient() as http_client:
response = await http_client.get(
f"{self.base_url}/models",
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=10,
)
response.raise_for_status()
latency_ms = int((time.time() - start_time) * 1000)
return ConnectionTestResult(success=True, latency_ms=latency_ms)
elif capability == ModelCapability.VISION:
# 视觉模型测试:发送简单的文本请求
response = await self.chat_completion(
messages=[{"role": "user", "content": "Hi"}],
model=model,
max_tokens=5,
)
else:
# 文本模型测试
response = await self.chat_completion(
messages=[{"role": "user", "content": "Hi"}],
model=model,
max_tokens=5,
)
latency_ms = int((time.time() - start_time) * 1000)
return ConnectionTestResult(success=True, latency_ms=latency_ms)
except Exception as e:
latency_ms = int((time.time() - start_time) * 1000)
return ConnectionTestResult(
success=False,
latency_ms=latency_ms,
error=str(e),
)
async def list_models(self) -> dict[str, list[dict]]:
"""
获取可用模型列表
Returns:
按能力分类的模型列表
{"text": [...], "vision": [...], "audio": [...]}
"""
try:
models = await self.client.models.list()
# 已知模型能力映射
known_capabilities = {
# OpenAI
"gpt-4o": ["text", "vision"],
"gpt-4o-mini": ["text", "vision"],
"gpt-4-turbo": ["text", "vision"],
"gpt-4": ["text"],
"gpt-3.5-turbo": ["text"],
"whisper-1": ["audio"],
# Claude (通过兼容层)
"claude-3-opus": ["text", "vision"],
"claude-3-sonnet": ["text", "vision"],
"claude-3-haiku": ["text", "vision"],
# DeepSeek
"deepseek-chat": ["text"],
"deepseek-coder": ["text"],
# Qwen
"qwen-turbo": ["text"],
"qwen-plus": ["text"],
"qwen-max": ["text"],
"qwen-vl-plus": ["vision"],
"qwen-vl-max": ["vision"],
# Doubao
"doubao-pro": ["text"],
"doubao-lite": ["text"],
}
result: dict[str, list[dict]] = {
"text": [],
"vision": [],
"audio": [],
}
for model in models.data:
model_id = model.id
capabilities = known_capabilities.get(model_id, ["text"])
for cap in capabilities:
if cap in result:
result[cap].append({
"id": model_id,
"name": model_id.replace("-", " ").title(),
})
return result
except Exception:
# 如果无法获取模型列表,返回预设列表
return {
"text": [
{"id": "gpt-4o", "name": "GPT-4o"},
{"id": "gpt-4o-mini", "name": "GPT-4o Mini"},
{"id": "deepseek-chat", "name": "DeepSeek Chat"},
],
"vision": [
{"id": "gpt-4o", "name": "GPT-4o"},
{"id": "qwen-vl-max", "name": "Qwen VL Max"},
],
"audio": [
{"id": "whisper-1", "name": "Whisper"},
],
}
async def close(self):
"""关闭客户端"""
try:
await self.client.close()
except Exception:
# 关闭失败不应影响主流程
pass
# 便捷函数
async def create_ai_client(
base_url: str,
api_key: str,
provider: str = "openai",
) -> OpenAICompatibleClient:
"""创建 AI 客户端"""
return OpenAICompatibleClient(
base_url=base_url,
api_key=api_key,
provider=provider,
)
+182
View File
@@ -0,0 +1,182 @@
"""
AI 服务工厂
根据租户配置创建和管理 AI 客户端
"""
from typing import Optional
from cachetools import TTLCache
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai_config import AIConfig
from app.services.ai_client import OpenAICompatibleClient
from app.utils.crypto import decrypt_api_key
class AIServiceFactory:
"""
AI 服务工厂
根据租户的 AI 配置创建对应的 AI 客户端
使用 TTL 缓存避免频繁创建客户端
"""
# 客户端缓存,TTL 10 分钟
_cache: TTLCache = TTLCache(maxsize=100, ttl=600)
@classmethod
async def get_client(
cls,
tenant_id: str,
db: AsyncSession,
) -> Optional[OpenAICompatibleClient]:
"""
获取租户的 AI 客户端
Args:
tenant_id: 租户 ID
db: 数据库会话
Returns:
AI 客户端实例,未配置返回 None
"""
# 检查缓存
cache_key = f"ai_client:{tenant_id}"
if cache_key in cls._cache:
return cls._cache[cache_key]
# 从数据库获取配置
result = await db.execute(
select(AIConfig).where(
AIConfig.tenant_id == tenant_id,
AIConfig.is_configured == True,
)
)
config = result.scalar_one_or_none()
if not config:
return None
# 解密 API Key
api_key = decrypt_api_key(config.api_key_encrypted)
# 创建客户端
client = OpenAICompatibleClient(
base_url=config.base_url,
api_key=api_key,
provider=config.provider,
)
# 缓存客户端
cls._cache[cache_key] = client
return client
@classmethod
def invalidate_cache(cls, tenant_id: str) -> None:
"""
使缓存失效
当租户更新 AI 配置时调用
"""
cache_key = f"ai_client:{tenant_id}"
if cache_key in cls._cache:
del cls._cache[cache_key]
@classmethod
def clear_cache(cls) -> None:
"""清空所有缓存"""
cls._cache.clear()
@classmethod
async def get_config(
cls,
tenant_id: str,
db: AsyncSession,
) -> Optional[AIConfig]:
"""
获取租户的 AI 配置
Args:
tenant_id: 租户 ID
db: 数据库会话
Returns:
AI 配置模型,未配置返回 None
"""
result = await db.execute(
select(AIConfig).where(AIConfig.tenant_id == tenant_id)
)
return result.scalar_one_or_none()
@classmethod
async def create_or_update_config(
cls,
tenant_id: str,
provider: str,
base_url: str,
api_key_encrypted: str,
models: dict,
temperature: float,
max_tokens: int,
db: AsyncSession,
) -> AIConfig:
"""
创建或更新 AI 配置
Args:
tenant_id: 租户 ID
provider: 提供商
base_url: API 地址
api_key_encrypted: 加密的 API Key
models: 模型配置
temperature: 温度参数
max_tokens: 最大 token 数
db: 数据库会话
Returns:
更新后的配置
"""
# 查找现有配置
result = await db.execute(
select(AIConfig).where(AIConfig.tenant_id == tenant_id)
)
config = result.scalar_one_or_none()
if config:
# 更新现有配置
config.provider = provider
config.base_url = base_url
config.api_key_encrypted = api_key_encrypted
config.models = models
config.temperature = temperature
config.max_tokens = max_tokens
config.is_configured = True
else:
# 创建新配置
config = AIConfig(
tenant_id=tenant_id,
provider=provider,
base_url=base_url,
api_key_encrypted=api_key_encrypted,
models=models,
temperature=temperature,
max_tokens=max_tokens,
is_configured=True,
)
db.add(config)
await db.flush()
# 使缓存失效
cls.invalidate_cache(tenant_id)
return config
# 便捷函数
async def get_ai_client_for_tenant(
tenant_id: str,
db: AsyncSession,
) -> Optional[OpenAICompatibleClient]:
"""获取租户的 AI 客户端"""
return await AIServiceFactory.get_client(tenant_id, db)
+310
View File
@@ -0,0 +1,310 @@
"""
ASR 语音转写服务
集成 Whisper API 实现音频转写
"""
import asyncio
import os
import tempfile
from dataclasses import dataclass, field
from typing import Optional
import httpx
@dataclass
class TranscriptSegment:
"""转写片段"""
text: str
start: float # 开始时间(秒)
end: float # 结束时间(秒)
confidence: float = 1.0
@dataclass
class TranscriptionResult:
"""转写结果"""
success: bool
text: str = "" # 完整文本
segments: list[TranscriptSegment] = field(default_factory=list)
language: str = "zh"
duration: float = 0.0
error: Optional[str] = None
class ASRService:
"""ASR 语音转写服务"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.openai.com/v1",
model: str = "whisper-1",
timeout: float = 300.0,
):
"""
初始化 ASR 服务
Args:
api_key: API Key
base_url: API 基础 URL
model: 模型名称
timeout: 请求超时(秒)
"""
self.api_key = api_key
self.base_url = base_url.rstrip("/")
self.model = model
self.timeout = timeout
async def transcribe_file(
self,
audio_path: str,
language: str = "zh",
response_format: str = "verbose_json",
) -> TranscriptionResult:
"""
转写音频文件
Args:
audio_path: 音频文件路径
language: 语言代码
response_format: 响应格式
Returns:
TranscriptionResult: 转写结果
"""
if not os.path.exists(audio_path):
return TranscriptionResult(
success=False,
error=f"文件不存在: {audio_path}",
)
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout)
) as client:
with open(audio_path, "rb") as f:
files = {"file": (os.path.basename(audio_path), f, "audio/mpeg")}
data = {
"model": self.model,
"language": language,
"response_format": response_format,
}
response = await client.post(
f"{self.base_url}/audio/transcriptions",
headers={"Authorization": f"Bearer {self.api_key}"},
files=files,
data=data,
)
if response.status_code != 200:
return TranscriptionResult(
success=False,
error=f"API 错误 {response.status_code}: {response.text[:200]}",
)
result = response.json()
return self._parse_response(result, language)
except Exception as e:
return TranscriptionResult(
success=False,
error=str(e),
)
async def transcribe_url(
self,
audio_url: str,
language: str = "zh",
) -> TranscriptionResult:
"""
转写远程音频
Args:
audio_url: 音频 URL
language: 语言代码
Returns:
TranscriptionResult: 转写结果
"""
# 下载音频到临时文件
temp_path = None
try:
async with httpx.AsyncClient(
timeout=httpx.Timeout(60.0),
follow_redirects=True,
) as client:
response = await client.get(audio_url)
if response.status_code != 200:
return TranscriptionResult(
success=False,
error=f"下载音频失败: HTTP {response.status_code}",
)
# 写入临时文件
with tempfile.NamedTemporaryFile(
suffix=".mp3",
delete=False,
) as f:
f.write(response.content)
temp_path = f.name
# 转写
result = await self.transcribe_file(temp_path, language)
return result
except Exception as e:
return TranscriptionResult(
success=False,
error=str(e),
)
finally:
# 清理临时文件
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
def _parse_response(
self,
response: dict,
language: str,
) -> TranscriptionResult:
"""解析 API 响应"""
text = response.get("text", "")
duration = response.get("duration", 0.0)
segments = []
for seg in response.get("segments", []):
segments.append(TranscriptSegment(
text=seg.get("text", "").strip(),
start=seg.get("start", 0.0),
end=seg.get("end", 0.0),
confidence=seg.get("confidence", 1.0) if "confidence" in seg else 1.0,
))
# 如果没有分段信息,创建单个分段
if not segments and text:
segments = [TranscriptSegment(
text=text,
start=0.0,
end=duration,
)]
return TranscriptionResult(
success=True,
text=text,
segments=segments,
language=language,
duration=duration,
)
class AudioExtractor:
"""从视频中提取音频"""
def __init__(self, ffmpeg_path: str = "ffmpeg"):
self.ffmpeg_path = ffmpeg_path
async def extract_audio(
self,
video_path: str,
output_path: Optional[str] = None,
format: str = "mp3",
sample_rate: int = 16000,
) -> Optional[str]:
"""
从视频中提取音频
Args:
video_path: 视频文件路径
output_path: 输出路径,默认生成临时文件
format: 输出格式
sample_rate: 采样率
Returns:
音频文件路径,失败返回 None
"""
import shutil
if not shutil.which(self.ffmpeg_path):
return None
if output_path is None:
output_path = tempfile.mktemp(suffix=f".{format}")
cmd = [
self.ffmpeg_path,
"-i", video_path,
"-vn", # 不要视频
"-acodec", "libmp3lame" if format == "mp3" else "pcm_s16le",
"-ar", str(sample_rate),
"-ac", "1", # 单声道
"-y",
output_path,
]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await process.communicate()
if process.returncode != 0:
return None
return output_path
except Exception:
return None
class VideoASRService:
"""视频 ASR 服务(组合音频提取和转写)"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.openai.com/v1",
model: str = "whisper-1",
):
self.asr = ASRService(api_key, base_url, model)
self.audio_extractor = AudioExtractor()
async def transcribe_video(
self,
video_path: str,
language: str = "zh",
) -> TranscriptionResult:
"""
转写视频中的语音
Args:
video_path: 视频文件路径
language: 语言代码
Returns:
TranscriptionResult: 转写结果
"""
# 提取音频
audio_path = await self.audio_extractor.extract_audio(video_path)
if not audio_path:
return TranscriptionResult(
success=False,
error="音频提取失败,请确保 FFmpeg 已安装",
)
try:
# 转写
result = await self.asr.transcribe_file(audio_path, language)
return result
finally:
# 清理临时音频
if os.path.exists(audio_path):
try:
os.remove(audio_path)
except OSError:
pass
+138
View File
@@ -0,0 +1,138 @@
"""
健康检查服务
提供依赖注入接口,便于测试 mock
"""
from typing import Protocol, Optional
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
class HealthChecker(Protocol):
"""健康检查协议(用于类型提示)"""
async def check_database(self) -> bool:
"""检查数据库连接"""
...
async def check_redis(self) -> bool:
"""检查 Redis 连接"""
...
async def check_all(self) -> dict[str, bool]:
"""检查所有依赖"""
...
class DefaultHealthChecker:
"""
默认健康检查实现
生产环境使用,检查真实依赖
"""
# 默认连接超时(秒)
DEFAULT_CONNECT_TIMEOUT = 5
def __init__(
self,
db_engine: Optional[AsyncEngine] = None,
redis_url: Optional[str] = None,
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
):
self._db_engine = db_engine
self._redis_url = redis_url
self._connect_timeout = connect_timeout
async def check_database(self) -> bool:
"""
检查数据库连接
Returns:
bool: 数据库是否可用
"""
if self._db_engine is None:
# 未配置数据库引擎,尝试从全局获取
try:
from app.database import engine
self._db_engine = engine
except Exception:
return False
try:
async with self._db_engine.connect() as conn:
await conn.execute(text("SELECT 1"))
return True
except Exception:
return False
async def check_redis(self) -> bool:
"""
检查 Redis 连接
Returns:
bool: Redis 是否可用
"""
if self._redis_url is None:
# 未配置 Redis URL,尝试从配置获取
try:
from app.config import settings
self._redis_url = settings.REDIS_URL
except Exception:
return False
try:
import redis.asyncio as aioredis
client = aioredis.from_url(
self._redis_url,
socket_connect_timeout=self._connect_timeout
)
try:
await client.ping()
return True
finally:
await client.aclose()
except Exception:
return False
async def check_all(self) -> dict[str, bool]:
"""检查所有依赖"""
return {
"database": await self.check_database(),
"redis": await self.check_redis(),
}
class MockHealthChecker:
"""
Mock 健康检查实现
测试环境使用,可配置返回值
"""
def __init__(
self,
database_healthy: bool = True,
redis_healthy: bool = True,
):
self._database_healthy = database_healthy
self._redis_healthy = redis_healthy
async def check_database(self) -> bool:
return self._database_healthy
async def check_redis(self) -> bool:
return self._redis_healthy
async def check_all(self) -> dict[str, bool]:
return {
"database": self._database_healthy,
"redis": self._redis_healthy,
}
def get_health_checker() -> HealthChecker:
"""
获取健康检查器依赖
生产环境返回 DefaultHealthChecker(检查真实依赖)
测试环境通过 app.dependency_overrides 替换
"""
return DefaultHealthChecker()
+353
View File
@@ -0,0 +1,353 @@
"""
关键帧提取服务
使用 FFmpeg 从视频中提取关键帧用于视觉分析
"""
import asyncio
import base64
import os
import shutil
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@dataclass
class KeyFrame:
"""关键帧数据"""
timestamp: float # 时间戳(秒)
file_path: str # 帧图片路径
width: int = 0
height: int = 0
def to_base64(self) -> str:
"""将帧图片转为 base64"""
with open(self.file_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def to_data_url(self) -> str:
"""将帧图片转为 data URL"""
return f"data:image/jpeg;base64,{self.to_base64()}"
@dataclass
class ExtractionResult:
"""提取结果"""
success: bool
frames: list[KeyFrame] = field(default_factory=list)
video_duration: float = 0.0
error: Optional[str] = None
output_dir: Optional[str] = None
class KeyFrameExtractor:
"""关键帧提取器"""
def __init__(
self,
ffmpeg_path: str = "ffmpeg",
ffprobe_path: str = "ffprobe",
output_format: str = "jpg",
quality: int = 2, # 1-31, 越小质量越高
):
"""
初始化提取器
Args:
ffmpeg_path: ffmpeg 可执行文件路径
ffprobe_path: ffprobe 可执行文件路径
output_format: 输出格式 (jpg/png)
quality: JPEG 质量 (1-31)
"""
self.ffmpeg_path = ffmpeg_path
self.ffprobe_path = ffprobe_path
self.output_format = output_format
self.quality = quality
def _check_ffmpeg(self) -> bool:
"""检查 FFmpeg 是否可用"""
return shutil.which(self.ffmpeg_path) is not None
async def get_video_info(self, video_path: str) -> dict:
"""
获取视频信息
Args:
video_path: 视频文件路径
Returns:
视频信息字典
"""
cmd = [
self.ffprobe_path,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
video_path,
]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await process.communicate()
import json
info = json.loads(stdout.decode())
# 提取关键信息
duration = float(info.get("format", {}).get("duration", 0))
video_stream = next(
(s for s in info.get("streams", []) if s.get("codec_type") == "video"),
{}
)
return {
"duration": duration,
"width": video_stream.get("width", 0),
"height": video_stream.get("height", 0),
"fps": eval(video_stream.get("r_frame_rate", "0/1")) if "/" in video_stream.get("r_frame_rate", "0") else 0,
"codec": video_stream.get("codec_name", ""),
}
except Exception as e:
return {"error": str(e), "duration": 0}
async def extract_at_intervals(
self,
video_path: str,
interval_seconds: float = 1.0,
max_frames: int = 60,
output_dir: Optional[str] = None,
) -> ExtractionResult:
"""
按时间间隔提取帧
Args:
video_path: 视频文件路径
interval_seconds: 提取间隔(秒)
max_frames: 最大帧数
output_dir: 输出目录,默认创建临时目录
Returns:
ExtractionResult: 提取结果
"""
if not self._check_ffmpeg():
return ExtractionResult(
success=False,
error="FFmpeg 未安装或不在 PATH 中",
)
# 获取视频信息
video_info = await self.get_video_info(video_path)
duration = video_info.get("duration", 0)
if duration <= 0:
return ExtractionResult(
success=False,
error="无法获取视频时长",
)
# 创建输出目录
if output_dir is None:
output_dir = tempfile.mkdtemp(prefix="keyframes_")
else:
Path(output_dir).mkdir(parents=True, exist_ok=True)
# 计算实际帧数
frame_count = min(int(duration / interval_seconds), max_frames)
if frame_count <= 0:
frame_count = 1
# 使用 FFmpeg 提取帧
output_pattern = os.path.join(output_dir, f"frame_%04d.{self.output_format}")
cmd = [
self.ffmpeg_path,
"-i", video_path,
"-vf", f"fps=1/{interval_seconds}",
"-frames:v", str(frame_count),
"-q:v", str(self.quality),
"-y",
output_pattern,
]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await process.communicate()
if process.returncode != 0:
return ExtractionResult(
success=False,
error=f"FFmpeg 错误: {stderr.decode()[:200]}",
output_dir=output_dir,
)
# 收集提取的帧
frames = []
for i in range(1, frame_count + 1):
frame_path = os.path.join(output_dir, f"frame_{i:04d}.{self.output_format}")
if os.path.exists(frame_path):
timestamp = (i - 1) * interval_seconds
frames.append(KeyFrame(
timestamp=timestamp,
file_path=frame_path,
width=video_info.get("width", 0),
height=video_info.get("height", 0),
))
return ExtractionResult(
success=True,
frames=frames,
video_duration=duration,
output_dir=output_dir,
)
except Exception as e:
return ExtractionResult(
success=False,
error=str(e),
output_dir=output_dir,
)
async def extract_scene_changes(
self,
video_path: str,
threshold: float = 0.3,
max_frames: int = 30,
output_dir: Optional[str] = None,
) -> ExtractionResult:
"""
基于场景变化提取关键帧
Args:
video_path: 视频文件路径
threshold: 场景变化阈值 (0-1)
max_frames: 最大帧数
output_dir: 输出目录
Returns:
ExtractionResult: 提取结果
"""
if not self._check_ffmpeg():
return ExtractionResult(
success=False,
error="FFmpeg 未安装或不在 PATH 中",
)
video_info = await self.get_video_info(video_path)
duration = video_info.get("duration", 0)
if output_dir is None:
output_dir = tempfile.mkdtemp(prefix="keyframes_")
else:
Path(output_dir).mkdir(parents=True, exist_ok=True)
output_pattern = os.path.join(output_dir, f"scene_%04d.{self.output_format}")
# 使用场景检测滤镜
cmd = [
self.ffmpeg_path,
"-i", video_path,
"-vf", f"select='gt(scene,{threshold})',showinfo",
"-vsync", "vfr",
"-frames:v", str(max_frames),
"-q:v", str(self.quality),
"-y",
output_pattern,
]
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await process.communicate()
# 解析时间戳
timestamps = []
for line in stderr.decode().split("\n"):
if "pts_time:" in line:
try:
pts_part = line.split("pts_time:")[1].split()[0]
timestamps.append(float(pts_part))
except (IndexError, ValueError):
pass
# 收集帧
frames = []
for i, ts in enumerate(timestamps[:max_frames], 1):
frame_path = os.path.join(output_dir, f"scene_{i:04d}.{self.output_format}")
if os.path.exists(frame_path):
frames.append(KeyFrame(
timestamp=ts,
file_path=frame_path,
width=video_info.get("width", 0),
height=video_info.get("height", 0),
))
# 如果场景检测帧太少,补充均匀采样
if len(frames) < 5 and duration > 0:
interval_result = await self.extract_at_intervals(
video_path,
interval_seconds=duration / 10,
max_frames=10,
output_dir=output_dir,
)
if interval_result.success:
# 合并并去重
existing_ts = {f.timestamp for f in frames}
for f in interval_result.frames:
if f.timestamp not in existing_ts:
frames.append(f)
frames.sort(key=lambda x: x.timestamp)
return ExtractionResult(
success=True,
frames=frames[:max_frames],
video_duration=duration,
output_dir=output_dir,
)
except Exception as e:
return ExtractionResult(
success=False,
error=str(e),
output_dir=output_dir,
)
def cleanup(self, output_dir: str) -> bool:
"""
清理提取的临时文件
Args:
output_dir: 输出目录
Returns:
是否成功删除
"""
try:
if os.path.exists(output_dir):
shutil.rmtree(output_dir)
return True
except OSError:
pass
return False
# 全局实例
_extractor: Optional[KeyFrameExtractor] = None
def get_keyframe_extractor() -> KeyFrameExtractor:
"""获取关键帧提取器单例"""
global _extractor
if _extractor is None:
_extractor = KeyFrameExtractor()
return _extractor
+46
View File
@@ -0,0 +1,46 @@
"""
风险分类服务
根据违规类型判断风险等级
"""
from app.schemas.review import ViolationType, RiskLevel
def classify_risk_level(violation_type: ViolationType) -> RiskLevel:
"""
根据违规类型分类风险等级
规则:
- 高风险 (HIGH): 法律违规(广告法极限词、功效宣称)
- 中风险 (MEDIUM): 平台规则违规(竞品露出、时长不足)
- 低风险 (LOW): 品牌规范违规(品牌提及不足)
Args:
violation_type: 违规类型
Returns:
RiskLevel: 风险等级
"""
high_risk_types = {
ViolationType.FORBIDDEN_WORD,
ViolationType.EFFICACY_CLAIM,
}
medium_risk_types = {
ViolationType.COMPETITOR_LOGO,
ViolationType.DURATION_SHORT,
ViolationType.BRAND_SAFETY,
}
low_risk_types = {
ViolationType.MENTION_MISSING,
}
if violation_type in high_risk_types:
return RiskLevel.HIGH
elif violation_type in medium_risk_types:
return RiskLevel.MEDIUM
elif violation_type in low_risk_types:
return RiskLevel.LOW
else:
# 默认中风险
return RiskLevel.MEDIUM
+74
View File
@@ -0,0 +1,74 @@
"""
特例审批服务
超时策略、审批流程
"""
from datetime import datetime, timedelta, timezone
from app.schemas.review import (
RiskExceptionRecord,
RiskExceptionStatus,
)
# 超时时间(小时)
TIMEOUT_HOURS = 48
def apply_timeout_policy(
record: RiskExceptionRecord,
current_time: datetime,
) -> RiskExceptionRecord:
"""
应用超时策略
规则:
- 超过 48 小时未审批 → 自动拒绝
- 记录自动拒绝原因
Args:
record: 特例记录
current_time: 当前时间
Returns:
更新后的记录
"""
# 只处理待审批状态
if record.status != RiskExceptionStatus.PENDING:
return record
# 计算时间差
apply_time = record.apply_time
if isinstance(apply_time, str):
apply_time = datetime.fromisoformat(apply_time.replace("Z", "+00:00"))
# 确保时区一致
if apply_time.tzinfo is None:
apply_time = apply_time.replace(tzinfo=timezone.utc)
if current_time.tzinfo is None:
current_time = current_time.replace(tzinfo=timezone.utc)
elapsed = current_time - apply_time
if elapsed > timedelta(hours=TIMEOUT_HOURS):
# 超时自动拒绝
return RiskExceptionRecord(
record_id=record.record_id,
applicant_id=record.applicant_id,
apply_time=record.apply_time,
target_type=record.target_type,
target_id=record.target_id,
risk_rule_id=record.risk_rule_id,
status=RiskExceptionStatus.REJECTED,
valid_start_time=record.valid_start_time,
valid_end_time=record.valid_end_time,
reason_category=record.reason_category,
justification=record.justification,
attachment_url=record.attachment_url,
current_approver_id=record.current_approver_id,
approval_chain_log=record.approval_chain_log,
auto_rejected=True,
rejection_reason="timeout",
last_status_at=current_time,
)
return record
+75
View File
@@ -0,0 +1,75 @@
"""
软性风控服务
临界值、低置信度、历史记录触发警告
"""
from app.schemas.review import (
SoftRiskContext,
SoftRiskWarning,
SoftRiskAction,
)
def evaluate_soft_risk(context: SoftRiskContext) -> list[SoftRiskWarning]:
"""
评估软性风控
规则:
- 违规率接近阈值(90% 以上)→ 二次确认
- ASR/OCR 置信度 60%-80% → 备注提示
- 有历史类似违规 → 备注提示
Args:
context: 软性风控上下文
Returns:
警告列表(可能为空)
"""
warnings: list[SoftRiskWarning] = []
# 1. 临界值检测
if (
context.violation_rate is not None
and context.violation_threshold is not None
and context.violation_threshold > 0
):
ratio = context.violation_rate / context.violation_threshold
# 使用 round 避免浮点数精度问题 (0.045/0.05 = 0.8999999999999999)
ratio = round(ratio, 10)
if ratio >= 0.9 and ratio < 1.0:
warnings.append(SoftRiskWarning(
code="NEAR_THRESHOLD",
message=f"违规率 {context.violation_rate:.1%} 接近阈值 {context.violation_threshold:.1%}",
action_required=SoftRiskAction.CONFIRM,
blocking=False,
))
# 2. ASR 低置信度检测
if context.asr_confidence is not None:
if 0.6 <= context.asr_confidence < 0.8:
warnings.append(SoftRiskWarning(
code="LOW_CONFIDENCE_ASR",
message=f"语音识别置信度较低 ({context.asr_confidence:.0%}),建议人工复核",
action_required=SoftRiskAction.NOTE,
blocking=False,
))
# 3. OCR 低置信度检测
if context.ocr_confidence is not None:
if 0.6 <= context.ocr_confidence < 0.8:
warnings.append(SoftRiskWarning(
code="LOW_CONFIDENCE_OCR",
message=f"字幕识别置信度较低 ({context.ocr_confidence:.0%}),建议人工复核",
action_required=SoftRiskAction.NOTE,
blocking=False,
))
# 4. 历史违规检测
if context.has_history_violation:
warnings.append(SoftRiskWarning(
code="HISTORY_RISK",
message="该达人/内容存在历史类似违规记录",
action_required=SoftRiskAction.NOTE,
blocking=False,
))
return warnings
+248
View File
@@ -0,0 +1,248 @@
"""
视频下载服务
从 URL 下载视频到临时目录,支持重试和进度回调
"""
import asyncio
import hashlib
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import httpx
@dataclass
class DownloadResult:
"""下载结果"""
success: bool
file_path: Optional[str] = None
file_size: int = 0
content_type: Optional[str] = None
error: Optional[str] = None
class VideoDownloadService:
"""视频下载服务"""
def __init__(
self,
temp_dir: Optional[str] = None,
max_file_size: int = 500 * 1024 * 1024, # 500MB
timeout: float = 300.0, # 5 分钟
chunk_size: int = 1024 * 1024, # 1MB
):
"""
初始化下载服务
Args:
temp_dir: 临时目录,默认使用系统临时目录
max_file_size: 最大文件大小(字节)
timeout: 下载超时(秒)
chunk_size: 分块大小(字节)
"""
self.temp_dir = temp_dir or tempfile.gettempdir()
self.max_file_size = max_file_size
self.timeout = timeout
self.chunk_size = chunk_size
# 确保临时目录存在
Path(self.temp_dir).mkdir(parents=True, exist_ok=True)
def _generate_filename(self, url: str, content_type: Optional[str] = None) -> str:
"""根据 URL 生成唯一文件名"""
url_hash = hashlib.md5(url.encode()).hexdigest()[:12]
# 根据 content-type 确定扩展名
ext = ".mp4"
if content_type:
ext_map = {
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/quicktime": ".mov",
"video/x-msvideo": ".avi",
"video/x-matroska": ".mkv",
}
ext = ext_map.get(content_type, ".mp4")
return f"video_{url_hash}{ext}"
async def download(
self,
url: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
max_retries: int = 3,
) -> DownloadResult:
"""
下载视频文件
Args:
url: 视频 URL
progress_callback: 进度回调函数 (downloaded_bytes, total_bytes)
max_retries: 最大重试次数
Returns:
DownloadResult: 下载结果
"""
last_error = None
for attempt in range(max_retries):
try:
result = await self._download_once(url, progress_callback)
if result.success:
return result
last_error = result.error
except Exception as e:
last_error = str(e)
# 重试前等待
if attempt < max_retries - 1:
await asyncio.sleep(2 ** attempt)
return DownloadResult(
success=False,
error=f"下载失败(已重试 {max_retries} 次): {last_error}",
)
async def _download_once(
self,
url: str,
progress_callback: Optional[Callable[[int, int], None]] = None,
) -> DownloadResult:
"""单次下载尝试"""
async with httpx.AsyncClient(
timeout=httpx.Timeout(self.timeout),
follow_redirects=True,
) as client:
# 先获取文件信息
head_resp = await client.head(url)
if head_resp.status_code >= 400:
return DownloadResult(
success=False,
error=f"HTTP {head_resp.status_code}",
)
content_type = head_resp.headers.get("content-type", "")
content_length = int(head_resp.headers.get("content-length", 0))
# 检查文件大小
if content_length > self.max_file_size:
return DownloadResult(
success=False,
error=f"文件过大: {content_length / 1024 / 1024:.1f}MB > {self.max_file_size / 1024 / 1024:.1f}MB",
)
# 检查是否为视频类型
if content_type and not content_type.startswith("video/"):
return DownloadResult(
success=False,
error=f"非视频文件类型: {content_type}",
)
# 生成本地文件路径
filename = self._generate_filename(url, content_type)
file_path = os.path.join(self.temp_dir, filename)
# 如果文件已存在且大小匹配,直接返回
if os.path.exists(file_path):
existing_size = os.path.getsize(file_path)
if existing_size == content_length:
return DownloadResult(
success=True,
file_path=file_path,
file_size=existing_size,
content_type=content_type,
)
# 流式下载
downloaded = 0
async with client.stream("GET", url) as response:
if response.status_code >= 400:
return DownloadResult(
success=False,
error=f"HTTP {response.status_code}",
)
with open(file_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=self.chunk_size):
f.write(chunk)
downloaded += len(chunk)
# 检查是否超过最大限制
if downloaded > self.max_file_size:
os.remove(file_path)
return DownloadResult(
success=False,
error=f"文件过大,已下载 {downloaded / 1024 / 1024:.1f}MB",
)
if progress_callback:
progress_callback(downloaded, content_length or downloaded)
return DownloadResult(
success=True,
file_path=file_path,
file_size=downloaded,
content_type=content_type,
)
def cleanup(self, file_path: str) -> bool:
"""
清理下载的临时文件
Args:
file_path: 文件路径
Returns:
是否成功删除
"""
try:
if os.path.exists(file_path):
os.remove(file_path)
return True
except OSError:
pass
return False
def cleanup_old_files(self, max_age_seconds: int = 3600) -> int:
"""
清理过期的临时文件
Args:
max_age_seconds: 最大文件年龄(秒)
Returns:
删除的文件数量
"""
import time
deleted = 0
now = time.time()
for filename in os.listdir(self.temp_dir):
if not filename.startswith("video_"):
continue
file_path = os.path.join(self.temp_dir, filename)
try:
file_age = now - os.path.getmtime(file_path)
if file_age > max_age_seconds:
os.remove(file_path)
deleted += 1
except OSError:
pass
return deleted
# 全局实例
_download_service: Optional[VideoDownloadService] = None
def get_download_service() -> VideoDownloadService:
"""获取下载服务单例"""
global _download_service
if _download_service is None:
_download_service = VideoDownloadService()
return _download_service
+318
View File
@@ -0,0 +1,318 @@
"""
视频审核服务
核心业务逻辑:违规检测、时长校验、风险分类、分数计算
"""
from typing import Optional
from unittest.mock import AsyncMock
class VideoReviewService:
"""视频审核服务"""
def __init__(self):
# AI 服务依赖(可注入 mock
self.asr_service: Optional[AsyncMock] = None
self.cv_service: Optional[AsyncMock] = None
self.ocr_service: Optional[AsyncMock] = None
async def detect_competitor_logos(
self,
frames: list[dict],
competitors: list[str],
min_confidence: float = 0.7,
) -> list[dict]:
"""
检测画面中的竞品 Logo
Args:
frames: 视频帧数据,每帧包含 timestamp 和 objects
competitors: 竞品列表
min_confidence: 最小置信度阈值
Returns:
违规列表
"""
violations = []
for frame in frames:
timestamp = frame.get("timestamp", 0.0)
objects = frame.get("objects", [])
for obj in objects:
label = obj.get("label", "")
confidence = obj.get("confidence", 0.0)
if label in competitors and confidence >= min_confidence:
violations.append({
"type": "competitor_logo",
"timestamp": timestamp,
"content": label,
"confidence": confidence,
"risk_level": "medium",
"suggestion": f"请移除画面中的竞品露出:{label}",
})
return violations
async def detect_forbidden_words_in_speech(
self,
transcript: list[dict],
forbidden_words: list[str],
context_aware: bool = False,
) -> list[dict]:
"""
检测语音转文字中的违禁词
Args:
transcript: ASR 转写结果,每段包含 text, start, end
forbidden_words: 违禁词列表
context_aware: 是否启用语境感知
Returns:
违规列表
"""
violations = []
# 广告语境关键词
ad_context_keywords = ["产品", "购买", "推荐", "选择", "品牌", "效果"]
for segment in transcript:
text = segment.get("text", "")
start = segment.get("start", 0.0)
for word in forbidden_words:
if word in text:
# 语境感知检测
if context_aware:
is_ad_context = any(kw in text for kw in ad_context_keywords)
if not is_ad_context:
continue # 非广告语境,跳过
violations.append({
"type": "forbidden_word",
"content": word,
"timestamp": start,
"source": "speech",
"risk_level": "high",
"suggestion": f"建议删除或替换违禁词:{word}",
})
return violations
async def detect_forbidden_words_in_subtitle(
self,
subtitles: list[dict],
forbidden_words: list[str],
) -> list[dict]:
"""
检测字幕中的违禁词
Args:
subtitles: OCR 提取的字幕,每条包含 text, timestamp
forbidden_words: 违禁词列表
Returns:
违规列表
"""
violations = []
for subtitle in subtitles:
text = subtitle.get("text", "")
timestamp = subtitle.get("timestamp", 0.0)
for word in forbidden_words:
if word in text:
violations.append({
"type": "forbidden_word",
"content": word,
"timestamp": timestamp,
"source": "subtitle",
"risk_level": "high",
"suggestion": f"建议删除字幕中的违禁词:{word}",
})
return violations
async def check_product_display_duration(
self,
appearances: list[dict],
min_seconds: int,
) -> list[dict]:
"""
校验产品同框时长
Args:
appearances: 产品出现时间段列表,每段包含 start, end
min_seconds: 最小要求秒数
Returns:
违规列表(如果时长不足)
"""
total_duration = 0.0
for appearance in appearances:
start = appearance.get("start", 0.0)
end = appearance.get("end", 0.0)
total_duration += (end - start)
if total_duration < min_seconds:
return [{
"type": "duration_short",
"content": f"产品同框时长 {total_duration:.0f} 秒,不足要求的 {min_seconds}",
"timestamp": 0.0,
"risk_level": "medium",
"suggestion": f"建议增加产品同框时长至 {min_seconds} 秒以上",
}]
return []
async def check_brand_mention_frequency(
self,
transcript: list[dict],
brand_name: str,
min_mentions: int,
) -> list[dict]:
"""
校验品牌提及频次
Args:
transcript: ASR 转写结果
brand_name: 品牌名称
min_mentions: 最小提及次数
Returns:
违规列表(如果提及不足)
"""
mention_count = 0
for segment in transcript:
text = segment.get("text", "")
mention_count += text.count(brand_name)
if mention_count < min_mentions:
return [{
"type": "mention_missing",
"content": f"品牌 '{brand_name}' 提及 {mention_count} 次,不足要求的 {min_mentions}",
"timestamp": 0.0,
"risk_level": "low",
"suggestion": f"建议增加品牌提及至 {min_mentions} 次以上",
}]
return []
def classify_risk_level(self, violation: dict) -> str:
"""
根据违规项分类风险等级
Args:
violation: 违规项
Returns:
风险等级: high/medium/low
"""
violation_type = violation.get("type", "")
category = violation.get("category", "")
# 法律违规 -> 高风险
if category == "absolute_term" or violation_type == "forbidden_word":
return "high"
# 平台规则违规 -> 中风险
if category == "platform_rule" or violation_type in ["duration_short", "competitor_logo"]:
return "medium"
# 品牌规范违规 -> 低风险
if category == "brand_guideline" or violation_type == "mention_missing":
return "low"
return "medium" # 默认中风险
def calculate_score(self, violations: list[dict]) -> int:
"""
计算合规分数
规则:
- 基础分 100 分
- 高风险违规扣 25 分
- 中风险违规扣 15 分
- 低风险违规扣 5 分
- 最低 0 分
Args:
violations: 违规列表
Returns:
合规分数 (0-100)
"""
score = 100
for violation in violations:
risk_level = violation.get("risk_level", "medium")
if risk_level == "high":
score -= 25
elif risk_level == "medium":
score -= 15
else:
score -= 5
return max(0, score)
async def review_video(
self,
video_url: str,
platform: str,
brand_id: str,
competitors: list[str] = None,
forbidden_words: list[str] = None,
) -> dict:
"""
完整视频审核流程
Args:
video_url: 视频 URL
platform: 投放平台
brand_id: 品牌 ID
competitors: 竞品列表
forbidden_words: 违禁词列表
Returns:
审核结果
"""
competitors = competitors or []
forbidden_words = forbidden_words or []
all_violations = []
# 1. ASR 语音转文字 + 违禁词检测
if self.asr_service:
transcript = await self.asr_service.transcribe(video_url)
speech_violations = await self.detect_forbidden_words_in_speech(
transcript, forbidden_words
)
all_violations.extend(speech_violations)
# 2. CV 物体检测 + 竞品 Logo 检测
if self.cv_service:
frames = await self.cv_service.detect_objects(video_url)
logo_violations = await self.detect_competitor_logos(frames, competitors)
all_violations.extend(logo_violations)
# 3. OCR 字幕提取 + 违禁词检测
if self.ocr_service:
subtitles = await self.ocr_service.extract_subtitles(video_url)
subtitle_violations = await self.detect_forbidden_words_in_subtitle(
subtitles, forbidden_words
)
all_violations.extend(subtitle_violations)
# 4. 计算分数
score = self.calculate_score(all_violations)
# 5. 生成摘要
if not all_violations:
summary = "视频内容合规,未发现违规项"
else:
summary = f"发现 {len(all_violations)} 处违规"
return {
"score": score,
"summary": summary,
"violations": all_violations,
}
+427
View File
@@ -0,0 +1,427 @@
"""
视觉分析服务
集成 GPT-4V 实现竞品 Logo 检测、画面分析、OCR 字幕提取
"""
import base64
import json
from dataclasses import dataclass, field
from typing import Optional
from app.services.ai_client import OpenAICompatibleClient
from app.services.keyframe import KeyFrame
@dataclass
class DetectedObject:
"""检测到的对象"""
label: str
confidence: float
timestamp: float
bounding_box: Optional[dict] = None # {x, y, width, height}
description: Optional[str] = None
@dataclass
class SubtitleSegment:
"""字幕片段"""
text: str
timestamp: float
confidence: float = 1.0
@dataclass
class VisionAnalysisResult:
"""视觉分析结果"""
success: bool
detected_logos: list[DetectedObject] = field(default_factory=list)
detected_texts: list[SubtitleSegment] = field(default_factory=list)
scene_description: str = ""
error: Optional[str] = None
class VisionAnalysisService:
"""视觉分析服务"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.openai.com/v1",
model: str = "gpt-4o",
max_tokens: int = 2000,
):
"""
初始化视觉分析服务
Args:
api_key: API Key
base_url: API 基础 URL
model: 视觉模型名称
max_tokens: 最大输出 token
"""
self.client = OpenAICompatibleClient(
base_url=base_url,
api_key=api_key,
)
self.model = model
self.max_tokens = max_tokens
async def detect_logos(
self,
frames: list[KeyFrame],
competitor_names: list[str],
batch_size: int = 5,
) -> VisionAnalysisResult:
"""
检测画面中的竞品 Logo
Args:
frames: 关键帧列表
competitor_names: 竞品名称列表
batch_size: 每批处理的帧数
Returns:
VisionAnalysisResult: 分析结果
"""
if not frames:
return VisionAnalysisResult(success=True)
all_logos = []
competitors_str = "".join(competitor_names) if competitor_names else "任何品牌"
# 分批处理帧
for i in range(0, len(frames), batch_size):
batch = frames[i:i + batch_size]
try:
result = await self._analyze_frames_for_logos(
batch,
competitors_str,
)
all_logos.extend(result)
except Exception as e:
# 单批失败不影响整体
continue
return VisionAnalysisResult(
success=True,
detected_logos=all_logos,
)
async def _analyze_frames_for_logos(
self,
frames: list[KeyFrame],
competitors_str: str,
) -> list[DetectedObject]:
"""分析一批帧中的 Logo"""
# 构建图片内容
image_contents = []
timestamps = []
for frame in frames:
base64_image = frame.to_base64()
image_contents.append({
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}",
"detail": "low",
},
})
timestamps.append(frame.timestamp)
prompt = f"""分析这些视频帧,检测是否出现以下竞品品牌的 Logo 或产品:{competitors_str}
请以 JSON 格式返回检测结果,格式如下:
{{
"detections": [
{{
"frame_index": 0,
"brand": "品牌名称",
"confidence": 0.9,
"description": "Logo 出现在画面左上角"
}}
]
}}
如果没有检测到任何竞品,返回空数组:{{"detections": []}}
只返回 JSON,不要其他文字。"""
messages = [{
"role": "user",
"content": [{"type": "text", "text": prompt}] + image_contents,
}]
response = await self.client.chat_completion(
messages=messages,
model=self.model,
temperature=0.1,
max_tokens=self.max_tokens,
)
# 解析响应
try:
content = response.content.strip()
# 尝试提取 JSON
if "```json" in content:
content = content.split("```json")[1].split("```")[0]
elif "```" in content:
content = content.split("```")[1].split("```")[0]
data = json.loads(content)
detections = data.get("detections", [])
result = []
for det in detections:
frame_idx = det.get("frame_index", 0)
if 0 <= frame_idx < len(timestamps):
result.append(DetectedObject(
label=det.get("brand", ""),
confidence=det.get("confidence", 0.8),
timestamp=timestamps[frame_idx],
description=det.get("description", ""),
))
return result
except (json.JSONDecodeError, KeyError):
return []
async def extract_text_from_frames(
self,
frames: list[KeyFrame],
batch_size: int = 5,
) -> VisionAnalysisResult:
"""
从帧中提取文字(OCR)
Args:
frames: 关键帧列表
batch_size: 每批处理的帧数
Returns:
VisionAnalysisResult: 分析结果
"""
if not frames:
return VisionAnalysisResult(success=True)
all_texts = []
for i in range(0, len(frames), batch_size):
batch = frames[i:i + batch_size]
try:
result = await self._extract_text_from_batch(batch)
all_texts.extend(result)
except Exception:
continue
return VisionAnalysisResult(
success=True,
detected_texts=all_texts,
)
async def _extract_text_from_batch(
self,
frames: list[KeyFrame],
) -> list[SubtitleSegment]:
"""从一批帧中提取文字"""
image_contents = []
timestamps = []
for frame in frames:
base64_image = frame.to_base64()
image_contents.append({
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}",
"detail": "high",
},
})
timestamps.append(frame.timestamp)
prompt = """提取这些视频帧中的所有可见文字,特别是字幕和标题。
请以 JSON 格式返回,格式如下:
{
"texts": [
{
"frame_index": 0,
"text": "提取到的文字内容",
"type": "subtitle"
}
]
}
type 可以是: subtitle(字幕), title(标题), caption(说明文字), other(其他)
如果没有文字,返回空数组:{"texts": []}
只返回 JSON,不要其他文字。"""
messages = [{
"role": "user",
"content": [{"type": "text", "text": prompt}] + image_contents,
}]
response = await self.client.chat_completion(
messages=messages,
model=self.model,
temperature=0.1,
max_tokens=self.max_tokens,
)
try:
content = response.content.strip()
if "```json" in content:
content = content.split("```json")[1].split("```")[0]
elif "```" in content:
content = content.split("```")[1].split("```")[0]
data = json.loads(content)
texts = data.get("texts", [])
result = []
for txt in texts:
frame_idx = txt.get("frame_index", 0)
if 0 <= frame_idx < len(timestamps):
text_content = txt.get("text", "").strip()
if text_content:
result.append(SubtitleSegment(
text=text_content,
timestamp=timestamps[frame_idx],
))
return result
except (json.JSONDecodeError, KeyError):
return []
async def analyze_scene(
self,
frame: KeyFrame,
context: str = "",
) -> str:
"""
分析单帧场景
Args:
frame: 关键帧
context: 额外上下文
Returns:
场景描述
"""
base64_image = frame.to_base64()
prompt = f"请简要描述这个视频画面的内容,特别关注:产品、人物、场景、文字。{context}"
messages = [{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{base64_image}",
"detail": "low",
},
},
],
}]
try:
response = await self.client.chat_completion(
messages=messages,
model=self.model,
temperature=0.3,
max_tokens=500,
)
return response.content.strip()
except Exception as e:
return f"分析失败: {str(e)}"
async def close(self):
"""关闭客户端"""
await self.client.close()
class CompetitorLogoDetector:
"""竞品 Logo 检测器(封装简化接口)"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.openai.com/v1",
model: str = "gpt-4o",
):
self.service = VisionAnalysisService(api_key, base_url, model)
async def detect(
self,
frames: list[KeyFrame],
competitors: list[str],
) -> list[dict]:
"""
检测竞品 Logo
Args:
frames: 关键帧
competitors: 竞品列表
Returns:
违规列表(兼容 VideoReviewService 格式)
"""
result = await self.service.detect_logos(frames, competitors)
violations = []
for logo in result.detected_logos:
if logo.label in competitors or any(c in logo.label for c in competitors):
violations.append({
"type": "competitor_logo",
"timestamp": logo.timestamp,
"timestamp_end": logo.timestamp + 1.0,
"content": logo.label,
"confidence": logo.confidence,
"risk_level": "medium",
"source": "visual",
"suggestion": f"请移除画面中的竞品露出:{logo.label}",
})
return violations
async def close(self):
await self.service.close()
class VideoOCRService:
"""视频 OCR 服务"""
def __init__(
self,
api_key: str,
base_url: str = "https://api.openai.com/v1",
model: str = "gpt-4o",
):
self.service = VisionAnalysisService(api_key, base_url, model)
async def extract_subtitles(
self,
frames: list[KeyFrame],
) -> list[dict]:
"""
提取字幕
Args:
frames: 关键帧
Returns:
字幕列表(兼容 VideoReviewService 格式)
"""
result = await self.service.extract_text_from_frames(frames)
subtitles = []
for seg in result.detected_texts:
subtitles.append({
"text": seg.text,
"timestamp": seg.timestamp,
})
return subtitles
async def close(self):
await self.service.close()