feat: 完善代理商端业务逻辑与前后端框架
主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
d52509d630
commit
e4959d584f
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user