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,2 @@
|
||||
"""秒思智能审核平台后端服务"""
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1 @@
|
||||
"""API 路由模块"""
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
AI 服务配置 API
|
||||
品牌方管理 AI 提供商配置、模型选择、连通性测试
|
||||
"""
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Header, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.tenant import Tenant
|
||||
from app.schemas.ai_config import (
|
||||
AIProvider,
|
||||
AIConfigUpdate,
|
||||
AIConfigResponse,
|
||||
AIModelsConfig,
|
||||
AIParametersConfig,
|
||||
GetModelsRequest,
|
||||
TestConnectionRequest,
|
||||
ModelsListResponse,
|
||||
ConnectionTestResponse,
|
||||
ModelTestResult,
|
||||
ModelInfo,
|
||||
ModelCapability,
|
||||
mask_api_key,
|
||||
)
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
from app.utils.crypto import encrypt_api_key, decrypt_api_key
|
||||
|
||||
router = APIRouter(prefix="/ai-config", tags=["ai-config"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
@router.get("", response_model=AIConfigResponse)
|
||||
async def get_ai_config(
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AIConfigResponse:
|
||||
"""
|
||||
获取当前 AI 配置
|
||||
|
||||
- 未配置返回 404
|
||||
- 已配置返回配置信息(API Key 脱敏)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(
|
||||
AIConfig.tenant_id == x_tenant_id,
|
||||
AIConfig.is_configured == True,
|
||||
)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
|
||||
if not config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="AI 服务未配置,请先完成配置",
|
||||
)
|
||||
|
||||
# 解密 API Key 用于脱敏显示
|
||||
api_key = decrypt_api_key(config.api_key_encrypted)
|
||||
|
||||
return AIConfigResponse(
|
||||
provider=config.provider,
|
||||
base_url=config.base_url,
|
||||
api_key_masked=mask_api_key(api_key),
|
||||
models=AIModelsConfig(**config.models),
|
||||
parameters=AIParametersConfig(
|
||||
temperature=config.temperature,
|
||||
max_tokens=config.max_tokens,
|
||||
),
|
||||
available_models=config.available_models or {},
|
||||
is_configured=config.is_configured,
|
||||
last_test_at=config.last_test_at.isoformat() if config.last_test_at else None,
|
||||
last_test_result=config.last_test_result,
|
||||
)
|
||||
|
||||
|
||||
@router.put("", response_model=AIConfigResponse)
|
||||
async def update_ai_config(
|
||||
request: AIConfigUpdate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AIConfigResponse:
|
||||
"""
|
||||
更新 AI 配置
|
||||
|
||||
- 保存提供商、连接信息、模型配置
|
||||
- API Key 加密存储
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
# 加密 API Key
|
||||
api_key_encrypted = encrypt_api_key(request.api_key)
|
||||
|
||||
# 创建或更新配置
|
||||
config = await AIServiceFactory.create_or_update_config(
|
||||
tenant_id=x_tenant_id,
|
||||
provider=request.provider.value,
|
||||
base_url=request.base_url,
|
||||
api_key_encrypted=api_key_encrypted,
|
||||
models=request.models.model_dump(),
|
||||
temperature=request.parameters.temperature,
|
||||
max_tokens=request.parameters.max_tokens,
|
||||
db=db,
|
||||
)
|
||||
|
||||
return AIConfigResponse(
|
||||
provider=config.provider,
|
||||
base_url=config.base_url,
|
||||
api_key_masked=mask_api_key(request.api_key),
|
||||
models=AIModelsConfig(**config.models),
|
||||
parameters=AIParametersConfig(
|
||||
temperature=config.temperature,
|
||||
max_tokens=config.max_tokens,
|
||||
),
|
||||
available_models=config.available_models or {},
|
||||
is_configured=True,
|
||||
last_test_at=config.last_test_at.isoformat() if config.last_test_at else None,
|
||||
last_test_result=config.last_test_result,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/models", response_model=ModelsListResponse)
|
||||
async def get_available_models(
|
||||
request: GetModelsRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ModelsListResponse:
|
||||
"""
|
||||
获取可用模型列表
|
||||
|
||||
- 调用提供商 API 获取模型列表
|
||||
- 按能力分类(text/vision/audio)
|
||||
"""
|
||||
try:
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=request.base_url,
|
||||
api_key=request.api_key,
|
||||
provider=request.provider.value,
|
||||
)
|
||||
|
||||
models_dict = await client.list_models()
|
||||
await client.close()
|
||||
|
||||
# 转换为 ModelInfo 对象
|
||||
models = {
|
||||
k: [ModelInfo(**m) for m in v]
|
||||
for k, v in models_dict.items()
|
||||
}
|
||||
|
||||
# 更新配置中的可用模型缓存
|
||||
result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == x_tenant_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config:
|
||||
config.available_models = models_dict
|
||||
await db.flush()
|
||||
|
||||
return ModelsListResponse(
|
||||
success=True,
|
||||
models=models,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"获取模型列表失败: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=ConnectionTestResponse)
|
||||
async def test_connection(
|
||||
request: TestConnectionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ConnectionTestResponse:
|
||||
"""
|
||||
测试 AI 服务连接
|
||||
|
||||
- 并行测试三个模型
|
||||
- 返回每个模型的测试结果
|
||||
"""
|
||||
client = None
|
||||
models = request.models.model_dump()
|
||||
try:
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=request.base_url,
|
||||
api_key=request.api_key,
|
||||
provider=request.provider.value,
|
||||
)
|
||||
|
||||
# 定义模型能力映射
|
||||
capability_map = {
|
||||
"text": ModelCapability.TEXT,
|
||||
"vision": ModelCapability.VISION,
|
||||
"audio": ModelCapability.AUDIO,
|
||||
}
|
||||
|
||||
async def test_single(model_type: str, model_id: str) -> tuple[str, ModelTestResult]:
|
||||
capability = capability_map.get(model_type, ModelCapability.TEXT)
|
||||
result = await client.test_connection(model_id, capability)
|
||||
return model_type, ModelTestResult(
|
||||
success=result.success,
|
||||
latency_ms=result.latency_ms,
|
||||
error=result.error,
|
||||
model=model_id,
|
||||
)
|
||||
|
||||
# 并行测试所有模型
|
||||
tasks = [
|
||||
test_single(model_type, model_id)
|
||||
for model_type, model_id in models.items()
|
||||
]
|
||||
results_list = await asyncio.gather(*tasks)
|
||||
results = {model_type: result for model_type, result in results_list}
|
||||
|
||||
# 计算测试结果
|
||||
all_success = all(r.success for r in results.values())
|
||||
failed_count = sum(1 for r in results.values() if not r.success)
|
||||
|
||||
if all_success:
|
||||
message = "所有模型连接成功"
|
||||
else:
|
||||
message = f"{failed_count} 个模型连接失败,请检查模型名称或 API 权限"
|
||||
|
||||
response = ConnectionTestResponse(
|
||||
success=all_success,
|
||||
results=results,
|
||||
message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
# 确保接口返回 200,并返回失败详情
|
||||
results = {
|
||||
model_type: ModelTestResult(
|
||||
success=False,
|
||||
latency_ms=0,
|
||||
error=str(exc),
|
||||
model=model_id,
|
||||
)
|
||||
for model_type, model_id in models.items()
|
||||
}
|
||||
response = ConnectionTestResponse(
|
||||
success=False,
|
||||
results=results,
|
||||
message=f"连接测试失败: {str(exc)}",
|
||||
)
|
||||
finally:
|
||||
if client is not None:
|
||||
try:
|
||||
await client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 保存测试结果到数据库
|
||||
db_result = await db.execute(
|
||||
select(AIConfig).where(AIConfig.tenant_id == x_tenant_id)
|
||||
)
|
||||
config = db_result.scalar_one_or_none()
|
||||
if config:
|
||||
config.last_test_at = datetime.now(timezone.utc)
|
||||
config.last_test_result = {
|
||||
k: v.model_dump() for k, v in response.results.items()
|
||||
}
|
||||
await db.flush()
|
||||
|
||||
return response
|
||||
|
||||
|
||||
# ==================== 供其他模块调用 ====================
|
||||
|
||||
async def get_ai_config_for_tenant(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
) -> Optional[dict]:
|
||||
"""获取租户的 AI 配置(供审核服务调用)"""
|
||||
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
|
||||
|
||||
return {
|
||||
"tenant_id": config.tenant_id,
|
||||
"provider": config.provider,
|
||||
"base_url": config.base_url,
|
||||
"api_key": decrypt_api_key(config.api_key_encrypted),
|
||||
"models": config.models,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""健康检查 API"""
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.config import settings
|
||||
from app.services.health import HealthChecker, get_health_checker
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""
|
||||
健康检查端点
|
||||
|
||||
Returns:
|
||||
dict: 包含服务状态信息
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": settings.APP_NAME,
|
||||
"version": settings.APP_VERSION,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def readiness_check(
|
||||
health_checker: HealthChecker = Depends(get_health_checker),
|
||||
):
|
||||
"""
|
||||
就绪检查端点(用于 K8s)
|
||||
检查数据库、Redis 等依赖服务是否就绪
|
||||
|
||||
Returns:
|
||||
dict: 服务就绪状态和依赖检查结果
|
||||
"""
|
||||
checks = await health_checker.check_all()
|
||||
all_ready = all(checks.values())
|
||||
|
||||
return {
|
||||
"ready": all_ready,
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/live")
|
||||
async def liveness_check():
|
||||
"""
|
||||
存活检查端点(用于 K8s)
|
||||
只检查服务进程是否存活,不检查依赖
|
||||
|
||||
Returns:
|
||||
dict: 服务存活状态
|
||||
"""
|
||||
return {"alive": True}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
一致性指标 API
|
||||
按达人、规则类型、时间窗口查询
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
|
||||
from app.schemas.review import (
|
||||
ConsistencyMetricsResponse,
|
||||
ConsistencyWindow,
|
||||
RuleConsistencyMetric,
|
||||
ViolationType,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/metrics", tags=["metrics"])
|
||||
|
||||
|
||||
@router.get("/consistency", response_model=ConsistencyMetricsResponse)
|
||||
async def get_consistency_metrics(
|
||||
influencer_id: str = Query(None, description="达人 ID(必填)"),
|
||||
window: ConsistencyWindow = Query(ConsistencyWindow.ROLLING_30D, description="计算周期"),
|
||||
rule_type: ViolationType = Query(None, description="规则类型筛选"),
|
||||
) -> ConsistencyMetricsResponse:
|
||||
"""
|
||||
查询一致性指标
|
||||
|
||||
- 按达人 ID 查询
|
||||
- 支持 Rolling 30 天、周度快照、月度快照
|
||||
- 可按规则类型筛选
|
||||
"""
|
||||
# 验证必填参数
|
||||
if not influencer_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="缺少必填参数: influencer_id",
|
||||
)
|
||||
|
||||
# 计算时间范围
|
||||
now = datetime.now(timezone.utc)
|
||||
if window == ConsistencyWindow.ROLLING_30D:
|
||||
period_start = now - timedelta(days=30)
|
||||
period_end = now
|
||||
elif window == ConsistencyWindow.SNAPSHOT_WEEK:
|
||||
# 本周一到现在
|
||||
days_since_monday = now.weekday()
|
||||
period_start = (now - timedelta(days=days_since_monday)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
)
|
||||
period_end = now
|
||||
else: # SNAPSHOT_MONTH
|
||||
# 本月1号到现在
|
||||
period_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
period_end = now
|
||||
|
||||
# 生成模拟数据(实际应从数据库查询)
|
||||
all_metrics = [
|
||||
RuleConsistencyMetric(
|
||||
rule_type=ViolationType.FORBIDDEN_WORD,
|
||||
total_reviews=100,
|
||||
violation_count=5,
|
||||
violation_rate=0.05,
|
||||
),
|
||||
RuleConsistencyMetric(
|
||||
rule_type=ViolationType.COMPETITOR_LOGO,
|
||||
total_reviews=100,
|
||||
violation_count=2,
|
||||
violation_rate=0.02,
|
||||
),
|
||||
RuleConsistencyMetric(
|
||||
rule_type=ViolationType.DURATION_SHORT,
|
||||
total_reviews=100,
|
||||
violation_count=8,
|
||||
violation_rate=0.08,
|
||||
),
|
||||
]
|
||||
|
||||
# 按规则类型筛选
|
||||
if rule_type:
|
||||
all_metrics = [m for m in all_metrics if m.rule_type == rule_type]
|
||||
|
||||
return ConsistencyMetricsResponse(
|
||||
influencer_id=influencer_id,
|
||||
window=window,
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
metrics=all_metrics,
|
||||
)
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
特例审批 API
|
||||
创建、查询、审批特例记录
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.risk_exception import (
|
||||
RiskException,
|
||||
RiskTargetType as DBRiskTargetType,
|
||||
RiskExceptionStatus as DBRiskExceptionStatus,
|
||||
)
|
||||
from app.schemas.review import (
|
||||
RiskExceptionCreateRequest,
|
||||
RiskExceptionRecord,
|
||||
RiskExceptionStatus,
|
||||
RiskExceptionDecisionRequest,
|
||||
RiskTargetType,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/risk-exceptions", tags=["risk-exceptions"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
def _exception_to_response(record: RiskException) -> RiskExceptionRecord:
|
||||
"""将数据库模型转换为响应模型"""
|
||||
return RiskExceptionRecord(
|
||||
record_id=record.id,
|
||||
applicant_id=record.applicant_id,
|
||||
apply_time=record.apply_time,
|
||||
target_type=RiskTargetType(record.target_type.value),
|
||||
target_id=record.target_id,
|
||||
risk_rule_id=record.risk_rule_id,
|
||||
status=RiskExceptionStatus(record.status.value),
|
||||
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 or [],
|
||||
auto_rejected=record.auto_rejected,
|
||||
rejection_reason=record.rejection_reason,
|
||||
last_status_at=record.last_status_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=RiskExceptionRecord, status_code=status.HTTP_201_CREATED)
|
||||
async def create_exception(
|
||||
request: RiskExceptionCreateRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""创建特例申请"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
record_id = f"exc-{uuid.uuid4().hex[:12]}"
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
record = RiskException(
|
||||
id=record_id,
|
||||
tenant_id=x_tenant_id,
|
||||
applicant_id=request.applicant_id,
|
||||
apply_time=now,
|
||||
target_type=DBRiskTargetType(request.target_type.value),
|
||||
target_id=request.target_id,
|
||||
risk_rule_id=request.risk_rule_id,
|
||||
status=DBRiskExceptionStatus.PENDING,
|
||||
valid_start_time=request.valid_start_time,
|
||||
valid_end_time=request.valid_end_time,
|
||||
reason_category=request.reason_category,
|
||||
justification=request.justification,
|
||||
attachment_url=request.attachment_url,
|
||||
current_approver_id=request.current_approver_id,
|
||||
approval_chain_log=[],
|
||||
auto_rejected=False,
|
||||
rejection_reason=None,
|
||||
last_status_at=now,
|
||||
)
|
||||
db.add(record)
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
return _exception_to_response(record)
|
||||
|
||||
|
||||
@router.get("/{record_id}", response_model=RiskExceptionRecord)
|
||||
async def get_exception(
|
||||
record_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""查询特例记录"""
|
||||
result = await db.execute(
|
||||
select(RiskException).where(
|
||||
and_(
|
||||
RiskException.id == record_id,
|
||||
RiskException.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"特例记录不存在: {record_id}",
|
||||
)
|
||||
|
||||
return _exception_to_response(record)
|
||||
|
||||
|
||||
@router.post("/{record_id}/approve", response_model=RiskExceptionRecord)
|
||||
async def approve_exception(
|
||||
record_id: str,
|
||||
request: RiskExceptionDecisionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""审批通过"""
|
||||
result = await db.execute(
|
||||
select(RiskException).where(
|
||||
and_(
|
||||
RiskException.id == record_id,
|
||||
RiskException.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"特例记录不存在: {record_id}",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
record.status = DBRiskExceptionStatus.APPROVED
|
||||
record.last_status_at = now
|
||||
|
||||
# 更新审批日志
|
||||
approval_log = record.approval_chain_log or []
|
||||
approval_log.append({
|
||||
"approver_id": request.approver_id,
|
||||
"action": "approve",
|
||||
"comment": request.comment,
|
||||
"timestamp": now.isoformat(),
|
||||
})
|
||||
record.approval_chain_log = approval_log
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
return _exception_to_response(record)
|
||||
|
||||
|
||||
@router.post("/{record_id}/reject", response_model=RiskExceptionRecord)
|
||||
async def reject_exception(
|
||||
record_id: str,
|
||||
request: RiskExceptionDecisionRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RiskExceptionRecord:
|
||||
"""驳回申请"""
|
||||
result = await db.execute(
|
||||
select(RiskException).where(
|
||||
and_(
|
||||
RiskException.id == record_id,
|
||||
RiskException.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
|
||||
if not record:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"特例记录不存在: {record_id}",
|
||||
)
|
||||
|
||||
# 驳回必须填写原因
|
||||
if not request.comment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="驳回必须填写原因",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
record.status = DBRiskExceptionStatus.REJECTED
|
||||
record.rejection_reason = request.comment
|
||||
record.last_status_at = now
|
||||
|
||||
# 更新审批日志
|
||||
approval_log = record.approval_chain_log or []
|
||||
approval_log.append({
|
||||
"approver_id": request.approver_id,
|
||||
"action": "reject",
|
||||
"comment": request.comment,
|
||||
"timestamp": now.isoformat(),
|
||||
})
|
||||
record.approval_chain_log = approval_log
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(record)
|
||||
|
||||
return _exception_to_response(record)
|
||||
@@ -0,0 +1,535 @@
|
||||
"""
|
||||
规则管理 API
|
||||
违禁词库、白名单、竞品库、平台规则
|
||||
"""
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||
|
||||
router = APIRouter(prefix="/rules", tags=["rules"])
|
||||
|
||||
|
||||
# ==================== 请求/响应模型 ====================
|
||||
|
||||
class ForbiddenWordCreate(BaseModel):
|
||||
word: str
|
||||
category: str
|
||||
severity: str
|
||||
|
||||
|
||||
class ForbiddenWordResponse(BaseModel):
|
||||
id: str
|
||||
word: str
|
||||
category: str
|
||||
severity: str
|
||||
|
||||
|
||||
class ForbiddenWordListResponse(BaseModel):
|
||||
items: list[ForbiddenWordResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class WhitelistCreate(BaseModel):
|
||||
term: str
|
||||
reason: str
|
||||
brand_id: str
|
||||
|
||||
|
||||
class WhitelistResponse(BaseModel):
|
||||
id: str
|
||||
term: str
|
||||
reason: str
|
||||
brand_id: str
|
||||
|
||||
|
||||
class WhitelistListResponse(BaseModel):
|
||||
items: list[WhitelistResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class CompetitorCreate(BaseModel):
|
||||
name: str
|
||||
brand_id: str
|
||||
logo_url: Optional[str] = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CompetitorResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
brand_id: str
|
||||
logo_url: Optional[str] = None
|
||||
keywords: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CompetitorListResponse(BaseModel):
|
||||
items: list[CompetitorResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PlatformRuleResponse(BaseModel):
|
||||
platform: str
|
||||
rules: list[dict]
|
||||
version: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class PlatformListResponse(BaseModel):
|
||||
items: list[PlatformRuleResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class RuleValidateRequest(BaseModel):
|
||||
brand_id: str
|
||||
platform: str
|
||||
brief_rules: dict
|
||||
|
||||
|
||||
class RuleConflict(BaseModel):
|
||||
brief_rule: str
|
||||
platform_rule: str
|
||||
suggestion: str
|
||||
|
||||
|
||||
class RuleValidateResponse(BaseModel):
|
||||
conflicts: list[RuleConflict]
|
||||
|
||||
|
||||
# ==================== 预置平台规则 ====================
|
||||
|
||||
_platform_rules = {
|
||||
"douyin": {
|
||||
"platform": "douyin",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "第一", "最佳", "绝对", "100%"]},
|
||||
{"type": "duration", "min_seconds": 7},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-15T00:00:00Z",
|
||||
},
|
||||
"xiaohongshu": {
|
||||
"platform": "xiaohongshu",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "绝对", "100%"]},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-10T00:00:00Z",
|
||||
},
|
||||
"bilibili": {
|
||||
"platform": "bilibili",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "第一"]},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-12T00:00:00Z",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
# ==================== 违禁词库 ====================
|
||||
|
||||
@router.get("/forbidden-words", response_model=ForbiddenWordListResponse)
|
||||
async def list_forbidden_words(
|
||||
category: str = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ForbiddenWordListResponse:
|
||||
"""查询违禁词列表"""
|
||||
query = select(ForbiddenWord).where(ForbiddenWord.tenant_id == x_tenant_id)
|
||||
|
||||
if category:
|
||||
query = query.where(ForbiddenWord.category == category)
|
||||
|
||||
result = await db.execute(query)
|
||||
words = result.scalars().all()
|
||||
|
||||
return ForbiddenWordListResponse(
|
||||
items=[
|
||||
ForbiddenWordResponse(
|
||||
id=w.id,
|
||||
word=w.word,
|
||||
category=w.category,
|
||||
severity=w.severity,
|
||||
)
|
||||
for w in words
|
||||
],
|
||||
total=len(words),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/forbidden-words",
|
||||
response_model=ForbiddenWordResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_forbidden_word(
|
||||
request: ForbiddenWordCreate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ForbiddenWordResponse:
|
||||
"""添加违禁词"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
# 检查重复
|
||||
result = await db.execute(
|
||||
select(ForbiddenWord).where(
|
||||
and_(
|
||||
ForbiddenWord.tenant_id == x_tenant_id,
|
||||
ForbiddenWord.word == request.word,
|
||||
)
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"违禁词已存在: {request.word}",
|
||||
)
|
||||
|
||||
word_id = f"fw-{uuid.uuid4().hex[:8]}"
|
||||
word = ForbiddenWord(
|
||||
id=word_id,
|
||||
tenant_id=x_tenant_id,
|
||||
word=request.word,
|
||||
category=request.category,
|
||||
severity=request.severity,
|
||||
)
|
||||
db.add(word)
|
||||
await db.flush()
|
||||
|
||||
return ForbiddenWordResponse(
|
||||
id=word.id,
|
||||
word=word.word,
|
||||
category=word.category,
|
||||
severity=word.severity,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/forbidden-words/{word_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_forbidden_word(
|
||||
word_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除违禁词"""
|
||||
result = await db.execute(
|
||||
select(ForbiddenWord).where(
|
||||
and_(
|
||||
ForbiddenWord.id == word_id,
|
||||
ForbiddenWord.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
word = result.scalar_one_or_none()
|
||||
|
||||
if not word:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"违禁词不存在: {word_id}",
|
||||
)
|
||||
|
||||
await db.delete(word)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ==================== 白名单 ====================
|
||||
|
||||
@router.get("/whitelist", response_model=WhitelistListResponse)
|
||||
async def list_whitelist(
|
||||
brand_id: str = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> WhitelistListResponse:
|
||||
"""查询白名单"""
|
||||
query = select(WhitelistItem).where(WhitelistItem.tenant_id == x_tenant_id)
|
||||
|
||||
if brand_id:
|
||||
query = query.where(WhitelistItem.brand_id == brand_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
return WhitelistListResponse(
|
||||
items=[
|
||||
WhitelistResponse(
|
||||
id=item.id,
|
||||
term=item.term,
|
||||
reason=item.reason,
|
||||
brand_id=item.brand_id,
|
||||
)
|
||||
for item in items
|
||||
],
|
||||
total=len(items),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/whitelist",
|
||||
response_model=WhitelistResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_to_whitelist(
|
||||
request: WhitelistCreate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> WhitelistResponse:
|
||||
"""添加白名单"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
item_id = f"wl-{uuid.uuid4().hex[:8]}"
|
||||
item = WhitelistItem(
|
||||
id=item_id,
|
||||
tenant_id=x_tenant_id,
|
||||
brand_id=request.brand_id,
|
||||
term=request.term,
|
||||
reason=request.reason,
|
||||
)
|
||||
db.add(item)
|
||||
await db.flush()
|
||||
|
||||
return WhitelistResponse(
|
||||
id=item.id,
|
||||
term=item.term,
|
||||
reason=item.reason,
|
||||
brand_id=item.brand_id,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 竞品库 ====================
|
||||
|
||||
@router.get("/competitors", response_model=CompetitorListResponse)
|
||||
async def list_competitors(
|
||||
brand_id: str = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompetitorListResponse:
|
||||
"""查询竞品列表"""
|
||||
query = select(Competitor).where(Competitor.tenant_id == x_tenant_id)
|
||||
|
||||
if brand_id:
|
||||
query = query.where(Competitor.brand_id == brand_id)
|
||||
|
||||
result = await db.execute(query)
|
||||
competitors = result.scalars().all()
|
||||
|
||||
return CompetitorListResponse(
|
||||
items=[
|
||||
CompetitorResponse(
|
||||
id=c.id,
|
||||
name=c.name,
|
||||
brand_id=c.brand_id,
|
||||
logo_url=c.logo_url,
|
||||
keywords=c.keywords or [],
|
||||
)
|
||||
for c in competitors
|
||||
],
|
||||
total=len(competitors),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/competitors",
|
||||
response_model=CompetitorResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_competitor(
|
||||
request: CompetitorCreate,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompetitorResponse:
|
||||
"""添加竞品"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
comp_id = f"comp-{uuid.uuid4().hex[:8]}"
|
||||
competitor = Competitor(
|
||||
id=comp_id,
|
||||
tenant_id=x_tenant_id,
|
||||
brand_id=request.brand_id,
|
||||
name=request.name,
|
||||
logo_url=request.logo_url,
|
||||
keywords=request.keywords,
|
||||
)
|
||||
db.add(competitor)
|
||||
await db.flush()
|
||||
|
||||
return CompetitorResponse(
|
||||
id=competitor.id,
|
||||
name=competitor.name,
|
||||
brand_id=competitor.brand_id,
|
||||
logo_url=competitor.logo_url,
|
||||
keywords=competitor.keywords or [],
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/competitors/{competitor_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_competitor(
|
||||
competitor_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除竞品"""
|
||||
result = await db.execute(
|
||||
select(Competitor).where(
|
||||
and_(
|
||||
Competitor.id == competitor_id,
|
||||
Competitor.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
competitor = result.scalar_one_or_none()
|
||||
|
||||
if not competitor:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"竞品不存在: {competitor_id}",
|
||||
)
|
||||
|
||||
await db.delete(competitor)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ==================== 平台规则 ====================
|
||||
|
||||
@router.get("/platforms", response_model=PlatformListResponse)
|
||||
async def list_platform_rules() -> PlatformListResponse:
|
||||
"""查询所有平台规则"""
|
||||
return PlatformListResponse(
|
||||
items=[PlatformRuleResponse(**r) for r in _platform_rules.values()],
|
||||
total=len(_platform_rules),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/platforms/{platform}", response_model=PlatformRuleResponse)
|
||||
async def get_platform_rules(platform: str) -> PlatformRuleResponse:
|
||||
"""查询指定平台规则"""
|
||||
if platform not in _platform_rules:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"平台不存在: {platform}",
|
||||
)
|
||||
return PlatformRuleResponse(**_platform_rules[platform])
|
||||
|
||||
|
||||
# ==================== 规则冲突检测 ====================
|
||||
|
||||
@router.post("/validate", response_model=RuleValidateResponse)
|
||||
async def validate_rules(request: RuleValidateRequest) -> RuleValidateResponse:
|
||||
"""检测 Brief 与平台规则冲突"""
|
||||
conflicts = []
|
||||
|
||||
platform_rule = _platform_rules.get(request.platform)
|
||||
if not platform_rule:
|
||||
return RuleValidateResponse(conflicts=[])
|
||||
|
||||
# 检查 required_phrases 是否包含违禁词
|
||||
required_phrases = request.brief_rules.get("required_phrases", [])
|
||||
platform_forbidden = []
|
||||
for rule in platform_rule.get("rules", []):
|
||||
if rule.get("type") == "forbidden_word":
|
||||
platform_forbidden.extend(rule.get("words", []))
|
||||
|
||||
for phrase in required_phrases:
|
||||
for word in platform_forbidden:
|
||||
if word in phrase:
|
||||
conflicts.append(RuleConflict(
|
||||
brief_rule=f"要求使用:{phrase}",
|
||||
platform_rule=f"平台禁止:{word}",
|
||||
suggestion=f"Brief 要求的 '{phrase}' 包含平台违禁词 '{word}',建议修改",
|
||||
))
|
||||
|
||||
return RuleValidateResponse(conflicts=conflicts)
|
||||
|
||||
|
||||
# ==================== 辅助函数(供其他模块调用) ====================
|
||||
|
||||
async def get_whitelist_for_brand(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[str]:
|
||||
"""获取品牌白名单词汇"""
|
||||
result = await db.execute(
|
||||
select(WhitelistItem).where(
|
||||
and_(
|
||||
WhitelistItem.tenant_id == tenant_id,
|
||||
WhitelistItem.brand_id == brand_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [item.term for item in items]
|
||||
|
||||
|
||||
async def get_other_brands_whitelist_terms(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""
|
||||
获取其他品牌的白名单词汇(用于品牌安全检测)
|
||||
|
||||
Returns:
|
||||
list of (term, owner_brand_id)
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(WhitelistItem).where(
|
||||
and_(
|
||||
WhitelistItem.tenant_id == tenant_id,
|
||||
WhitelistItem.brand_id != brand_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
items = result.scalars().all()
|
||||
return [(item.term, item.brand_id) for item in items]
|
||||
|
||||
|
||||
async def get_forbidden_words_for_tenant(
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
category: str = None,
|
||||
) -> list[dict]:
|
||||
"""获取租户的违禁词列表"""
|
||||
query = select(ForbiddenWord).where(ForbiddenWord.tenant_id == tenant_id)
|
||||
if category:
|
||||
query = query.where(ForbiddenWord.category == category)
|
||||
|
||||
result = await db.execute(query)
|
||||
words = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": w.id,
|
||||
"word": w.word,
|
||||
"category": w.category,
|
||||
"severity": w.severity,
|
||||
}
|
||||
for w in words
|
||||
]
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
脚本预审 API
|
||||
"""
|
||||
import re
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas.review import (
|
||||
ScriptReviewRequest,
|
||||
ScriptReviewResponse,
|
||||
Violation,
|
||||
ViolationType,
|
||||
RiskLevel,
|
||||
Position,
|
||||
SoftRiskWarning,
|
||||
)
|
||||
from app.api.rules import (
|
||||
get_whitelist_for_brand,
|
||||
get_other_brands_whitelist_terms,
|
||||
get_forbidden_words_for_tenant,
|
||||
)
|
||||
from app.services.soft_risk import evaluate_soft_risk
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
|
||||
router = APIRouter(prefix="/scripts", tags=["scripts"])
|
||||
|
||||
# 内置违禁词库(广告极限词)
|
||||
ABSOLUTE_WORDS = ["最好", "第一", "最佳", "绝对", "100%"]
|
||||
|
||||
# 功效词库(医疗/功效宣称)
|
||||
EFFICACY_WORDS = ["根治", "治愈", "治疗", "药效", "疗效", "特效"]
|
||||
|
||||
# 广告语境关键词(用于判断是否为广告场景)
|
||||
AD_CONTEXT_KEYWORDS = ["产品", "购买", "销量", "品质", "推荐", "价格", "优惠", "促销"]
|
||||
|
||||
|
||||
def _is_ad_context(content: str, word: str) -> bool:
|
||||
"""
|
||||
判断是否为广告语境
|
||||
|
||||
规则:
|
||||
- 如果内容中包含广告关键词,认为是广告语境
|
||||
- 如果违禁词出现在明显的非广告句式中,不是广告语境
|
||||
"""
|
||||
# 非广告语境模式
|
||||
non_ad_patterns = [
|
||||
r"他是第一[个名位]", # 他是第一个/名
|
||||
r"[是为]第一[个名位]", # 是第一个
|
||||
r"最开心|最高兴|最难忘", # 情感表达
|
||||
r"第一[次个].*[到来抵达]", # 第一次到达
|
||||
]
|
||||
|
||||
for pattern in non_ad_patterns:
|
||||
if re.search(pattern, content):
|
||||
return False
|
||||
|
||||
# 检查是否包含广告关键词
|
||||
return any(kw in content for kw in AD_CONTEXT_KEYWORDS)
|
||||
|
||||
|
||||
def _check_selling_point_coverage(content: str, required_points: list[str]) -> list[str]:
|
||||
"""
|
||||
检查卖点覆盖情况
|
||||
|
||||
使用语义匹配而非精确匹配
|
||||
"""
|
||||
missing = []
|
||||
|
||||
# 卖点关键词映射
|
||||
point_keywords = {
|
||||
"品牌名称": ["品牌", "牌子", "品牌A", "品牌B"],
|
||||
"使用方法": ["使用", "用法", "早晚", "每天", "一次", "涂抹", "喷洒"],
|
||||
"功效说明": ["功效", "效果", "水润", "美白", "保湿", "滋润", "改善"],
|
||||
}
|
||||
|
||||
for point in required_points:
|
||||
# 精确匹配
|
||||
if point in content:
|
||||
continue
|
||||
|
||||
# 关键词匹配
|
||||
keywords = point_keywords.get(point, [])
|
||||
if any(kw in content for kw in keywords):
|
||||
continue
|
||||
|
||||
missing.append(point)
|
||||
|
||||
return missing
|
||||
|
||||
|
||||
@router.post("/review", response_model=ScriptReviewResponse)
|
||||
async def review_script(
|
||||
request: ScriptReviewRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ScriptReviewResponse:
|
||||
"""
|
||||
脚本预审
|
||||
|
||||
- 检测违禁词(支持语境感知)
|
||||
- 检测功效词
|
||||
- 检查必要卖点
|
||||
- 应用白名单
|
||||
- 可选 AI 深度分析
|
||||
- 返回合规分数和修改建议
|
||||
"""
|
||||
violations = []
|
||||
content = request.content
|
||||
|
||||
# 获取品牌白名单
|
||||
whitelist = await get_whitelist_for_brand(x_tenant_id, request.brand_id, db)
|
||||
|
||||
# 获取租户自定义违禁词
|
||||
tenant_forbidden_words = await get_forbidden_words_for_tenant(x_tenant_id, db)
|
||||
|
||||
# 1. 违禁词检测(广告极限词)
|
||||
all_forbidden_words = ABSOLUTE_WORDS + [w["word"] for w in tenant_forbidden_words]
|
||||
|
||||
for word in all_forbidden_words:
|
||||
# 白名单跳过
|
||||
if word in whitelist:
|
||||
continue
|
||||
|
||||
start = 0
|
||||
while True:
|
||||
pos = content.find(word, start)
|
||||
if pos == -1:
|
||||
break
|
||||
|
||||
# 语境感知:非广告语境跳过
|
||||
if not _is_ad_context(content, word):
|
||||
start = pos + 1
|
||||
continue
|
||||
|
||||
violations.append(Violation(
|
||||
type=ViolationType.FORBIDDEN_WORD,
|
||||
content=word,
|
||||
severity=RiskLevel.HIGH,
|
||||
suggestion=f"建议删除或替换违禁词:{word}",
|
||||
position=Position(start=pos, end=pos + len(word)),
|
||||
))
|
||||
start = pos + 1
|
||||
|
||||
# 2. 功效词检测
|
||||
for word in EFFICACY_WORDS:
|
||||
if word in whitelist:
|
||||
continue
|
||||
|
||||
start = 0
|
||||
while True:
|
||||
pos = content.find(word, start)
|
||||
if pos == -1:
|
||||
break
|
||||
|
||||
violations.append(Violation(
|
||||
type=ViolationType.EFFICACY_CLAIM,
|
||||
content=word,
|
||||
severity=RiskLevel.HIGH,
|
||||
suggestion=f"功效宣称词违反广告法,建议删除:{word}",
|
||||
position=Position(start=pos, end=pos + len(word)),
|
||||
))
|
||||
start = pos + 1
|
||||
|
||||
# 3. 检测其他品牌专属词(品牌安全风险)
|
||||
other_brand_terms = await get_other_brands_whitelist_terms(x_tenant_id, request.brand_id, db)
|
||||
for term, owner_brand in other_brand_terms:
|
||||
if term in content:
|
||||
violations.append(Violation(
|
||||
type=ViolationType.BRAND_SAFETY,
|
||||
content=term,
|
||||
severity=RiskLevel.MEDIUM,
|
||||
suggestion=f"使用了其他品牌的专属词汇:{term}",
|
||||
position=Position(start=content.find(term), end=content.find(term) + len(term)),
|
||||
))
|
||||
|
||||
# 4. 检查遗漏卖点
|
||||
missing_points: list[str] | None = None
|
||||
if request.required_points:
|
||||
missing = _check_selling_point_coverage(content, request.required_points)
|
||||
missing_points = missing if missing else []
|
||||
|
||||
# 5. 可选:AI 深度分析
|
||||
ai_violations = await _ai_deep_analysis(x_tenant_id, content, db)
|
||||
if ai_violations:
|
||||
violations.extend(ai_violations)
|
||||
|
||||
# 6. 计算分数
|
||||
score = 100 - len(violations) * 25
|
||||
if missing_points:
|
||||
score -= len(missing_points) * 5
|
||||
score = max(0, score)
|
||||
|
||||
# 7. 生成摘要
|
||||
parts = []
|
||||
if violations:
|
||||
parts.append(f"发现 {len(violations)} 处违规")
|
||||
if missing_points:
|
||||
parts.append(f"遗漏 {len(missing_points)} 个卖点")
|
||||
|
||||
if not parts:
|
||||
summary = "脚本内容合规,未发现问题"
|
||||
else:
|
||||
summary = ",".join(parts)
|
||||
|
||||
# 8. 软性风控评估
|
||||
soft_warnings: list[SoftRiskWarning] = []
|
||||
if request.soft_risk_context:
|
||||
soft_warnings = evaluate_soft_risk(request.soft_risk_context)
|
||||
|
||||
return ScriptReviewResponse(
|
||||
score=score,
|
||||
summary=summary,
|
||||
violations=violations,
|
||||
missing_points=missing_points,
|
||||
soft_warnings=soft_warnings,
|
||||
)
|
||||
|
||||
|
||||
async def _ai_deep_analysis(
|
||||
tenant_id: str,
|
||||
content: str,
|
||||
db: AsyncSession,
|
||||
) -> list[Violation]:
|
||||
"""
|
||||
使用 AI 进行深度分析
|
||||
|
||||
AI 分析失败时返回空列表,降级到规则检测
|
||||
"""
|
||||
try:
|
||||
# 获取 AI 客户端
|
||||
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||||
if not ai_client:
|
||||
return []
|
||||
|
||||
# 获取模型配置
|
||||
config = await AIServiceFactory.get_config(tenant_id, db)
|
||||
if not config:
|
||||
return []
|
||||
|
||||
text_model = config.models.get("text", "gpt-4o")
|
||||
|
||||
# 构建分析提示
|
||||
analysis_prompt = f"""作为广告合规审核专家,请分析以下广告脚本内容,检测潜在的合规风险:
|
||||
|
||||
脚本内容:
|
||||
{content}
|
||||
|
||||
请检查以下方面:
|
||||
1. 是否存在隐性的虚假宣传(如暗示疗效但不直接说明)
|
||||
2. 是否存在容易引起误解的表述
|
||||
3. 是否存在夸大描述
|
||||
4. 是否存在可能违反广告法的其他内容
|
||||
|
||||
如果发现问题,请以 JSON 数组格式返回,每项包含:
|
||||
- type: 违规类型 (forbidden_word/efficacy_claim/brand_safety)
|
||||
- content: 违规内容
|
||||
- severity: 严重程度 (high/medium/low)
|
||||
- suggestion: 修改建议
|
||||
|
||||
如果未发现问题,返回空数组 []
|
||||
|
||||
请只返回 JSON 数组,不要包含其他内容。"""
|
||||
|
||||
response = await ai_client.chat_completion(
|
||||
messages=[{"role": "user", "content": analysis_prompt}],
|
||||
model=text_model,
|
||||
temperature=0.3,
|
||||
max_tokens=1000,
|
||||
)
|
||||
|
||||
# 解析 AI 响应
|
||||
import json
|
||||
try:
|
||||
# 清理响应内容(移除可能的 markdown 标记)
|
||||
response_content = response.content.strip()
|
||||
if response_content.startswith("```"):
|
||||
response_content = response_content.split("\n", 1)[1]
|
||||
if response_content.endswith("```"):
|
||||
response_content = response_content.rsplit("\n", 1)[0]
|
||||
|
||||
ai_results = json.loads(response_content)
|
||||
|
||||
violations = []
|
||||
for item in ai_results:
|
||||
violation_type = item.get("type", "forbidden_word")
|
||||
if violation_type == "forbidden_word":
|
||||
vtype = ViolationType.FORBIDDEN_WORD
|
||||
elif violation_type == "efficacy_claim":
|
||||
vtype = ViolationType.EFFICACY_CLAIM
|
||||
else:
|
||||
vtype = ViolationType.BRAND_SAFETY
|
||||
|
||||
severity = item.get("severity", "medium")
|
||||
if severity == "high":
|
||||
slevel = RiskLevel.HIGH
|
||||
elif severity == "low":
|
||||
slevel = RiskLevel.LOW
|
||||
else:
|
||||
slevel = RiskLevel.MEDIUM
|
||||
|
||||
violations.append(Violation(
|
||||
type=vtype,
|
||||
content=item.get("content", ""),
|
||||
severity=slevel,
|
||||
suggestion=item.get("suggestion", "建议修改"),
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
except json.JSONDecodeError:
|
||||
# JSON 解析失败,返回空列表
|
||||
return []
|
||||
|
||||
except Exception:
|
||||
# AI 调用失败,降级到规则检测
|
||||
return []
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
审核任务 API
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.review import ManualTask, TaskStatus as DBTaskStatus, Platform as DBPlatform
|
||||
from app.schemas.review import (
|
||||
TaskCreateRequest,
|
||||
TaskResponse,
|
||||
TaskListResponse,
|
||||
TaskScriptUploadRequest,
|
||||
TaskVideoUploadRequest,
|
||||
TaskApproveRequest,
|
||||
TaskRejectRequest,
|
||||
TaskStatus,
|
||||
Platform,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
def _task_to_response(task: ManualTask) -> TaskResponse:
|
||||
"""将数据库模型转换为响应模型"""
|
||||
return TaskResponse(
|
||||
task_id=task.id,
|
||||
video_url=task.video_url,
|
||||
script_content=task.script_content,
|
||||
script_file_url=task.script_file_url,
|
||||
has_script=bool(task.script_content or task.script_file_url),
|
||||
has_video=bool(task.video_url),
|
||||
platform=Platform(task.platform.value),
|
||||
creator_id=task.creator_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
created_at=task.created_at.isoformat() if task.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=TaskResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
request: TaskCreateRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
创建审核任务
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
task_id = f"task-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
task = ManualTask(
|
||||
id=task_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url) if request.video_url else None,
|
||||
video_uploaded_at=datetime.now(timezone.utc) if request.video_url else None,
|
||||
platform=DBPlatform(request.platform.value),
|
||||
creator_id=request.creator_id,
|
||||
status=DBTaskStatus.PENDING,
|
||||
script_content=request.script_content,
|
||||
script_file_url=str(request.script_file_url) if request.script_file_url else None,
|
||||
script_uploaded_at=datetime.now(timezone.utc)
|
||||
if request.script_content or request.script_file_url
|
||||
else None,
|
||||
)
|
||||
db.add(task)
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.post("/{task_id}/script", response_model=TaskResponse)
|
||||
async def upload_task_script(
|
||||
task_id: str,
|
||||
request: TaskScriptUploadRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
上传/更新任务脚本
|
||||
"""
|
||||
if not request.script_content and not request.script_file_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="script_content 或 script_file_url 至少提供一个",
|
||||
)
|
||||
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
task.script_content = request.script_content
|
||||
task.script_file_url = (
|
||||
str(request.script_file_url) if request.script_file_url else None
|
||||
)
|
||||
task.script_uploaded_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.post("/{task_id}/video", response_model=TaskResponse)
|
||||
async def upload_task_video(
|
||||
task_id: str,
|
||||
request: TaskVideoUploadRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
上传/更新任务视频
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
task.video_url = str(request.video_url)
|
||||
task.video_uploaded_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskResponse)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
查询单个任务
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.get("", response_model=TaskListResponse)
|
||||
async def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
task_status: TaskStatus = Query(None, alias="status"),
|
||||
platform: Platform = None,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskListResponse:
|
||||
"""
|
||||
查询任务列表
|
||||
|
||||
支持分页和筛选
|
||||
"""
|
||||
# 构建查询
|
||||
query = select(ManualTask).where(ManualTask.tenant_id == x_tenant_id)
|
||||
|
||||
if task_status:
|
||||
query = query.where(ManualTask.status == DBTaskStatus(task_status.value))
|
||||
|
||||
if platform:
|
||||
query = query.where(ManualTask.platform == DBPlatform(platform.value))
|
||||
|
||||
# 按创建时间倒序排列
|
||||
query = query.order_by(ManualTask.created_at.desc())
|
||||
|
||||
# 执行查询获取总数
|
||||
count_result = await db.execute(
|
||||
select(ManualTask.id).where(ManualTask.tenant_id == x_tenant_id)
|
||||
)
|
||||
total = len(count_result.all())
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
query = query.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(query)
|
||||
tasks = result.scalars().all()
|
||||
|
||||
return TaskListResponse(
|
||||
items=[_task_to_response(t) for t in tasks],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{task_id}/approve", response_model=TaskResponse)
|
||||
async def approve_task(
|
||||
task_id: str,
|
||||
request: TaskApproveRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
通过任务
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
task.status = DBTaskStatus.APPROVED
|
||||
task.approve_comment = request.comment
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@router.post("/{task_id}/reject", response_model=TaskResponse)
|
||||
async def reject_task(
|
||||
task_id: str,
|
||||
request: TaskRejectRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TaskResponse:
|
||||
"""
|
||||
驳回任务
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ManualTask).where(
|
||||
and_(
|
||||
ManualTask.id == task_id,
|
||||
ManualTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"任务不存在: {task_id}",
|
||||
)
|
||||
|
||||
task.status = DBTaskStatus.REJECTED
|
||||
task.reject_reason = request.reason
|
||||
task.reject_violations = request.violations
|
||||
task.reviewed_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(task)
|
||||
|
||||
return _task_to_response(task)
|
||||
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
视频审核 API
|
||||
"""
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.review import ReviewTask, TaskStatus as DBTaskStatus, Platform as DBPlatform
|
||||
from app.schemas.review import (
|
||||
VideoReviewRequest,
|
||||
VideoReviewSubmitResponse,
|
||||
VideoReviewProgressResponse,
|
||||
VideoReviewResultResponse,
|
||||
TaskStatus,
|
||||
Violation,
|
||||
ViolationType,
|
||||
RiskLevel,
|
||||
ViolationSource,
|
||||
SoftRiskWarning,
|
||||
)
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
from app.services.ai_client import OpenAICompatibleClient
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||
|
||||
|
||||
async def _ensure_tenant_exists(tenant_id: str, db: AsyncSession) -> Tenant:
|
||||
"""确保租户存在,不存在则自动创建"""
|
||||
result = await db.execute(
|
||||
select(Tenant).where(Tenant.id == tenant_id)
|
||||
)
|
||||
tenant = result.scalar_one_or_none()
|
||||
|
||||
if not tenant:
|
||||
tenant = Tenant(id=tenant_id, name=f"租户-{tenant_id}")
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
@router.post(
|
||||
"/review",
|
||||
response_model=VideoReviewSubmitResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def submit_video_review(
|
||||
request: VideoReviewRequest,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VideoReviewSubmitResponse:
|
||||
"""
|
||||
提交视频审核
|
||||
|
||||
返回 202 Accepted,异步处理
|
||||
"""
|
||||
# 确保租户存在
|
||||
await _ensure_tenant_exists(x_tenant_id, db)
|
||||
|
||||
review_id = f"review-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# 创建审核任务
|
||||
task = ReviewTask(
|
||||
id=review_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url),
|
||||
platform=DBPlatform(request.platform.value),
|
||||
brand_id=request.brand_id,
|
||||
creator_id=request.creator_id,
|
||||
status=DBTaskStatus.PENDING,
|
||||
progress=0,
|
||||
current_step="等待处理",
|
||||
competitors=request.competitors,
|
||||
requirements=request.requirements,
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
|
||||
# 触发 Celery 异步任务
|
||||
try:
|
||||
from app.tasks.review import process_video_review_task
|
||||
process_video_review_task.delay(
|
||||
review_id=review_id,
|
||||
tenant_id=x_tenant_id,
|
||||
video_url=str(request.video_url),
|
||||
brand_id=request.brand_id,
|
||||
platform=request.platform.value,
|
||||
)
|
||||
except Exception:
|
||||
# Celery 不可用时,任务保持 PENDING 状态
|
||||
# 后续可通过定时任务或手动触发处理
|
||||
pass
|
||||
|
||||
return VideoReviewSubmitResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus.PENDING,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/review/{review_id}/progress",
|
||||
response_model=VideoReviewProgressResponse,
|
||||
)
|
||||
async def get_review_progress(
|
||||
review_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> VideoReviewProgressResponse:
|
||||
"""
|
||||
查询审核进度
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"审核任务不存在: {review_id}",
|
||||
)
|
||||
|
||||
return VideoReviewProgressResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
progress=task.progress,
|
||||
current_step=task.current_step,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/review/{review_id}/result")
|
||||
async def get_review_result(
|
||||
review_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
查询审核结果
|
||||
|
||||
- 未完成:返回 202 + 进度结构
|
||||
- 已完成:返回 200 + 结果结构
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"审核任务不存在: {review_id}",
|
||||
)
|
||||
|
||||
# 未完成:返回 202 + 进度
|
||||
if task.status in [DBTaskStatus.PENDING, DBTaskStatus.PROCESSING]:
|
||||
progress_response = VideoReviewProgressResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus(task.status.value),
|
||||
progress=task.progress,
|
||||
current_step=task.current_step,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
content=progress_response.model_dump(),
|
||||
)
|
||||
|
||||
# 失败:返回错误信息
|
||||
if task.status == DBTaskStatus.FAILED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=task.error_message or "审核任务失败",
|
||||
)
|
||||
|
||||
# 已完成:返回 200 + 结果
|
||||
violations = []
|
||||
if task.violations:
|
||||
for v in task.violations:
|
||||
violations.append(Violation(**v))
|
||||
|
||||
soft_warnings = []
|
||||
if task.soft_warnings:
|
||||
for w in task.soft_warnings:
|
||||
soft_warnings.append(SoftRiskWarning(**w))
|
||||
|
||||
return VideoReviewResultResponse(
|
||||
review_id=review_id,
|
||||
status=TaskStatus.COMPLETED,
|
||||
score=task.score or 100,
|
||||
summary=task.summary or "审核完成",
|
||||
violations=violations,
|
||||
soft_warnings=soft_warnings,
|
||||
)
|
||||
|
||||
|
||||
# ==================== AI 辅助审核方法 ====================
|
||||
|
||||
async def _perform_ai_video_review(
|
||||
task: ReviewTask,
|
||||
ai_client: OpenAICompatibleClient,
|
||||
text_model: str,
|
||||
vision_model: str,
|
||||
audio_model: str,
|
||||
db: AsyncSession,
|
||||
) -> dict:
|
||||
"""
|
||||
使用 AI 执行视频审核
|
||||
|
||||
流程:
|
||||
1. 下载视频
|
||||
2. ASR 转写
|
||||
3. 提取关键帧
|
||||
4. 视觉分析 (竞品 Logo)
|
||||
5. OCR 字幕
|
||||
6. 生成报告
|
||||
"""
|
||||
violations = []
|
||||
score = 100
|
||||
|
||||
try:
|
||||
# 更新进度: 开始处理
|
||||
task.status = DBTaskStatus.PROCESSING
|
||||
task.progress = 10
|
||||
task.current_step = "下载视频"
|
||||
await db.flush()
|
||||
|
||||
# TODO: 实际实现需要集成视频处理库
|
||||
# 1. 下载视频
|
||||
# video_path = await download_video(task.video_url)
|
||||
|
||||
# 2. ASR 转写
|
||||
task.progress = 30
|
||||
task.current_step = "语音转写"
|
||||
await db.flush()
|
||||
|
||||
# asr_result = await ai_client.audio_transcription(
|
||||
# audio_url=task.video_url, # 需要提取音频
|
||||
# model=audio_model,
|
||||
# )
|
||||
# transcript = asr_result.content
|
||||
|
||||
# 3. 提取关键帧
|
||||
task.progress = 50
|
||||
task.current_step = "提取关键帧"
|
||||
await db.flush()
|
||||
|
||||
# frames = await extract_keyframes(video_path)
|
||||
|
||||
# 4. 视觉分析
|
||||
task.progress = 70
|
||||
task.current_step = "视觉分析"
|
||||
await db.flush()
|
||||
|
||||
# 检测竞品 Logo
|
||||
# if task.competitors:
|
||||
# vision_prompt = f"""
|
||||
# 分析这些视频截图,检测是否包含以下竞品品牌的 Logo 或标识:
|
||||
# 竞品列表: {task.competitors}
|
||||
#
|
||||
# 如果发现竞品,请返回:
|
||||
# 1. 竞品名称
|
||||
# 2. 出现的帧编号
|
||||
# 3. 置信度 (0-1)
|
||||
# """
|
||||
# vision_result = await ai_client.vision_analysis(
|
||||
# image_urls=frames,
|
||||
# prompt=vision_prompt,
|
||||
# model=vision_model,
|
||||
# )
|
||||
|
||||
# 5. 文本综合分析
|
||||
task.progress = 85
|
||||
task.current_step = "综合分析"
|
||||
await db.flush()
|
||||
|
||||
# analysis_prompt = f"""
|
||||
# 作为广告合规审核专家,请分析以下视频脚本内容:
|
||||
#
|
||||
# 脚本内容:
|
||||
# {transcript}
|
||||
#
|
||||
# 请检查:
|
||||
# 1. 是否包含广告法违禁词(最好、第一、最佳等极限词)
|
||||
# 2. 是否包含虚假功效宣称
|
||||
# 3. 品牌信息是否正确
|
||||
#
|
||||
# 返回 JSON 格式:
|
||||
# {{"violations": [...], "score": 0-100, "summary": "..."}}
|
||||
# """
|
||||
# analysis_result = await ai_client.chat_completion(
|
||||
# messages=[{"role": "user", "content": analysis_prompt}],
|
||||
# model=text_model,
|
||||
# )
|
||||
|
||||
# 6. 完成审核
|
||||
task.progress = 100
|
||||
task.current_step = "审核完成"
|
||||
task.status = DBTaskStatus.COMPLETED
|
||||
task.score = score
|
||||
task.summary = "审核完成,未发现违规" if not violations else f"发现 {len(violations)} 处违规"
|
||||
task.violations = [v.model_dump() for v in violations] if violations else []
|
||||
|
||||
await db.flush()
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"summary": task.summary,
|
||||
"violations": violations,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
task.status = DBTaskStatus.FAILED
|
||||
task.error_message = str(e)
|
||||
await db.flush()
|
||||
raise
|
||||
|
||||
|
||||
# ==================== 后台任务入口 ====================
|
||||
|
||||
async def process_video_review_task(
|
||||
review_id: str,
|
||||
tenant_id: str,
|
||||
db: AsyncSession,
|
||||
):
|
||||
"""
|
||||
处理视频审核任务(由 Celery 或后台任务调用)
|
||||
"""
|
||||
# 获取任务
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(
|
||||
and_(
|
||||
ReviewTask.id == review_id,
|
||||
ReviewTask.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
|
||||
if not task:
|
||||
return
|
||||
|
||||
# 获取 AI 客户端
|
||||
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||||
|
||||
if not ai_client:
|
||||
# 没有配置 AI,使用规则引擎审核
|
||||
task.status = DBTaskStatus.COMPLETED
|
||||
task.score = 100
|
||||
task.summary = "审核完成(规则引擎)"
|
||||
task.progress = 100
|
||||
task.current_step = "审核完成"
|
||||
await db.flush()
|
||||
return
|
||||
|
||||
# 获取模型配置
|
||||
config = await AIServiceFactory.get_config(tenant_id, db)
|
||||
models = config.models
|
||||
|
||||
# 执行 AI 审核
|
||||
await _perform_ai_video_review(
|
||||
task=task,
|
||||
ai_client=ai_client,
|
||||
text_model=models.get("text", "gpt-4o"),
|
||||
vision_model=models.get("vision", "gpt-4o"),
|
||||
audio_model=models.get("audio", "whisper-1"),
|
||||
db=db,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Celery 应用配置
|
||||
后台任务队列
|
||||
"""
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# 创建 Celery 应用
|
||||
celery_app = Celery(
|
||||
"miaosi",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
include=["app.tasks.review"],
|
||||
)
|
||||
|
||||
# 配置
|
||||
celery_app.conf.update(
|
||||
# 任务序列化
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
|
||||
# 时区
|
||||
timezone="Asia/Shanghai",
|
||||
enable_utc=True,
|
||||
|
||||
# 任务配置
|
||||
task_track_started=True,
|
||||
task_time_limit=600, # 10 分钟超时
|
||||
task_soft_time_limit=540, # 9 分钟软超时
|
||||
|
||||
# 结果配置
|
||||
result_expires=3600, # 结果保留 1 小时
|
||||
|
||||
# 并发配置
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_concurrency=4,
|
||||
|
||||
# 重试配置
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
|
||||
# 路由配置
|
||||
task_routes={
|
||||
"app.tasks.review.*": {"queue": "review"},
|
||||
},
|
||||
|
||||
# 队列配置
|
||||
task_default_queue="default",
|
||||
|
||||
# 定时任务
|
||||
beat_schedule={
|
||||
# 每小时清理过期临时文件
|
||||
"cleanup-old-files": {
|
||||
"task": "app.tasks.review.cleanup_old_files_task",
|
||||
"schedule": crontab(minute=0), # 每小时整点执行
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""应用配置"""
|
||||
from pydantic_settings import BaseSettings
|
||||
from functools import lru_cache
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用设置"""
|
||||
# 应用
|
||||
APP_NAME: str = "秒思智能审核平台"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = False
|
||||
|
||||
# 数据库
|
||||
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi"
|
||||
|
||||
# Redis
|
||||
REDIS_URL: str = "redis://localhost:6379/0"
|
||||
|
||||
# JWT
|
||||
SECRET_KEY: str = "your-secret-key-change-in-production"
|
||||
ALGORITHM: str = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||
|
||||
# AI 服务
|
||||
AI_PROVIDER: str = "doubao" # doubao | qwen | deepseek
|
||||
AI_API_KEY: str = ""
|
||||
AI_API_BASE_URL: str = ""
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
@lru_cache()
|
||||
def get_settings() -> Settings:
|
||||
"""获取配置单例"""
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""数据库配置"""
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# 导入所有模型,确保在创建表时被注册
|
||||
from app.models.base import Base
|
||||
from app.models import (
|
||||
Tenant,
|
||||
AIConfig,
|
||||
ReviewTask,
|
||||
ManualTask,
|
||||
ForbiddenWord,
|
||||
WhitelistItem,
|
||||
Competitor,
|
||||
RiskException,
|
||||
)
|
||||
|
||||
# 创建异步引擎
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# 创建异步会话工厂
|
||||
AsyncSessionLocal = sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""获取数据库会话依赖"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""初始化数据库(创建所有表)"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def drop_db():
|
||||
"""删除所有表(仅用于测试)"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
# 导出所有模型,供其他模块使用
|
||||
__all__ = [
|
||||
"Base",
|
||||
"engine",
|
||||
"AsyncSessionLocal",
|
||||
"get_db",
|
||||
"init_db",
|
||||
"drop_db",
|
||||
"Tenant",
|
||||
"AIConfig",
|
||||
"ReviewTask",
|
||||
"ManualTask",
|
||||
"ForbiddenWord",
|
||||
"WhitelistItem",
|
||||
"Competitor",
|
||||
"RiskException",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""FastAPI 应用入口"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.api import health, scripts, videos, tasks, rules, ai_config, risk_exceptions, metrics
|
||||
|
||||
# 创建应用
|
||||
app = FastAPI(
|
||||
title=settings.APP_NAME,
|
||||
version=settings.APP_VERSION,
|
||||
description="AI 营销内容合规审核平台 API",
|
||||
docs_url="/docs" if settings.DEBUG else None,
|
||||
redoc_url="/redoc" if settings.DEBUG else None,
|
||||
)
|
||||
|
||||
# CORS 配置
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if settings.DEBUG else ["https://miaosi.ai"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 注册路由
|
||||
app.include_router(health.router, prefix="/api/v1")
|
||||
app.include_router(scripts.router, prefix="/api/v1")
|
||||
app.include_router(videos.router, prefix="/api/v1")
|
||||
app.include_router(tasks.router, prefix="/api/v1")
|
||||
app.include_router(rules.router, prefix="/api/v1")
|
||||
app.include_router(ai_config.router, prefix="/api/v1")
|
||||
app.include_router(risk_exceptions.router, prefix="/api/v1")
|
||||
app.include_router(metrics.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"message": f"Welcome to {settings.APP_NAME}",
|
||||
"version": settings.APP_VERSION,
|
||||
"docs": "/docs" if settings.DEBUG else "disabled",
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
数据库模型
|
||||
导出所有 ORM 模型
|
||||
"""
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.review import ReviewTask, ManualTask
|
||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||
from app.models.risk_exception import RiskException
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"TimestampMixin",
|
||||
"Tenant",
|
||||
"AIConfig",
|
||||
"ReviewTask",
|
||||
"ManualTask",
|
||||
"ForbiddenWord",
|
||||
"WhitelistItem",
|
||||
"Competitor",
|
||||
"RiskException",
|
||||
]
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
AI 配置模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, Float, Integer, ForeignKey, DateTime
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class AIConfig(Base, TimestampMixin):
|
||||
"""AI 服务配置表"""
|
||||
__tablename__ = "ai_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 提供商配置
|
||||
provider: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
base_url: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
api_key_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# 模型配置 (JSON)
|
||||
# {"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"}
|
||||
models: Mapped[dict] = mapped_column(JSONType, nullable=False)
|
||||
|
||||
# 参数配置
|
||||
temperature: Mapped[float] = mapped_column(Float, default=0.7, nullable=False)
|
||||
max_tokens: Mapped[int] = mapped_column(Integer, default=2000, nullable=False)
|
||||
|
||||
# 可用模型缓存 (JSON)
|
||||
available_models: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 测试结果
|
||||
last_test_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
last_test_result: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 配置状态
|
||||
is_configured: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="ai_config")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AIConfig(tenant_id={self.tenant_id}, provider={self.provider})>"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
数据库模型基类
|
||||
提供公共字段和功能
|
||||
"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""声明基类"""
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""时间戳 Mixin,提供 created_at 和 updated_at 字段"""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
审核任务模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Integer, Float, Text, ForeignKey, DateTime, Enum as SQLEnum
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import enum
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
"""任务状态"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class Platform(str, enum.Enum):
|
||||
"""投放平台"""
|
||||
DOUYIN = "douyin"
|
||||
XIAOHONGSHU = "xiaohongshu"
|
||||
BILIBILI = "bilibili"
|
||||
KUAISHOU = "kuaishou"
|
||||
|
||||
|
||||
class ReviewTask(Base, TimestampMixin):
|
||||
"""审核任务表 (AI 自动审核)"""
|
||||
__tablename__ = "review_tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 视频信息
|
||||
video_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
platform: Mapped[Platform] = mapped_column(
|
||||
SQLEnum(Platform, name="platform_enum"),
|
||||
nullable=False,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
creator_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
# 审核状态
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum"),
|
||||
default=TaskStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
current_step: Mapped[str] = mapped_column(String(100), default="等待处理", nullable=False)
|
||||
|
||||
# 审核结果
|
||||
score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 违规详情 (JSON 数组)
|
||||
# [{"type": "forbidden_word", "content": "最好", "severity": "high", ...}]
|
||||
violations: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 软性风控提示 (JSON 数组)
|
||||
soft_warnings: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 审核要求 (JSON)
|
||||
requirements: Mapped[Optional[dict]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 竞品列表
|
||||
competitors: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 错误信息
|
||||
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="review_tasks")
|
||||
manual_task: Mapped[Optional["ManualTask"]] = relationship(
|
||||
"ManualTask",
|
||||
back_populates="review_task",
|
||||
uselist=False,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ReviewTask(id={self.id}, status={self.status})>"
|
||||
|
||||
|
||||
class ManualTask(Base, TimestampMixin):
|
||||
"""人工审核任务表"""
|
||||
__tablename__ = "manual_tasks"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
review_task_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("review_tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 视频信息 (冗余存储,即使关联的 review_task 被删除也能查看)
|
||||
video_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
video_uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
platform: Mapped[Platform] = mapped_column(
|
||||
SQLEnum(Platform, name="platform_enum", create_type=False),
|
||||
nullable=False,
|
||||
)
|
||||
creator_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
# 脚本信息
|
||||
script_content: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
script_file_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
script_uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# 任务状态
|
||||
status: Mapped[TaskStatus] = mapped_column(
|
||||
SQLEnum(TaskStatus, name="task_status_enum", create_type=False),
|
||||
default=TaskStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 审批结果
|
||||
approve_comment: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
reject_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
reject_violations: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 审批人
|
||||
reviewer_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
reviewed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="manual_tasks")
|
||||
review_task: Mapped[Optional["ReviewTask"]] = relationship(
|
||||
"ReviewTask",
|
||||
back_populates="manual_task",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ManualTask(id={self.id}, status={self.status})>"
|
||||
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
特例审批模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from datetime import datetime
|
||||
from sqlalchemy import String, Text, Boolean, ForeignKey, DateTime, Enum as SQLEnum
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
import enum
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class RiskTargetType(str, enum.Enum):
|
||||
"""特例目标类型"""
|
||||
INFLUENCER = "influencer"
|
||||
ORDER = "order"
|
||||
CONTENT = "content"
|
||||
|
||||
|
||||
class RiskExceptionStatus(str, enum.Enum):
|
||||
"""特例审批状态"""
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
EXPIRED = "expired"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class RiskException(Base, TimestampMixin):
|
||||
"""特例审批表"""
|
||||
__tablename__ = "risk_exceptions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 申请信息
|
||||
applicant_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
apply_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 目标信息
|
||||
target_type: Mapped[RiskTargetType] = mapped_column(
|
||||
SQLEnum(RiskTargetType, name="risk_target_type_enum"),
|
||||
nullable=False,
|
||||
)
|
||||
target_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
risk_rule_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
|
||||
# 状态
|
||||
status: Mapped[RiskExceptionStatus] = mapped_column(
|
||||
SQLEnum(RiskExceptionStatus, name="risk_exception_status_enum"),
|
||||
default=RiskExceptionStatus.PENDING,
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# 有效期
|
||||
valid_start_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
valid_end_time: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# 申请原因
|
||||
reason_category: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
justification: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
attachment_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
|
||||
# 审批信息
|
||||
current_approver_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# 审批流转日志 (JSON 数组)
|
||||
# [{"approver_id": "...", "action": "approve/reject", "comment": "...", "timestamp": "..."}]
|
||||
approval_chain_log: Mapped[list] = mapped_column(JSONType, default=list, nullable=False)
|
||||
|
||||
# 驳回信息
|
||||
auto_rejected: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
rejection_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
# 最近状态变更时间
|
||||
last_status_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="risk_exceptions")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RiskException(id={self.id}, status={self.status})>"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
规则模型
|
||||
违禁词、白名单、竞品
|
||||
"""
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from sqlalchemy import String, Text, ForeignKey
|
||||
from app.models.types import JSONType
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class ForbiddenWord(Base, TimestampMixin):
|
||||
"""违禁词表"""
|
||||
__tablename__ = "forbidden_words"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
word: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
category: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
severity: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="forbidden_words")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<ForbiddenWord(word={self.word}, category={self.category})>"
|
||||
|
||||
|
||||
class WhitelistItem(Base, TimestampMixin):
|
||||
"""白名单表"""
|
||||
__tablename__ = "whitelist_items"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
term: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
reason: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="whitelist_items")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<WhitelistItem(term={self.term}, brand_id={self.brand_id})>"
|
||||
|
||||
|
||||
class Competitor(Base, TimestampMixin):
|
||||
"""竞品表"""
|
||||
__tablename__ = "competitors"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(
|
||||
String(64),
|
||||
ForeignKey("tenants.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
brand_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
logo_url: Mapped[Optional[str]] = mapped_column(String(2048), nullable=True)
|
||||
|
||||
# 关键词列表 (JSON 数组)
|
||||
keywords: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 关联
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="competitors")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Competitor(name={self.name}, brand_id={self.brand_id})>"
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
租户模型
|
||||
"""
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy import String, Boolean
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.models.review import ReviewTask, ManualTask
|
||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||
from app.models.risk_exception import RiskException
|
||||
|
||||
|
||||
class Tenant(Base, TimestampMixin):
|
||||
"""租户表"""
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
# 关联关系
|
||||
ai_config: Mapped["AIConfig"] = relationship(
|
||||
"AIConfig",
|
||||
back_populates="tenant",
|
||||
uselist=False,
|
||||
lazy="selectin",
|
||||
)
|
||||
review_tasks: Mapped[list["ReviewTask"]] = relationship(
|
||||
"ReviewTask",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
manual_tasks: Mapped[list["ManualTask"]] = relationship(
|
||||
"ManualTask",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
forbidden_words: Mapped[list["ForbiddenWord"]] = relationship(
|
||||
"ForbiddenWord",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
whitelist_items: Mapped[list["WhitelistItem"]] = relationship(
|
||||
"WhitelistItem",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
competitors: Mapped[list["Competitor"]] = relationship(
|
||||
"Competitor",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
risk_exceptions: Mapped[list["RiskException"]] = relationship(
|
||||
"RiskException",
|
||||
back_populates="tenant",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tenant(id={self.id}, name={self.name})>"
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Shared SQLAlchemy column types with cross-database compatibility."""
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
# Use JSONB on PostgreSQL, fall back to JSON on other databases (e.g., SQLite for tests)
|
||||
JSONType = JSON().with_variant(JSONB, "postgresql")
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
AI 服务配置相关的 Pydantic 模型
|
||||
"""
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AIProvider(str, Enum):
|
||||
"""支持的 AI 提供商"""
|
||||
# 中转服务
|
||||
ONEAPI = "oneapi"
|
||||
OPENROUTER = "openrouter"
|
||||
|
||||
# 直连厂商 - 国际
|
||||
ANTHROPIC = "anthropic"
|
||||
OPENAI = "openai"
|
||||
|
||||
# 直连厂商 - 国内
|
||||
DEEPSEEK = "deepseek"
|
||||
QWEN = "qwen"
|
||||
DOUBAO = "doubao"
|
||||
ZHIPU = "zhipu"
|
||||
MOONSHOT = "moonshot"
|
||||
|
||||
|
||||
# 提供商默认 Base URL
|
||||
PROVIDER_DEFAULT_URLS = {
|
||||
AIProvider.ANTHROPIC: "https://api.anthropic.com/v1",
|
||||
AIProvider.OPENAI: "https://api.openai.com/v1",
|
||||
AIProvider.DEEPSEEK: "https://api.deepseek.com/v1",
|
||||
AIProvider.QWEN: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
AIProvider.DOUBAO: "https://ark.cn-beijing.volces.com/api/v3",
|
||||
AIProvider.ZHIPU: "https://open.bigmodel.cn/api/paas/v4",
|
||||
AIProvider.MOONSHOT: "https://api.moonshot.cn/v1",
|
||||
}
|
||||
|
||||
|
||||
class ModelCapability(str, Enum):
|
||||
"""模型能力类型"""
|
||||
TEXT = "text"
|
||||
VISION = "vision"
|
||||
AUDIO = "audio"
|
||||
|
||||
|
||||
# ==================== 请求模型 ====================
|
||||
|
||||
class AIModelsConfig(BaseModel):
|
||||
"""三个模型配置"""
|
||||
text: str = Field(..., description="文字处理模型")
|
||||
vision: str = Field(..., description="视频分析模型")
|
||||
audio: str = Field(..., description="音频解析模型")
|
||||
|
||||
|
||||
class AIParametersConfig(BaseModel):
|
||||
"""参数配置"""
|
||||
temperature: float = Field(default=0.7, ge=0, le=1)
|
||||
max_tokens: int = Field(default=2000, ge=100, le=32000)
|
||||
|
||||
|
||||
class AIConfigUpdate(BaseModel):
|
||||
"""更新 AI 配置请求"""
|
||||
provider: AIProvider
|
||||
base_url: str = Field(..., min_length=1)
|
||||
api_key: str = Field(..., min_length=1)
|
||||
models: AIModelsConfig
|
||||
parameters: AIParametersConfig = Field(default_factory=AIParametersConfig)
|
||||
|
||||
|
||||
class GetModelsRequest(BaseModel):
|
||||
"""获取模型列表请求"""
|
||||
provider: AIProvider
|
||||
base_url: str
|
||||
api_key: str
|
||||
|
||||
|
||||
class TestConnectionRequest(BaseModel):
|
||||
"""测试连接请求"""
|
||||
provider: AIProvider
|
||||
base_url: str
|
||||
api_key: str
|
||||
models: AIModelsConfig
|
||||
|
||||
|
||||
# ==================== 响应模型 ====================
|
||||
|
||||
class AIConfigResponse(BaseModel):
|
||||
"""AI 配置响应"""
|
||||
provider: str
|
||||
base_url: str
|
||||
api_key_masked: str = Field(..., description="脱敏后的 API Key")
|
||||
models: AIModelsConfig
|
||||
parameters: AIParametersConfig
|
||||
available_models: dict[str, list[dict]] = Field(default_factory=dict)
|
||||
is_configured: bool
|
||||
last_test_at: Optional[str] = None
|
||||
last_test_result: Optional[dict] = None
|
||||
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
"""模型信息"""
|
||||
id: str
|
||||
name: str
|
||||
|
||||
|
||||
class ModelsListResponse(BaseModel):
|
||||
"""模型列表响应"""
|
||||
success: bool
|
||||
models: dict[str, list[ModelInfo]] = Field(default_factory=dict)
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class ModelTestResult(BaseModel):
|
||||
"""单个模型测试结果"""
|
||||
success: bool
|
||||
latency_ms: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
model: str
|
||||
|
||||
|
||||
class ConnectionTestResponse(BaseModel):
|
||||
"""测试连接响应"""
|
||||
success: bool
|
||||
results: dict[str, ModelTestResult]
|
||||
message: str
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def mask_api_key(api_key: str) -> str:
|
||||
"""API Key 脱敏"""
|
||||
if len(api_key) <= 8:
|
||||
return "****"
|
||||
return f"{api_key[:4]}****{api_key[-4:]}"
|
||||
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
审核相关的 Pydantic 模型(API 契约定义)
|
||||
所有测试和实现必须遵循此契约
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field, HttpUrl
|
||||
from enum import Enum
|
||||
|
||||
|
||||
# ==================== 枚举定义 ====================
|
||||
|
||||
class Platform(str, Enum):
|
||||
"""支持的投放平台"""
|
||||
DOUYIN = "douyin"
|
||||
XIAOHONGSHU = "xiaohongshu"
|
||||
BILIBILI = "bilibili"
|
||||
KUAISHOU = "kuaishou"
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
"""任务状态"""
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
|
||||
|
||||
class RiskLevel(str, Enum):
|
||||
"""风险等级"""
|
||||
HIGH = "high" # 法律违规(广告法极限词)
|
||||
MEDIUM = "medium" # 平台规则违规
|
||||
LOW = "low" # 品牌规范违规
|
||||
|
||||
|
||||
class ViolationType(str, Enum):
|
||||
"""违规类型"""
|
||||
FORBIDDEN_WORD = "forbidden_word" # 违禁词
|
||||
EFFICACY_CLAIM = "efficacy_claim" # 功效宣称
|
||||
COMPETITOR_LOGO = "competitor_logo" # 竞品露出
|
||||
DURATION_SHORT = "duration_short" # 时长不足
|
||||
MENTION_MISSING = "mention_missing" # 品牌提及不足
|
||||
BRAND_SAFETY = "brand_safety" # 品牌安全风险
|
||||
|
||||
|
||||
class ViolationSource(str, Enum):
|
||||
"""违规来源"""
|
||||
TEXT = "text" # 文本/脚本
|
||||
SPEECH = "speech" # 语音(ASR)
|
||||
SUBTITLE = "subtitle" # 字幕(OCR)
|
||||
VISUAL = "visual" # 画面(CV)
|
||||
|
||||
|
||||
class SoftRiskAction(str, Enum):
|
||||
"""软性风控动作"""
|
||||
CONFIRM = "confirm" # 需要二次确认
|
||||
NOTE = "note" # 需要填写备注
|
||||
|
||||
|
||||
class SoftRiskWarning(BaseModel):
|
||||
"""软性风控提示(Warn-only)"""
|
||||
code: str = Field(..., description="提示类型代码")
|
||||
message: str = Field(..., description="提示内容")
|
||||
action_required: SoftRiskAction = Field(..., description="要求动作")
|
||||
blocking: bool = Field(default=False, description="是否阻断(默认不阻断)")
|
||||
context: Optional[dict] = Field(None, description="附加上下文")
|
||||
|
||||
|
||||
class SoftRiskContext(BaseModel):
|
||||
"""软性风控输入上下文"""
|
||||
violation_rate: Optional[float] = Field(None, ge=0, le=1, description="违规率")
|
||||
violation_threshold: Optional[float] = Field(None, ge=0, le=1, description="违规率阈值")
|
||||
asr_confidence: Optional[float] = Field(None, ge=0, le=1, description="ASR 置信度")
|
||||
ocr_confidence: Optional[float] = Field(None, ge=0, le=1, description="OCR 置信度")
|
||||
has_history_violation: Optional[bool] = Field(None, description="是否有历史类似违规")
|
||||
|
||||
|
||||
# ==================== 通用模型 ====================
|
||||
|
||||
class Position(BaseModel):
|
||||
"""文本位置"""
|
||||
start: int = Field(..., description="起始位置")
|
||||
end: int = Field(..., description="结束位置")
|
||||
|
||||
|
||||
class Violation(BaseModel):
|
||||
"""违规项(统一结构)"""
|
||||
type: ViolationType = Field(..., description="违规类型")
|
||||
content: str = Field(..., description="违规内容")
|
||||
severity: RiskLevel = Field(..., description="严重程度")
|
||||
suggestion: str = Field(..., description="修改建议")
|
||||
|
||||
# 文本审核字段
|
||||
position: Optional[Position] = Field(None, description="文本位置(脚本审核)")
|
||||
|
||||
# 视频审核字段
|
||||
timestamp: Optional[float] = Field(None, description="开始时间戳(秒)")
|
||||
timestamp_end: Optional[float] = Field(None, description="结束时间戳(秒)")
|
||||
source: Optional[ViolationSource] = Field(None, description="违规来源(视频审核)")
|
||||
|
||||
|
||||
# ==================== 脚本预审 ====================
|
||||
|
||||
class ScriptReviewRequest(BaseModel):
|
||||
"""脚本预审请求"""
|
||||
content: str = Field(..., min_length=1, description="脚本内容")
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
brand_id: str = Field(..., description="品牌 ID")
|
||||
required_points: Optional[list[str]] = Field(None, description="必要卖点列表")
|
||||
soft_risk_context: Optional[SoftRiskContext] = Field(None, description="软性风控上下文")
|
||||
|
||||
|
||||
class ScriptReviewResponse(BaseModel):
|
||||
"""
|
||||
脚本预审响应
|
||||
|
||||
结构:
|
||||
- score: 合规分数 0-100
|
||||
- summary: 整体摘要
|
||||
- violations: 违规项列表,每项包含 suggestion
|
||||
- missing_points: 遗漏的卖点(可选)
|
||||
"""
|
||||
score: int = Field(..., ge=0, le=100, description="合规分数")
|
||||
summary: str = Field(..., description="审核摘要")
|
||||
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
||||
missing_points: Optional[list[str]] = Field(None, description="遗漏的卖点")
|
||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||
|
||||
|
||||
# ==================== 视频审核 ====================
|
||||
|
||||
class VideoReviewRequest(BaseModel):
|
||||
"""视频审核请求"""
|
||||
video_url: HttpUrl = Field(..., description="视频 URL")
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
brand_id: str = Field(..., description="品牌 ID")
|
||||
creator_id: str = Field(..., description="达人 ID")
|
||||
competitors: Optional[list[str]] = Field(None, description="竞品列表")
|
||||
requirements: Optional[dict] = Field(None, description="审核要求(时长、频次等)")
|
||||
|
||||
|
||||
class VideoReviewSubmitResponse(BaseModel):
|
||||
"""视频审核提交响应(202 Accepted)"""
|
||||
review_id: str = Field(..., description="审核任务 ID")
|
||||
status: TaskStatus = Field(default=TaskStatus.PENDING, description="任务状态")
|
||||
|
||||
|
||||
class VideoReviewProgressResponse(BaseModel):
|
||||
"""视频审核进度响应"""
|
||||
review_id: str = Field(..., description="审核任务 ID")
|
||||
status: TaskStatus = Field(..., description="任务状态")
|
||||
progress: int = Field(..., ge=0, le=100, description="进度百分比")
|
||||
current_step: str = Field(..., description="当前处理步骤")
|
||||
|
||||
|
||||
class VideoReviewResultResponse(BaseModel):
|
||||
"""
|
||||
视频审核结果响应(200 OK)
|
||||
|
||||
结构与脚本审核一致:
|
||||
- score: 合规分数
|
||||
- summary: 整体摘要
|
||||
- violations: 违规项列表,每项包含 timestamp 和 suggestion
|
||||
"""
|
||||
review_id: str = Field(..., description="审核任务 ID")
|
||||
status: TaskStatus = Field(default=TaskStatus.COMPLETED, description="任务状态")
|
||||
score: int = Field(..., ge=0, le=100, description="合规分数")
|
||||
summary: str = Field(..., description="审核摘要")
|
||||
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||
|
||||
|
||||
# ==================== 一致性指标 ====================
|
||||
|
||||
class ConsistencyWindow(str, Enum):
|
||||
"""一致性指标计算周期"""
|
||||
ROLLING_30D = "rolling_30d"
|
||||
SNAPSHOT_WEEK = "snapshot_week"
|
||||
SNAPSHOT_MONTH = "snapshot_month"
|
||||
|
||||
|
||||
class RuleConsistencyMetric(BaseModel):
|
||||
"""按规则类型的指标"""
|
||||
rule_type: ViolationType = Field(..., description="规则类型")
|
||||
total_reviews: int = Field(..., ge=0, description="总审核数")
|
||||
violation_count: int = Field(..., ge=0, description="违规数")
|
||||
violation_rate: float = Field(..., ge=0, le=1, description="违规率(0-1)")
|
||||
|
||||
|
||||
class ConsistencyMetricsResponse(BaseModel):
|
||||
"""一致性指标响应"""
|
||||
influencer_id: str = Field(..., description="达人 ID")
|
||||
window: ConsistencyWindow = Field(..., description="计算周期")
|
||||
period_start: datetime = Field(..., description="周期起始时间")
|
||||
period_end: datetime = Field(..., description="周期结束时间")
|
||||
metrics: list[RuleConsistencyMetric] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ==================== 特例审批(风控豁免) ====================
|
||||
|
||||
class RiskTargetType(str, Enum):
|
||||
"""特例目标类型"""
|
||||
INFLUENCER = "influencer"
|
||||
ORDER = "order"
|
||||
CONTENT = "content"
|
||||
|
||||
|
||||
class RiskExceptionStatus(str, Enum):
|
||||
"""特例审批状态"""
|
||||
PENDING = "pending"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
EXPIRED = "expired"
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class RiskExceptionCreateRequest(BaseModel):
|
||||
"""创建特例请求"""
|
||||
applicant_id: str = Field(..., description="申请人")
|
||||
target_type: RiskTargetType = Field(..., description="目标类型")
|
||||
target_id: str = Field(..., description="目标 ID")
|
||||
risk_rule_id: str = Field(..., description="豁免规则 ID")
|
||||
reason_category: str = Field(..., description="原因分类")
|
||||
justification: str = Field(..., min_length=1, description="详细理由")
|
||||
attachment_url: Optional[str] = Field(None, description="附件链接")
|
||||
current_approver_id: str = Field(..., description="当前审批人")
|
||||
valid_start_time: datetime = Field(..., description="生效开始时间")
|
||||
valid_end_time: datetime = Field(..., description="生效结束时间")
|
||||
|
||||
|
||||
class RiskExceptionRecord(BaseModel):
|
||||
"""特例记录"""
|
||||
record_id: str = Field(..., description="记录 ID")
|
||||
applicant_id: str = Field(..., description="申请人")
|
||||
apply_time: datetime = Field(..., description="申请时间")
|
||||
target_type: RiskTargetType = Field(..., description="目标类型")
|
||||
target_id: str = Field(..., description="目标 ID")
|
||||
risk_rule_id: str = Field(..., description="豁免规则 ID")
|
||||
status: RiskExceptionStatus = Field(..., description="状态")
|
||||
valid_start_time: datetime = Field(..., description="生效开始时间")
|
||||
valid_end_time: datetime = Field(..., description="生效结束时间")
|
||||
reason_category: str = Field(..., description="原因分类")
|
||||
justification: str = Field(..., description="详细理由")
|
||||
attachment_url: Optional[str] = Field(None, description="附件链接")
|
||||
current_approver_id: Optional[str] = Field(None, description="当前审批人")
|
||||
approval_chain_log: list[dict] = Field(default_factory=list, description="审批流转日志")
|
||||
auto_rejected: bool = Field(default=False, description="是否超时自动拒绝")
|
||||
rejection_reason: Optional[str] = Field(None, description="驳回原因")
|
||||
last_status_at: Optional[datetime] = Field(None, description="最近状态变更时间")
|
||||
|
||||
|
||||
class RiskExceptionDecisionRequest(BaseModel):
|
||||
"""特例审批决策请求"""
|
||||
approver_id: str = Field(..., description="审批人")
|
||||
comment: Optional[str] = Field(None, description="审批备注")
|
||||
|
||||
|
||||
# ==================== 审核任务 ====================
|
||||
|
||||
class TaskCreateRequest(BaseModel):
|
||||
"""创建任务请求"""
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
creator_id: str = Field(..., description="达人 ID")
|
||||
video_url: Optional[HttpUrl] = Field(None, description="视频 URL")
|
||||
script_content: Optional[str] = Field(None, min_length=1, description="脚本内容")
|
||||
script_file_url: Optional[HttpUrl] = Field(None, description="脚本文档 URL")
|
||||
|
||||
|
||||
class TaskScriptUploadRequest(BaseModel):
|
||||
"""上传脚本请求"""
|
||||
script_content: Optional[str] = Field(None, min_length=1, description="脚本内容")
|
||||
script_file_url: Optional[HttpUrl] = Field(None, description="脚本文档 URL")
|
||||
|
||||
|
||||
class TaskVideoUploadRequest(BaseModel):
|
||||
"""上传视频请求"""
|
||||
video_url: HttpUrl = Field(..., description="视频 URL")
|
||||
|
||||
|
||||
class TaskResponse(BaseModel):
|
||||
"""任务响应"""
|
||||
task_id: str = Field(..., description="任务 ID")
|
||||
video_url: Optional[str] = Field(None, description="视频 URL")
|
||||
script_content: Optional[str] = Field(None, description="脚本内容")
|
||||
script_file_url: Optional[str] = Field(None, description="脚本文档 URL")
|
||||
has_script: bool = Field(..., description="是否已上传脚本")
|
||||
has_video: bool = Field(..., description="是否已上传视频")
|
||||
platform: Platform = Field(..., description="投放平台")
|
||||
creator_id: str = Field(..., description="达人 ID")
|
||||
status: TaskStatus = Field(..., description="任务状态")
|
||||
created_at: str = Field(..., description="创建时间")
|
||||
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
"""任务列表响应"""
|
||||
items: list[TaskResponse] = Field(default_factory=list)
|
||||
total: int = Field(..., description="总数")
|
||||
page: int = Field(..., description="当前页")
|
||||
page_size: int = Field(..., description="每页数量")
|
||||
|
||||
|
||||
class TaskApproveRequest(BaseModel):
|
||||
"""通过任务请求"""
|
||||
comment: Optional[str] = Field(None, description="备注")
|
||||
|
||||
|
||||
class TaskRejectRequest(BaseModel):
|
||||
"""驳回任务请求"""
|
||||
reason: str = Field(..., min_length=1, description="驳回原因")
|
||||
violations: list[str] = Field(default_factory=list, description="违规类型列表")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
"""后台任务模块"""
|
||||
from app.celery_app import celery_app
|
||||
|
||||
__all__ = ["celery_app"]
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
视频审核后台任务
|
||||
完整的视频审核流程:下载 → 提取帧 → ASR → 视觉分析 → 生成报告
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
from app.models.review import ReviewTask, TaskStatus as DBTaskStatus
|
||||
from app.models.rule import ForbiddenWord, Competitor
|
||||
from app.models.ai_config import AIConfig
|
||||
from app.services.video_download import VideoDownloadService, DownloadResult
|
||||
from app.services.keyframe import KeyFrameExtractor, ExtractionResult
|
||||
from app.services.asr import VideoASRService, TranscriptionResult
|
||||
from app.services.vision import CompetitorLogoDetector, VideoOCRService
|
||||
from app.services.video_review import VideoReviewService
|
||||
from app.utils.crypto import decrypt_api_key
|
||||
|
||||
|
||||
# 异步数据库引擎
|
||||
_async_engine = None
|
||||
_async_session_factory = None
|
||||
|
||||
|
||||
def get_async_engine():
|
||||
"""获取异步数据库引擎"""
|
||||
global _async_engine
|
||||
if _async_engine is None:
|
||||
_async_engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
)
|
||||
return _async_engine
|
||||
|
||||
|
||||
def get_async_session() -> sessionmaker:
|
||||
"""获取异步会话工厂"""
|
||||
global _async_session_factory
|
||||
if _async_session_factory is None:
|
||||
_async_session_factory = sessionmaker(
|
||||
get_async_engine(),
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
return _async_session_factory
|
||||
|
||||
|
||||
async def update_review_progress(
|
||||
db: AsyncSession,
|
||||
review_id: str,
|
||||
progress: int,
|
||||
current_step: str,
|
||||
status: Optional[DBTaskStatus] = None,
|
||||
):
|
||||
"""更新审核进度"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(ReviewTask.id == review_id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task:
|
||||
task.progress = progress
|
||||
task.current_step = current_step
|
||||
if status:
|
||||
task.status = status
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def complete_review(
|
||||
db: AsyncSession,
|
||||
review_id: str,
|
||||
score: int,
|
||||
summary: str,
|
||||
violations: list[dict],
|
||||
status: DBTaskStatus = DBTaskStatus.COMPLETED,
|
||||
):
|
||||
"""完成审核"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(ReviewTask.id == review_id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task:
|
||||
task.status = status
|
||||
task.progress = 100
|
||||
task.current_step = "完成"
|
||||
task.score = score
|
||||
task.summary = summary
|
||||
task.violations = violations
|
||||
task.completed_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def fail_review(
|
||||
db: AsyncSession,
|
||||
review_id: str,
|
||||
error: str,
|
||||
):
|
||||
"""审核失败"""
|
||||
result = await db.execute(
|
||||
select(ReviewTask).where(ReviewTask.id == review_id)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if task:
|
||||
task.status = DBTaskStatus.FAILED
|
||||
task.current_step = "失败"
|
||||
task.summary = f"审核失败: {error}"
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_ai_config(db: AsyncSession, tenant_id: str) -> Optional[dict]:
|
||||
"""获取租户 AI 配置"""
|
||||
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
|
||||
|
||||
return {
|
||||
"api_key": decrypt_api_key(config.api_key_encrypted),
|
||||
"base_url": config.base_url,
|
||||
"models": config.models,
|
||||
}
|
||||
|
||||
|
||||
async def get_forbidden_words(db: AsyncSession, tenant_id: str) -> list[str]:
|
||||
"""获取违禁词列表"""
|
||||
result = await db.execute(
|
||||
select(ForbiddenWord.word).where(ForbiddenWord.tenant_id == tenant_id)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def get_competitors(db: AsyncSession, tenant_id: str, brand_id: str) -> list[str]:
|
||||
"""获取竞品列表"""
|
||||
result = await db.execute(
|
||||
select(Competitor.name).where(
|
||||
Competitor.tenant_id == tenant_id,
|
||||
Competitor.brand_id == brand_id,
|
||||
)
|
||||
)
|
||||
return [row[0] for row in result.fetchall()]
|
||||
|
||||
|
||||
async def process_video_review(
|
||||
review_id: str,
|
||||
tenant_id: str,
|
||||
video_url: str,
|
||||
brand_id: str,
|
||||
platform: str,
|
||||
):
|
||||
"""
|
||||
处理视频审核(异步核心逻辑)
|
||||
|
||||
流程:
|
||||
1. 下载视频
|
||||
2. 提取关键帧
|
||||
3. ASR 语音转写
|
||||
4. 视觉分析(竞品 Logo 检测)
|
||||
5. OCR 字幕提取
|
||||
6. 违规检测
|
||||
7. 生成报告
|
||||
"""
|
||||
session_factory = get_async_session()
|
||||
download_service = VideoDownloadService()
|
||||
keyframe_extractor = KeyFrameExtractor()
|
||||
review_service = VideoReviewService()
|
||||
|
||||
video_path = None
|
||||
frames_dir = None
|
||||
logo_detector = None
|
||||
ocr_service = None
|
||||
asr_service = None
|
||||
|
||||
async with session_factory() as db:
|
||||
try:
|
||||
# 更新状态:处理中
|
||||
await update_review_progress(
|
||||
db, review_id, 5, "开始处理",
|
||||
status=DBTaskStatus.PROCESSING,
|
||||
)
|
||||
|
||||
# 获取 AI 配置
|
||||
ai_config = await get_ai_config(db, tenant_id)
|
||||
if not ai_config:
|
||||
await fail_review(db, review_id, "AI 服务未配置")
|
||||
return
|
||||
|
||||
# 获取规则
|
||||
forbidden_words = await get_forbidden_words(db, tenant_id)
|
||||
competitors = await get_competitors(db, tenant_id, brand_id)
|
||||
|
||||
# 初始化 AI 服务
|
||||
api_key = ai_config["api_key"]
|
||||
base_url = ai_config["base_url"]
|
||||
models = ai_config["models"]
|
||||
|
||||
asr_service = VideoASRService(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=models.get("audio", "whisper-1"),
|
||||
)
|
||||
logo_detector = CompetitorLogoDetector(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=models.get("vision", "gpt-4o"),
|
||||
)
|
||||
ocr_service = VideoOCRService(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=models.get("vision", "gpt-4o"),
|
||||
)
|
||||
|
||||
# 1. 下载视频
|
||||
await update_review_progress(db, review_id, 10, "下载视频")
|
||||
download_result: DownloadResult = await download_service.download(video_url)
|
||||
if not download_result.success:
|
||||
await fail_review(db, review_id, f"视频下载失败: {download_result.error}")
|
||||
return
|
||||
video_path = download_result.file_path
|
||||
|
||||
# 2. 提取关键帧
|
||||
await update_review_progress(db, review_id, 25, "提取关键帧")
|
||||
extraction_result: ExtractionResult = await keyframe_extractor.extract_at_intervals(
|
||||
video_path,
|
||||
interval_seconds=2.0,
|
||||
max_frames=30,
|
||||
)
|
||||
if not extraction_result.success:
|
||||
await fail_review(db, review_id, f"关键帧提取失败: {extraction_result.error}")
|
||||
return
|
||||
frames_dir = extraction_result.output_dir
|
||||
frames = extraction_result.frames
|
||||
|
||||
all_violations = []
|
||||
|
||||
# 3. ASR 语音转写
|
||||
await update_review_progress(db, review_id, 40, "语音转写")
|
||||
transcript_result: TranscriptionResult = await asr_service.transcribe_video(video_path)
|
||||
transcript = []
|
||||
if transcript_result.success:
|
||||
transcript = [
|
||||
{"text": seg.text, "start": seg.start, "end": seg.end}
|
||||
for seg in transcript_result.segments
|
||||
]
|
||||
|
||||
# 检测口播违禁词
|
||||
speech_violations = await review_service.detect_forbidden_words_in_speech(
|
||||
transcript,
|
||||
forbidden_words,
|
||||
context_aware=True,
|
||||
)
|
||||
all_violations.extend(speech_violations)
|
||||
|
||||
# 4. 视觉分析 - 竞品 Logo 检测
|
||||
await update_review_progress(db, review_id, 60, "检测竞品 Logo")
|
||||
if competitors and frames:
|
||||
logo_violations = await logo_detector.detect(frames, competitors)
|
||||
all_violations.extend(logo_violations)
|
||||
|
||||
# 5. OCR 字幕提取
|
||||
await update_review_progress(db, review_id, 75, "提取字幕")
|
||||
if frames:
|
||||
subtitles = await ocr_service.extract_subtitles(frames)
|
||||
|
||||
# 检测字幕违禁词
|
||||
subtitle_violations = await review_service.detect_forbidden_words_in_subtitle(
|
||||
subtitles,
|
||||
forbidden_words,
|
||||
)
|
||||
all_violations.extend(subtitle_violations)
|
||||
|
||||
# 6. 计算分数和生成报告
|
||||
await update_review_progress(db, review_id, 90, "生成报告")
|
||||
score = review_service.calculate_score(all_violations)
|
||||
|
||||
if not all_violations:
|
||||
summary = "视频内容合规,未发现违规项"
|
||||
else:
|
||||
high_count = sum(1 for v in all_violations if v.get("risk_level") == "high")
|
||||
medium_count = sum(1 for v in all_violations if v.get("risk_level") == "medium")
|
||||
summary = f"发现 {len(all_violations)} 处违规"
|
||||
if high_count > 0:
|
||||
summary += f"({high_count} 处高风险)"
|
||||
|
||||
# 7. 完成审核
|
||||
await complete_review(
|
||||
db,
|
||||
review_id,
|
||||
score=score,
|
||||
summary=summary,
|
||||
violations=all_violations,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await fail_review(db, review_id, str(e))
|
||||
|
||||
finally:
|
||||
# 清理资源
|
||||
if video_path:
|
||||
download_service.cleanup(video_path)
|
||||
if frames_dir:
|
||||
keyframe_extractor.cleanup(frames_dir)
|
||||
if logo_detector:
|
||||
await logo_detector.close()
|
||||
if ocr_service:
|
||||
await ocr_service.close()
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
name="app.tasks.review.process_video_review_task",
|
||||
max_retries=3,
|
||||
default_retry_delay=60,
|
||||
)
|
||||
def process_video_review_task(
|
||||
self,
|
||||
review_id: str,
|
||||
tenant_id: str,
|
||||
video_url: str,
|
||||
brand_id: str,
|
||||
platform: str,
|
||||
):
|
||||
"""
|
||||
视频审核 Celery 任务
|
||||
|
||||
Args:
|
||||
review_id: 审核任务 ID
|
||||
tenant_id: 租户 ID
|
||||
video_url: 视频 URL
|
||||
brand_id: 品牌 ID
|
||||
platform: 平台
|
||||
"""
|
||||
try:
|
||||
# 运行异步任务
|
||||
asyncio.run(process_video_review(
|
||||
review_id=review_id,
|
||||
tenant_id=tenant_id,
|
||||
video_url=video_url,
|
||||
brand_id=brand_id,
|
||||
platform=platform,
|
||||
))
|
||||
except Exception as e:
|
||||
# 重试
|
||||
raise self.retry(exc=e)
|
||||
|
||||
|
||||
@shared_task(name="app.tasks.review.cleanup_old_files_task")
|
||||
def cleanup_old_files_task():
|
||||
"""清理过期的临时文件"""
|
||||
from app.services.video_download import get_download_service
|
||||
|
||||
service = get_download_service()
|
||||
deleted = service.cleanup_old_files(max_age_seconds=3600)
|
||||
return {"deleted_files": deleted}
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
工具模块
|
||||
"""
|
||||
from app.utils.crypto import encrypt_api_key, decrypt_api_key
|
||||
|
||||
__all__ = [
|
||||
"encrypt_api_key",
|
||||
"decrypt_api_key",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
加密工具
|
||||
API Key 加解密
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
"""
|
||||
获取 Fernet 加密器
|
||||
使用应用的 SECRET_KEY 派生加密密钥
|
||||
"""
|
||||
# 使用 PBKDF2 从 SECRET_KEY 派生 32 字节密钥
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=b"miaosi-api-key-salt", # 固定 salt,生产环境应配置为环境变量
|
||||
iterations=100000,
|
||||
)
|
||||
key = base64.urlsafe_b64encode(
|
||||
kdf.derive(settings.SECRET_KEY.encode())
|
||||
)
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_api_key(api_key: str) -> str:
|
||||
"""
|
||||
加密 API Key
|
||||
|
||||
Args:
|
||||
api_key: 明文 API Key
|
||||
|
||||
Returns:
|
||||
加密后的 Base64 字符串
|
||||
"""
|
||||
if not api_key:
|
||||
return ""
|
||||
|
||||
fernet = _get_fernet()
|
||||
encrypted = fernet.encrypt(api_key.encode())
|
||||
return encrypted.decode()
|
||||
|
||||
|
||||
def decrypt_api_key(encrypted: str) -> str:
|
||||
"""
|
||||
解密 API Key
|
||||
|
||||
Args:
|
||||
encrypted: 加密的 API Key
|
||||
|
||||
Returns:
|
||||
明文 API Key
|
||||
"""
|
||||
if not encrypted:
|
||||
return ""
|
||||
|
||||
fernet = _get_fernet()
|
||||
decrypted = fernet.decrypt(encrypted.encode())
|
||||
return decrypted.decode()
|
||||
|
||||
|
||||
def mask_api_key(api_key: str) -> str:
|
||||
"""
|
||||
脱敏 API Key
|
||||
|
||||
Args:
|
||||
api_key: API Key(明文或加密均可)
|
||||
|
||||
Returns:
|
||||
脱敏后的字符串,如 "sk-1234****5678"
|
||||
"""
|
||||
if not api_key:
|
||||
return ""
|
||||
|
||||
if len(api_key) <= 8:
|
||||
return "****"
|
||||
|
||||
return f"{api_key[:4]}****{api_key[-4:]}"
|
||||
Reference in New Issue
Block a user