feat: 审核体系全面改造 — 多维度评分 + 卖点优先级 + AI 语义匹配 + 品牌方 AI 状态通知
后端: - 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度) - 卖点优先级从 required:bool 改为三级 (core/recommended/reference) - AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析 - BriefMatchDetail 评分详情 (覆盖率+亮点+问题点) - min_selling_points 代理商可配置最少卖点数 + Alembic 迁移 - AI 语境复核过滤误报 - Brief AI 解析 + 规则 AI 解析 - AI 未配置/异常时通知品牌方 - 种子数据更新 (新格式审核结果+brief_match_detail) 前端: - 三端审核页面展示四维度评分卡片 - 卖点编辑改为三级优先级选择器 - BriefMatchDetail 展示 (覆盖率进度条+亮点+问题) - min_selling_points 配置 UI - AI 配置页未配置时静默处理 - 文件预览/下载/签名 URL 优化 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
0c59797d5b
commit
0ef7650c09
@@ -0,0 +1,25 @@
|
||||
"""add min_selling_points to briefs
|
||||
|
||||
Revision ID: 261778c01ef8
|
||||
Revises: 008
|
||||
Create Date: 2026-02-11 18:16:59.557746
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '261778c01ef8'
|
||||
down_revision: Union[str, None] = '008'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('briefs', sa.Column('min_selling_points', sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('briefs', 'min_selling_points')
|
||||
+235
-4
@@ -1,8 +1,12 @@
|
||||
"""
|
||||
Brief API
|
||||
项目 Brief 文档的 CRUD
|
||||
项目 Brief 文档的 CRUD + AI 解析
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -21,6 +25,8 @@ from app.schemas.brief import (
|
||||
)
|
||||
from app.services.auth import generate_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/projects/{project_id}/brief", tags=["Brief"])
|
||||
|
||||
|
||||
@@ -75,6 +81,7 @@ def _brief_to_response(brief: Brief) -> BriefResponse:
|
||||
file_url=brief.file_url,
|
||||
file_name=brief.file_name,
|
||||
selling_points=brief.selling_points,
|
||||
min_selling_points=brief.min_selling_points,
|
||||
blacklist_words=brief.blacklist_words,
|
||||
competitors=brief.competitors,
|
||||
brand_tone=brief.brand_tone,
|
||||
@@ -192,9 +199,10 @@ async def update_brief_agency_attachments(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新 Brief 代理商附件(代理商操作)
|
||||
"""更新 Brief 代理商配置(代理商操作)
|
||||
|
||||
代理商只能更新 agency_attachments 字段,不能修改品牌方设置的其他 Brief 内容。
|
||||
代理商可更新:agency_attachments、selling_points、blacklist_words。
|
||||
不能修改品牌方设置的核心 Brief 内容(文件、时长、竞品等)。
|
||||
"""
|
||||
# 权限检查:代理商必须属于该项目
|
||||
result = await db.execute(
|
||||
@@ -234,7 +242,7 @@ async def update_brief_agency_attachments(
|
||||
if not brief:
|
||||
raise HTTPException(status_code=404, detail="Brief 不存在")
|
||||
|
||||
# 仅更新 agency_attachments
|
||||
# 更新代理商可编辑的字段
|
||||
update_fields = request.model_dump(exclude_unset=True)
|
||||
for field, value in update_fields.items():
|
||||
setattr(brief, field, value)
|
||||
@@ -243,3 +251,226 @@ async def update_brief_agency_attachments(
|
||||
await db.refresh(brief)
|
||||
|
||||
return _brief_to_response(brief)
|
||||
|
||||
|
||||
# ==================== AI 解析 ====================
|
||||
|
||||
class BriefParseResponse(BaseModel):
|
||||
"""Brief AI 解析响应"""
|
||||
product_name: str = ""
|
||||
target_audience: str = ""
|
||||
content_requirements: str = ""
|
||||
selling_points: list[dict] = []
|
||||
blacklist_words: list[dict] = []
|
||||
|
||||
|
||||
@router.post("/parse", response_model=BriefParseResponse)
|
||||
async def parse_brief_with_ai(
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
AI 解析 Brief 文档
|
||||
|
||||
从品牌方上传的 Brief 文件中提取结构化信息:
|
||||
- 产品名称
|
||||
- 目标人群
|
||||
- 内容要求
|
||||
- 卖点建议
|
||||
- 违禁词建议
|
||||
"""
|
||||
# 权限检查(代理商需要属于该项目)
|
||||
project = await _get_project_with_permission(project_id, current_user, db)
|
||||
|
||||
# 获取 Brief
|
||||
result = await db.execute(
|
||||
select(Brief)
|
||||
.options(selectinload(Brief.project))
|
||||
.where(Brief.project_id == project_id)
|
||||
)
|
||||
brief = result.scalar_one_or_none()
|
||||
if not brief:
|
||||
raise HTTPException(status_code=404, detail="Brief 不存在,请先让品牌方创建 Brief")
|
||||
|
||||
# 收集所有可解析的文档 URL
|
||||
documents: list[dict] = [] # [{"url": ..., "name": ...}]
|
||||
|
||||
if brief.file_url and brief.file_name:
|
||||
documents.append({"url": brief.file_url, "name": brief.file_name})
|
||||
|
||||
if brief.attachments:
|
||||
for att in brief.attachments:
|
||||
if att.get("url") and att.get("name"):
|
||||
documents.append({"url": att["url"], "name": att["name"]})
|
||||
|
||||
if not documents:
|
||||
raise HTTPException(status_code=400, detail="Brief 没有可解析的文件")
|
||||
|
||||
# 提取文本(每个文档限时 60 秒)
|
||||
import asyncio
|
||||
from app.services.document_parser import DocumentParser
|
||||
|
||||
all_texts = []
|
||||
for doc in documents:
|
||||
try:
|
||||
text = await asyncio.wait_for(
|
||||
DocumentParser.download_and_parse(doc["url"], doc["name"]),
|
||||
timeout=60.0,
|
||||
)
|
||||
if text and text.strip():
|
||||
all_texts.append(f"=== {doc['name']} ===\n{text}")
|
||||
logger.info(f"成功解析文档 {doc['name']},提取 {len(text)} 字符")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"解析文档 {doc['name']} 超时(60s),已跳过")
|
||||
except Exception as e:
|
||||
logger.warning(f"解析文档 {doc['name']} 失败: {e}")
|
||||
|
||||
if not all_texts:
|
||||
raise HTTPException(status_code=400, detail="所有文档均解析失败,无法提取文本内容")
|
||||
|
||||
combined_text = "\n\n".join(all_texts)
|
||||
|
||||
# 截断过长文本
|
||||
max_chars = 15000
|
||||
if len(combined_text) > max_chars:
|
||||
combined_text = combined_text[:max_chars] + "\n...(内容已截断)"
|
||||
|
||||
# 获取 AI 客户端
|
||||
from app.services.ai_service import AIServiceFactory
|
||||
|
||||
tenant_id = project.brand_id or "default"
|
||||
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||||
if not ai_client:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="AI 服务未配置,请在品牌方设置中配置 AI 服务",
|
||||
)
|
||||
|
||||
config = await AIServiceFactory.get_config(tenant_id, db)
|
||||
text_model = "gpt-4o"
|
||||
if config and config.models:
|
||||
text_model = config.models.get("text", "gpt-4o")
|
||||
|
||||
# AI 解析
|
||||
prompt = f"""你是营销内容合规审核专家。请从以下品牌方 Brief 文档中提取结构化信息。
|
||||
|
||||
文档内容:
|
||||
{combined_text}
|
||||
|
||||
请以 JSON 格式返回,不要包含其他内容:
|
||||
{{
|
||||
"product_name": "产品名称",
|
||||
"target_audience": "目标人群描述",
|
||||
"content_requirements": "内容创作要求的简要总结",
|
||||
"selling_points": [
|
||||
{{"content": "卖点1", "priority": "core"}},
|
||||
{{"content": "卖点2", "priority": "recommended"}},
|
||||
{{"content": "卖点3", "priority": "reference"}}
|
||||
],
|
||||
"blacklist_words": [
|
||||
{{"word": "违禁词1", "reason": "原因"}},
|
||||
{{"word": "违禁词2", "reason": "原因"}}
|
||||
]
|
||||
}}
|
||||
|
||||
说明:
|
||||
- product_name: 从文档中识别的产品/品牌名称
|
||||
- target_audience: 目标消费人群
|
||||
- content_requirements: 对达人创作内容的要求(时长、风格、场景等)
|
||||
- selling_points: 产品卖点,priority 说明:
|
||||
- "core": 核心卖点,品牌方重点关注,建议优先传达
|
||||
- "recommended": 推荐卖点,建议提及
|
||||
- "reference": 参考信息,不要求出现在脚本中
|
||||
- blacklist_words: 从文档中识别的需要避免的词语(绝对化用语、竞品名、敏感词等)"""
|
||||
|
||||
last_error = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
response = await ai_client.chat_completion(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
model=text_model,
|
||||
temperature=0.2 if attempt == 0 else 0.1,
|
||||
max_tokens=2000,
|
||||
)
|
||||
|
||||
# 提取 JSON
|
||||
logger.info(f"AI 原始响应 (attempt={attempt}): {response.content[:500]}")
|
||||
content = _extract_json_from_response(response.content)
|
||||
logger.info(f"提取的 JSON: {content[:500]}")
|
||||
parsed = json.loads(content)
|
||||
|
||||
return BriefParseResponse(
|
||||
product_name=parsed.get("product_name", ""),
|
||||
target_audience=parsed.get("target_audience", ""),
|
||||
content_requirements=parsed.get("content_requirements", ""),
|
||||
selling_points=parsed.get("selling_points", []),
|
||||
blacklist_words=parsed.get("blacklist_words", []),
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
last_error = e
|
||||
logger.warning(f"AI 返回内容非 JSON (attempt={attempt}): {e}, raw={response.content[:300]}")
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"AI 解析 Brief 失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"AI 解析失败: {str(e)[:200]}")
|
||||
|
||||
# 两次都失败
|
||||
logger.error(f"AI 解析 Brief JSON 格式错误,两次重试均失败: {last_error}")
|
||||
raise HTTPException(status_code=500, detail="AI 解析结果格式错误,请重试")
|
||||
|
||||
|
||||
def _extract_json_from_response(raw: str) -> str:
|
||||
"""从 AI 响应中提取 JSON 内容(处理 markdown 代码块、中文引号等)"""
|
||||
import re
|
||||
text = raw.strip()
|
||||
|
||||
# 移除 markdown ```json ... ``` 代码块包裹
|
||||
m = re.search(r'```(?:json)?\s*\n(.*?)```', text, re.DOTALL)
|
||||
if m:
|
||||
text = m.group(1).strip()
|
||||
|
||||
# 尝试找到第一个 { 和最后一个 }
|
||||
first_brace = text.find("{")
|
||||
last_brace = text.rfind("}")
|
||||
if first_brace != -1 and last_brace != -1 and last_brace > first_brace:
|
||||
text = text[first_brace:last_brace + 1]
|
||||
|
||||
# 清理中文引号等特殊字符
|
||||
text = _sanitize_json_string(text)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _sanitize_json_string(text: str) -> str:
|
||||
"""
|
||||
清理 AI 返回的 JSON 文本中的中文引号等特殊字符。
|
||||
中文引号 "" 在 JSON 字符串值内会破坏解析。
|
||||
"""
|
||||
result = []
|
||||
in_string = False
|
||||
i = 0
|
||||
while i < len(text):
|
||||
ch = text[i]
|
||||
if ch == '\\' and in_string and i + 1 < len(text):
|
||||
result.append(ch)
|
||||
result.append(text[i + 1])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"' and not in_string:
|
||||
in_string = True
|
||||
result.append(ch)
|
||||
elif ch == '"' and in_string:
|
||||
in_string = False
|
||||
result.append(ch)
|
||||
elif in_string and ch in '\u201c\u201d\u300c\u300d':
|
||||
# 中文引号 "" 和「」 → 单引号
|
||||
result.append("'")
|
||||
elif not in_string and ch in '\u201c\u201d':
|
||||
# JSON 结构层的中文引号 → 英文双引号
|
||||
result.append('"')
|
||||
else:
|
||||
result.append(ch)
|
||||
i += 1
|
||||
return ''.join(result)
|
||||
|
||||
@@ -141,6 +141,7 @@ async def create_project(
|
||||
sender_name=brand.name,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return await _project_to_response(project, db)
|
||||
|
||||
|
||||
@@ -354,6 +355,7 @@ async def assign_agencies(
|
||||
sender_name=brand.name,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return await _project_to_response(project, db)
|
||||
|
||||
|
||||
|
||||
@@ -131,10 +131,14 @@ _platform_rules = {
|
||||
"xiaohongshu": {
|
||||
"platform": "xiaohongshu",
|
||||
"rules": [
|
||||
{"type": "forbidden_word", "words": ["最好", "绝对", "100%"]},
|
||||
{"type": "forbidden_word", "words": [
|
||||
"最好", "绝对", "100%", "第一", "最佳", "国家级", "顶级",
|
||||
"万能", "神器", "秒杀", "碾压", "永久", "根治",
|
||||
"一次见效", "立竿见影", "无副作用",
|
||||
]},
|
||||
],
|
||||
"version": "2024.01",
|
||||
"updated_at": "2024-01-10T00:00:00Z",
|
||||
"version": "2024.06",
|
||||
"updated_at": "2024-06-15T00:00:00Z",
|
||||
},
|
||||
"bilibili": {
|
||||
"platform": "bilibili",
|
||||
@@ -336,6 +340,33 @@ async def add_to_whitelist(
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/whitelist/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_whitelist_item(
|
||||
item_id: str,
|
||||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除白名单项"""
|
||||
result = await db.execute(
|
||||
select(WhitelistItem).where(
|
||||
and_(
|
||||
WhitelistItem.id == item_id,
|
||||
WhitelistItem.tenant_id == x_tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
|
||||
if not item:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"白名单项不存在: {item_id}",
|
||||
)
|
||||
|
||||
await db.delete(item)
|
||||
await db.flush()
|
||||
|
||||
|
||||
# ==================== 竞品库 ====================
|
||||
|
||||
@router.get("/competitors", response_model=CompetitorListResponse)
|
||||
@@ -1012,6 +1043,35 @@ async def get_forbidden_words_for_tenant(
|
||||
]
|
||||
|
||||
|
||||
async def get_competitors_for_brand(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
db: AsyncSession,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
获取品牌方配置的竞品列表
|
||||
|
||||
Returns:
|
||||
[{"name": "竞品名", "keywords": ["关键词1", ...]}]
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(Competitor).where(
|
||||
and_(
|
||||
Competitor.tenant_id == tenant_id,
|
||||
Competitor.brand_id == brand_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
competitors = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"name": c.name,
|
||||
"keywords": c.keywords or [],
|
||||
}
|
||||
for c in competitors
|
||||
]
|
||||
|
||||
|
||||
async def get_active_platform_rules(
|
||||
tenant_id: str,
|
||||
brand_id: str,
|
||||
|
||||
+749
-146
File diff suppressed because it is too large
Load Diff
+557
-19
@@ -2,13 +2,15 @@
|
||||
任务 API
|
||||
实现完整的审核任务流程
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import get_db
|
||||
from app.database import get_db, AsyncSessionLocal
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.task import Task, TaskStage, TaskStatus
|
||||
from app.models.project import Project
|
||||
@@ -41,6 +43,7 @@ from app.services.task_service import (
|
||||
check_task_permission,
|
||||
upload_script,
|
||||
upload_video,
|
||||
complete_ai_review,
|
||||
agency_review,
|
||||
brand_review,
|
||||
submit_appeal,
|
||||
@@ -53,10 +56,350 @@ from app.services.task_service import (
|
||||
)
|
||||
from app.api.sse import notify_new_task, notify_task_updated, notify_review_decision
|
||||
from app.services.message_service import create_message
|
||||
from app.models.brief import Brief
|
||||
from app.schemas.review import ScriptReviewRequest, Platform
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["任务"])
|
||||
|
||||
|
||||
async def _run_script_ai_review(task_id: str, tenant_id: str):
|
||||
"""
|
||||
后台执行脚本 AI 审核
|
||||
|
||||
- 获取 Brief 信息(卖点、黑名单词)
|
||||
- 调用 review_script 进行审核
|
||||
- 保存审核结果并推进任务阶段
|
||||
- 发送 SSE 通知
|
||||
"""
|
||||
from app.api.scripts import review_script
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
task = await get_task_by_id(db, task_id)
|
||||
if not task or task.stage.value != "script_ai_review":
|
||||
logger.warning(f"任务 {task_id} 不在 AI 审核阶段,跳过")
|
||||
return
|
||||
|
||||
# 获取项目信息
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == task.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if not project:
|
||||
logger.error(f"任务 {task_id} 对应的项目不存在")
|
||||
return
|
||||
|
||||
# 获取 Brief
|
||||
brief_result = await db.execute(
|
||||
select(Brief).where(Brief.project_id == project.id)
|
||||
)
|
||||
brief = brief_result.scalar_one_or_none()
|
||||
|
||||
# 构建审核请求
|
||||
platform = project.platform or "douyin"
|
||||
selling_points = brief.selling_points if brief else None
|
||||
blacklist_words = brief.blacklist_words if brief else None
|
||||
min_selling_points = brief.min_selling_points if brief else None
|
||||
|
||||
request = ScriptReviewRequest(
|
||||
content=" ", # 占位,实际内容从 file_url 解析
|
||||
platform=Platform(platform),
|
||||
brand_id=project.brand_id,
|
||||
selling_points=selling_points,
|
||||
min_selling_points=min_selling_points,
|
||||
blacklist_words=blacklist_words,
|
||||
file_url=task.script_file_url,
|
||||
file_name=task.script_file_name,
|
||||
)
|
||||
|
||||
# 调用审核逻辑
|
||||
result = await review_script(
|
||||
request=request,
|
||||
x_tenant_id=tenant_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
# 保存审核结果
|
||||
task = await get_task_by_id(db, task_id)
|
||||
task = await complete_ai_review(
|
||||
db=db,
|
||||
task=task,
|
||||
review_type="script",
|
||||
score=result.score,
|
||||
result=result.model_dump(),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"任务 {task_id} AI 审核完成,得分: {result.score}")
|
||||
|
||||
# SSE 通知达人和代理商
|
||||
try:
|
||||
user_ids = []
|
||||
creator_result = await db.execute(
|
||||
select(Creator).where(Creator.id == task.creator_id)
|
||||
)
|
||||
creator_obj = creator_result.scalar_one_or_none()
|
||||
if creator_obj:
|
||||
user_ids.append(creator_obj.user_id)
|
||||
|
||||
agency_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = agency_result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
user_ids.append(agency_obj.user_id)
|
||||
|
||||
if user_ids:
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=user_ids,
|
||||
data={"action": "ai_review_completed", "stage": task.stage.value, "score": result.score},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 创建消息通知代理商
|
||||
try:
|
||||
ag_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
ag_obj = ag_result.scalar_one_or_none()
|
||||
if ag_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=ag_obj.user_id,
|
||||
type="task",
|
||||
title="脚本 AI 审核完成",
|
||||
content=f"任务「{task.name}」AI 审核完成,综合得分 {result.score} 分,请审核。",
|
||||
related_task_id=task.id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# AI 未配置时通知品牌方
|
||||
if not result.ai_available:
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == project.brand_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj and brand_obj.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="AI 审核降级运行",
|
||||
content=f"任务「{task.name}」的 AI 审核已降级运行(仅关键词检测),请前往「AI 配置」完成设置以获得更精准的审核结果。",
|
||||
related_task_id=task.id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"任务 {task_id} AI 审核失败: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
# AI 审核异常时通知品牌方(rollback 后重新开始事务)
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == tenant_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj and brand_obj.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="AI 审核异常",
|
||||
content=f"任务 AI 审核过程中出错,审核结果可能不完整,请检查 AI 服务配置。错误信息:{str(e)[:100]}",
|
||||
related_task_id=task_id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _run_video_ai_review(task_id: str, tenant_id: str):
|
||||
"""
|
||||
后台执行视频 AI 审核
|
||||
|
||||
复用脚本审核的完整规则检测链(违禁词/竞品/平台规则/白名单/AI深度分析)。
|
||||
审核内容来源:已通过审核的脚本文本 + 视频文件(如可解析)。
|
||||
"""
|
||||
from app.api.scripts import review_script
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
await asyncio.sleep(2) # 模拟处理延迟
|
||||
|
||||
task = await get_task_by_id(db, task_id)
|
||||
if not task or task.stage.value != "video_ai_review":
|
||||
logger.warning(f"任务 {task_id} 不在视频 AI 审核阶段,跳过")
|
||||
return
|
||||
|
||||
# 获取项目信息
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == task.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if not project:
|
||||
logger.error(f"任务 {task_id} 对应的项目不存在")
|
||||
return
|
||||
|
||||
# 获取 Brief
|
||||
brief_result = await db.execute(
|
||||
select(Brief).where(Brief.project_id == project.id)
|
||||
)
|
||||
brief = brief_result.scalar_one_or_none()
|
||||
|
||||
platform = project.platform or "douyin"
|
||||
selling_points = brief.selling_points if brief else None
|
||||
blacklist_words = brief.blacklist_words if brief else None
|
||||
min_selling_points = brief.min_selling_points if brief else None
|
||||
|
||||
# 使用脚本内容作为审核基础(视频 ASR 尚未实现,先复用脚本文本)
|
||||
script_content = ""
|
||||
if task.script_file_url and task.script_file_name:
|
||||
# 脚本文件可用,复用
|
||||
pass # review_script 会自动解析 file_url
|
||||
|
||||
request = ScriptReviewRequest(
|
||||
content=script_content or " ",
|
||||
platform=Platform(platform),
|
||||
brand_id=project.brand_id,
|
||||
selling_points=selling_points,
|
||||
min_selling_points=min_selling_points,
|
||||
blacklist_words=blacklist_words,
|
||||
file_url=task.script_file_url,
|
||||
file_name=task.script_file_name,
|
||||
)
|
||||
|
||||
# 调用完整审核逻辑(竞品/违禁词/平台规则/白名单/AI深度分析全部参与)
|
||||
result = await review_script(
|
||||
request=request,
|
||||
x_tenant_id=tenant_id,
|
||||
db=db,
|
||||
)
|
||||
|
||||
video_score = result.score
|
||||
video_result = {
|
||||
"score": video_score,
|
||||
"summary": result.summary,
|
||||
"violations": [v.model_dump() for v in result.violations],
|
||||
"soft_warnings": [w.model_dump() for w in result.soft_warnings],
|
||||
"dimensions": result.dimensions.model_dump(),
|
||||
"selling_point_matches": [sp.model_dump() for sp in result.selling_point_matches],
|
||||
}
|
||||
|
||||
task = await get_task_by_id(db, task_id)
|
||||
task = await complete_ai_review(
|
||||
db=db,
|
||||
task=task,
|
||||
review_type="video",
|
||||
score=video_score,
|
||||
result=video_result,
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"任务 {task_id} 视频 AI 审核完成,得分: {video_score}")
|
||||
|
||||
# SSE 通知
|
||||
try:
|
||||
user_ids = []
|
||||
creator_result = await db.execute(
|
||||
select(Creator).where(Creator.id == task.creator_id)
|
||||
)
|
||||
creator_obj = creator_result.scalar_one_or_none()
|
||||
if creator_obj:
|
||||
user_ids.append(creator_obj.user_id)
|
||||
|
||||
agency_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = agency_result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
user_ids.append(agency_obj.user_id)
|
||||
|
||||
if user_ids:
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=user_ids,
|
||||
data={"action": "ai_review_completed", "stage": task.stage.value, "score": video_score},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 创建消息通知代理商
|
||||
try:
|
||||
ag_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
ag_obj = ag_result.scalar_one_or_none()
|
||||
if ag_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=ag_obj.user_id,
|
||||
type="task",
|
||||
title="视频 AI 审核完成",
|
||||
content=f"任务「{task.name}」视频 AI 审核完成,得分 {video_score} 分,请审核。",
|
||||
related_task_id=task.id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# AI 未配置时通知品牌方
|
||||
if not result.ai_available:
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == project.brand_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj and brand_obj.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="视频 AI 审核降级运行",
|
||||
content=f"任务「{task.name}」的视频 AI 审核已降级运行(仅关键词检测),请前往「AI 配置」完成设置以获得更精准的审核结果。",
|
||||
related_task_id=task.id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"任务 {task_id} 视频 AI 审核失败: {e}", exc_info=True)
|
||||
await db.rollback()
|
||||
# AI 审核异常时通知品牌方(rollback 后重新开始事务)
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == tenant_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj and brand_obj.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="视频 AI 审核异常",
|
||||
content=f"任务视频 AI 审核过程中出错,审核结果可能不完整,请检查 AI 服务配置。错误信息:{str(e)[:100]}",
|
||||
related_task_id=task_id,
|
||||
sender_name="系统",
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _task_to_response(task: Task) -> TaskResponse:
|
||||
"""将数据库模型转换为响应模型"""
|
||||
return TaskResponse(
|
||||
@@ -175,30 +518,64 @@ async def create_new_task(
|
||||
# 重新加载关联
|
||||
task = await get_task_by_id(db, task.id)
|
||||
|
||||
# 提取通知所需的值(commit 后 ORM 对象会过期,提前缓存)
|
||||
_task_id = task.id
|
||||
_task_name = task.name
|
||||
_project_id = task.project.id
|
||||
_project_name = task.project.name
|
||||
_project_brand_id = task.project.brand_id
|
||||
_agency_name = agency.name
|
||||
_creator_user_id = creator.user_id
|
||||
_creator_name = creator.name or creator.id
|
||||
|
||||
# 创建消息 + SSE 通知达人有新任务
|
||||
try:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=creator.user_id,
|
||||
user_id=_creator_user_id,
|
||||
type="new_task",
|
||||
title="新任务分配",
|
||||
content=f"您有新的任务「{task.name}」,来自项目「{task.project.name}」",
|
||||
related_task_id=task.id,
|
||||
related_project_id=task.project.id,
|
||||
sender_name=agency.name,
|
||||
content=f"您有新的任务「{_task_name}」,来自项目「{_project_name}」",
|
||||
related_task_id=_task_id,
|
||||
related_project_id=_project_id,
|
||||
sender_name=_agency_name,
|
||||
)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"创建达人通知消息失败: {e}")
|
||||
|
||||
# 通知品牌方:代理商给项目添加了达人
|
||||
try:
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == _project_brand_id)
|
||||
)
|
||||
brand = brand_result.scalar_one_or_none()
|
||||
if brand and brand.user_id:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand.user_id,
|
||||
type="new_task",
|
||||
title="达人加入项目",
|
||||
content=f"代理商「{_agency_name}」将达人「{_creator_name}」加入项目「{_project_name}」,任务:{_task_name}",
|
||||
related_task_id=_task_id,
|
||||
related_project_id=_project_id,
|
||||
sender_name=_agency_name,
|
||||
)
|
||||
await db.commit()
|
||||
else:
|
||||
logger.warning(f"品牌方不存在或无 user_id: brand_id={_project_brand_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"创建品牌方通知消息失败: {e}")
|
||||
|
||||
try:
|
||||
await notify_new_task(
|
||||
task_id=task.id,
|
||||
creator_user_id=creator.user_id,
|
||||
task_name=task.name,
|
||||
project_name=task.project.name,
|
||||
task_id=_task_id,
|
||||
creator_user_id=_creator_user_id,
|
||||
task_name=_task_name,
|
||||
project_name=_project_name,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning(f"SSE 通知失败: {e}")
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
@@ -211,6 +588,7 @@ async def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
stage: Optional[TaskStage] = Query(None),
|
||||
project_id: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
@@ -243,7 +621,7 @@ async def list_tasks(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="代理商信息不存在",
|
||||
)
|
||||
tasks, total = await list_tasks_for_agency(db, agency.id, page, page_size, stage)
|
||||
tasks, total = await list_tasks_for_agency(db, agency.id, page, page_size, stage, project_id)
|
||||
|
||||
elif current_user.role == UserRole.BRAND:
|
||||
result = await db.execute(
|
||||
@@ -255,7 +633,7 @@ async def list_tasks(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="品牌方信息不存在",
|
||||
)
|
||||
tasks, total = await list_tasks_for_brand(db, brand.id, page, page_size, stage)
|
||||
tasks, total = await list_tasks_for_brand(db, brand.id, page, page_size, stage, project_id)
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
@@ -395,13 +773,23 @@ async def upload_task_script(
|
||||
# 重新加载关联
|
||||
task = await get_task_by_id(db, task.id)
|
||||
|
||||
# SSE 通知代理商脚本已上传
|
||||
# 通知代理商脚本已上传(消息 + SSE)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=agency_obj.user_id,
|
||||
type="task",
|
||||
title="达人已上传脚本",
|
||||
content=f"任务「{task.name}」的脚本已上传,等待 AI 审核。",
|
||||
related_task_id=task.id,
|
||||
sender_name=creator.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[agency_obj.user_id],
|
||||
@@ -410,6 +798,18 @@ async def upload_task_script(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取 tenant_id (品牌方 ID) 并在后台触发 AI 审核
|
||||
try:
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == task.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if project:
|
||||
asyncio.create_task(_run_script_ai_review(task.id, project.brand_id))
|
||||
logger.info(f"已触发任务 {task.id} 的后台 AI 审核")
|
||||
except Exception as e:
|
||||
logger.error(f"触发 AI 审核失败: {e}")
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@@ -458,13 +858,23 @@ async def upload_task_video(
|
||||
# 重新加载关联
|
||||
task = await get_task_by_id(db, task.id)
|
||||
|
||||
# SSE 通知代理商视频已上传
|
||||
# 通知代理商视频已上传(消息 + SSE)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=agency_obj.user_id,
|
||||
type="task",
|
||||
title="达人已上传视频",
|
||||
content=f"任务「{task.name}」的视频已上传,等待 AI 审核。",
|
||||
related_task_id=task.id,
|
||||
sender_name=creator.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[agency_obj.user_id],
|
||||
@@ -473,6 +883,18 @@ async def upload_task_video(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 获取 tenant_id 并在后台触发视频 AI 审核
|
||||
try:
|
||||
project_result = await db.execute(
|
||||
select(Project).where(Project.id == task.project_id)
|
||||
)
|
||||
project = project_result.scalar_one_or_none()
|
||||
if project:
|
||||
asyncio.create_task(_run_video_ai_review(task.id, project.brand_id))
|
||||
logger.info(f"已触发任务 {task.id} 的后台视频 AI 审核")
|
||||
except Exception as e:
|
||||
logger.error(f"触发视频 AI 审核失败: {e}")
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@@ -616,6 +1038,59 @@ async def review_script(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 代理商通过 → 通知品牌方有新内容待审核
|
||||
try:
|
||||
if current_user.role == UserRole.AGENCY and request.action in ("pass", "force_pass"):
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == task.project.brand_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="新脚本待审核",
|
||||
content=f"任务「{task.name}」脚本已通过代理商审核,请进行品牌终审。",
|
||||
related_task_id=task.id,
|
||||
sender_name=current_user.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[brand_obj.user_id],
|
||||
data={"action": "script_pending_brand_review", "stage": task.stage.value},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 品牌方审核 → 通知代理商结果
|
||||
try:
|
||||
if current_user.role == UserRole.BRAND:
|
||||
ag_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
ag_obj = ag_result.scalar_one_or_none()
|
||||
if ag_obj:
|
||||
action_text = {"pass": "通过", "reject": "驳回"}.get(request.action, request.action)
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=ag_obj.user_id,
|
||||
type="task",
|
||||
title=f"脚本品牌终审{action_text}",
|
||||
content=f"任务「{task.name}」脚本品牌终审已{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
|
||||
related_task_id=task.id,
|
||||
sender_name=current_user.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[ag_obj.user_id],
|
||||
data={"action": f"script_brand_{request.action}", "stage": task.stage.value},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@@ -756,6 +1231,59 @@ async def review_video(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 代理商通过 → 通知品牌方有视频待审核
|
||||
try:
|
||||
if current_user.role == UserRole.AGENCY and request.action in ("pass", "force_pass"):
|
||||
brand_result = await db.execute(
|
||||
select(Brand).where(Brand.id == task.project.brand_id)
|
||||
)
|
||||
brand_obj = brand_result.scalar_one_or_none()
|
||||
if brand_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=brand_obj.user_id,
|
||||
type="task",
|
||||
title="新视频待审核",
|
||||
content=f"任务「{task.name}」视频已通过代理商审核,请进行品牌终审。",
|
||||
related_task_id=task.id,
|
||||
sender_name=current_user.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[brand_obj.user_id],
|
||||
data={"action": "video_pending_brand_review", "stage": task.stage.value},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 品牌方审核 → 通知代理商结果
|
||||
try:
|
||||
if current_user.role == UserRole.BRAND:
|
||||
ag_result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
ag_obj = ag_result.scalar_one_or_none()
|
||||
if ag_obj:
|
||||
action_text = {"pass": "通过", "reject": "驳回"}.get(request.action, request.action)
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=ag_obj.user_id,
|
||||
type="task",
|
||||
title=f"视频品牌终审{action_text}",
|
||||
content=f"任务「{task.name}」视频品牌终审已{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
|
||||
related_task_id=task.id,
|
||||
sender_name=current_user.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[ag_obj.user_id],
|
||||
data={"action": f"video_brand_{request.action}", "stage": task.stage.value},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return _task_to_response(task)
|
||||
|
||||
|
||||
@@ -804,13 +1332,23 @@ async def submit_task_appeal(
|
||||
# 重新加载关联
|
||||
task = await get_task_by_id(db, task.id)
|
||||
|
||||
# SSE 通知代理商有新申诉
|
||||
# 通知代理商有新申诉(消息 + SSE)
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(Agency).where(Agency.id == task.agency_id)
|
||||
)
|
||||
agency_obj = result.scalar_one_or_none()
|
||||
if agency_obj:
|
||||
await create_message(
|
||||
db=db,
|
||||
user_id=agency_obj.user_id,
|
||||
type="task",
|
||||
title="达人提交申诉",
|
||||
content=f"任务「{task.name}」的达人提交了申诉:{request.reason}",
|
||||
related_task_id=task.id,
|
||||
sender_name=creator.name,
|
||||
)
|
||||
await db.commit()
|
||||
await notify_task_updated(
|
||||
task_id=task.id,
|
||||
user_ids=[agency_obj.user_id],
|
||||
|
||||
+116
-2
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
文件上传 API
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form, status
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
@@ -135,7 +136,6 @@ class SignedUrlResponse(BaseModel):
|
||||
async def get_signed_url(
|
||||
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||
expire: int = Query(3600, ge=60, le=43200, description="有效期(秒),默认1小时,最长12小时"),
|
||||
download: bool = Query(False, description="是否强制下载(添加 Content-Disposition: attachment)"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
@@ -158,7 +158,7 @@ async def get_signed_url(
|
||||
)
|
||||
|
||||
try:
|
||||
signed_url = generate_presigned_url(file_key, expire_seconds=expire, download=download)
|
||||
signed_url = generate_presigned_url(file_key, expire_seconds=expire)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
@@ -171,6 +171,120 @@ async def get_signed_url(
|
||||
)
|
||||
|
||||
|
||||
def _get_tos_object(file_key: str) -> tuple[bytes, str]:
|
||||
"""
|
||||
从 TOS 获取文件内容和文件名(内部工具函数)
|
||||
|
||||
Returns:
|
||||
(content, filename)
|
||||
"""
|
||||
import tos as tos_sdk
|
||||
|
||||
region = settings.TOS_REGION
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
client = tos_sdk.TosClientV2(
|
||||
ak=settings.TOS_ACCESS_KEY_ID,
|
||||
sk=settings.TOS_SECRET_ACCESS_KEY,
|
||||
endpoint=f"https://{endpoint}",
|
||||
region=region,
|
||||
)
|
||||
resp = client.get_object(bucket=settings.TOS_BUCKET_NAME, key=file_key)
|
||||
content = resp.read()
|
||||
|
||||
# 从 file_key 提取文件名,去掉时间戳前缀
|
||||
filename = file_key.split("/")[-1]
|
||||
if "_" in filename and filename.split("_")[0].isdigit():
|
||||
filename = filename.split("_", 1)[1]
|
||||
|
||||
return content, filename
|
||||
|
||||
|
||||
def _resolve_file_key(url: str) -> str:
|
||||
"""从 URL 或 file_key 解析出实际 file_key"""
|
||||
from app.services.oss import parse_file_key_from_url
|
||||
|
||||
file_key = url
|
||||
if url.startswith("http"):
|
||||
file_key = parse_file_key_from_url(url)
|
||||
return file_key
|
||||
|
||||
|
||||
def _guess_content_type(filename: str) -> str:
|
||||
"""根据文件名猜测 MIME 类型"""
|
||||
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
||||
mime_map = {
|
||||
"pdf": "application/pdf",
|
||||
"jpg": "image/jpeg",
|
||||
"jpeg": "image/jpeg",
|
||||
"png": "image/png",
|
||||
"gif": "image/gif",
|
||||
"webp": "image/webp",
|
||||
"mp4": "video/mp4",
|
||||
"mov": "video/quicktime",
|
||||
"webm": "video/webm",
|
||||
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"txt": "text/plain",
|
||||
}
|
||||
return mime_map.get(ext, "application/octet-stream")
|
||||
|
||||
|
||||
@router.get("/download")
|
||||
async def download_file(
|
||||
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
代理下载文件 — 后端获取 TOS 文件后返回给前端,
|
||||
设置 Content-Disposition: attachment 确保浏览器触发下载。
|
||||
"""
|
||||
file_key = _resolve_file_key(url)
|
||||
if not file_key:
|
||||
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||
|
||||
try:
|
||||
content, filename = _get_tos_object(file_key)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"下载文件失败: {e}")
|
||||
|
||||
from fastapi.responses import Response
|
||||
encoded_filename = quote(filename)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/preview")
|
||||
async def preview_file(
|
||||
url: str = Query(..., description="文件的原始 URL 或 file_key"),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
代理预览文件 — 后端获取 TOS 文件后返回给前端,
|
||||
设置正确的 Content-Type 让浏览器可以直接渲染(PDF / 图片等)。
|
||||
"""
|
||||
file_key = _resolve_file_key(url)
|
||||
if not file_key:
|
||||
raise HTTPException(status_code=400, detail="无效的文件路径")
|
||||
|
||||
try:
|
||||
content, filename = _get_tos_object(file_key)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"获取文件失败: {e}")
|
||||
|
||||
from fastapi.responses import Response
|
||||
content_type = _guess_content_type(filename)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type=content_type,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/proxy", response_model=FileUploadedResponse)
|
||||
async def proxy_upload(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
@@ -30,9 +30,12 @@ class Brief(Base, TimestampMixin):
|
||||
file_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# 解析后的结构化内容
|
||||
# 卖点要求: [{"content": "SPF50+", "required": true}, ...]
|
||||
# 卖点要求: [{"content": "SPF50+", "priority": "core"}, ...]
|
||||
selling_points: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
# 代理商要求至少体现的卖点条数(0 或 None 表示不限制)
|
||||
min_selling_points: Mapped[Optional[int]] = mapped_column(nullable=True)
|
||||
|
||||
# 违禁词: [{"word": "最好", "reason": "绝对化用语"}, ...]
|
||||
blacklist_words: Mapped[Optional[list]] = mapped_column(JSONType, nullable=True)
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
Brief 相关 Schema
|
||||
|
||||
卖点格式 (selling_points: List[dict]):
|
||||
新格式: {"content": "卖点内容", "priority": "core|recommended|reference"}
|
||||
旧格式: {"content": "卖点内容", "required": true|false}
|
||||
兼容规则: required=true → priority="core", required=false → priority="recommended"
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
@@ -39,8 +44,13 @@ class BriefUpdateRequest(BaseModel):
|
||||
|
||||
|
||||
class AgencyBriefUpdateRequest(BaseModel):
|
||||
"""代理商更新 Brief 请求(仅允许更新 agency_attachments)"""
|
||||
"""代理商更新 Brief 请求(允许更新代理商附件 + 卖点 + 违禁词 + AI解析内容)"""
|
||||
agency_attachments: Optional[List[dict]] = None
|
||||
selling_points: Optional[List[dict]] = None
|
||||
min_selling_points: Optional[int] = None
|
||||
blacklist_words: Optional[List[dict]] = None
|
||||
brand_tone: Optional[str] = None
|
||||
other_requirements: Optional[str] = None
|
||||
|
||||
|
||||
# ===== 响应 =====
|
||||
@@ -53,6 +63,7 @@ class BriefResponse(BaseModel):
|
||||
file_url: Optional[str] = None
|
||||
file_name: Optional[str] = None
|
||||
selling_points: Optional[List[dict]] = None
|
||||
min_selling_points: Optional[int] = None
|
||||
blacklist_words: Optional[List[dict]] = None
|
||||
competitors: Optional[List[str]] = None
|
||||
brand_tone: Optional[str] = None
|
||||
|
||||
@@ -91,6 +91,7 @@ class Violation(BaseModel):
|
||||
content: str = Field(..., description="违规内容")
|
||||
severity: RiskLevel = Field(..., description="严重程度")
|
||||
suggestion: str = Field(..., description="修改建议")
|
||||
dimension: Optional[str] = Field(None, description="所属维度: legal/platform/brand_safety/brief_match")
|
||||
|
||||
# 文本审核字段
|
||||
position: Optional[Position] = Field(None, description="文本位置(脚本审核)")
|
||||
@@ -101,6 +102,45 @@ class Violation(BaseModel):
|
||||
source: Optional[ViolationSource] = Field(None, description="违规来源(视频审核)")
|
||||
|
||||
|
||||
# ==================== 多维度审核 ====================
|
||||
|
||||
class ReviewDimension(BaseModel):
|
||||
"""审核维度评分"""
|
||||
score: int = Field(..., ge=0, le=100)
|
||||
passed: bool
|
||||
issue_count: int = 0
|
||||
|
||||
|
||||
class ReviewDimensions(BaseModel):
|
||||
"""四维度审核结果"""
|
||||
legal: ReviewDimension # 法规合规(违禁词、功效词、Brief黑名单词)
|
||||
platform: ReviewDimension # 平台规则
|
||||
brand_safety: ReviewDimension # 品牌安全(竞品、其他品牌词)
|
||||
brief_match: ReviewDimension # Brief 匹配度(卖点覆盖)
|
||||
|
||||
|
||||
class SellingPointMatch(BaseModel):
|
||||
"""卖点匹配结果"""
|
||||
content: str
|
||||
priority: str # "core" | "recommended" | "reference"
|
||||
matched: bool
|
||||
evidence: Optional[str] = None # AI 给出的匹配依据
|
||||
|
||||
|
||||
class BriefMatchDetail(BaseModel):
|
||||
"""Brief 匹配度评分详情"""
|
||||
# 卖点覆盖
|
||||
total_points: int = Field(0, description="需要检查的卖点总数(core + recommended)")
|
||||
matched_points: int = Field(0, description="实际匹配的卖点数")
|
||||
required_points: int = Field(0, description="代理商要求至少体现的卖点条数(min_selling_points)")
|
||||
coverage_score: int = Field(0, ge=0, le=100, description="卖点覆盖率得分")
|
||||
# AI 整体匹配分析
|
||||
overall_score: int = Field(0, ge=0, le=100, description="整体 Brief 匹配度得分")
|
||||
highlights: list[str] = Field(default_factory=list, description="内容亮点(AI 分析)")
|
||||
issues: list[str] = Field(default_factory=list, description="问题点(AI 分析)")
|
||||
explanation: str = Field("", description="评分说明(一句话总结)")
|
||||
|
||||
|
||||
# ==================== 脚本预审 ====================
|
||||
|
||||
class ScriptReviewRequest(BaseModel):
|
||||
@@ -108,9 +148,12 @@ 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="必要卖点列表")
|
||||
selling_points: Optional[list[dict]] = Field(None, description="卖点列表 [{content, priority}]")
|
||||
min_selling_points: Optional[int] = Field(None, ge=0, description="代理商要求至少体现的卖点条数")
|
||||
blacklist_words: Optional[list[dict]] = Field(None, description="Brief 黑名单词 [{word, reason}]")
|
||||
soft_risk_context: Optional[SoftRiskContext] = Field(None, description="软性风控上下文")
|
||||
file_url: Optional[str] = Field(None, description="脚本文件 URL(用于自动解析文本和提取图片)")
|
||||
file_name: Optional[str] = Field(None, description="原始文件名(用于判断格式)")
|
||||
|
||||
|
||||
class ScriptReviewResponse(BaseModel):
|
||||
@@ -118,16 +161,22 @@ class ScriptReviewResponse(BaseModel):
|
||||
脚本预审响应
|
||||
|
||||
结构:
|
||||
- score: 合规分数 0-100
|
||||
- score: 加权总分(向后兼容)
|
||||
- summary: 整体摘要
|
||||
- violations: 违规项列表,每项包含 suggestion
|
||||
- missing_points: 遗漏的卖点(可选)
|
||||
- dimensions: 四维度评分(法规/平台/品牌安全/Brief匹配)
|
||||
- selling_point_matches: 卖点匹配详情
|
||||
- violations: 违规项列表,每项带 dimension 标签
|
||||
- missing_points: 遗漏的核心卖点(向后兼容)
|
||||
"""
|
||||
score: int = Field(..., ge=0, le=100, description="合规分数")
|
||||
score: int = Field(..., ge=0, le=100, description="加权总分")
|
||||
summary: str = Field(..., description="审核摘要")
|
||||
dimensions: ReviewDimensions = Field(..., description="四维度评分")
|
||||
selling_point_matches: list[SellingPointMatch] = Field(default_factory=list, description="卖点匹配详情")
|
||||
brief_match_detail: Optional[BriefMatchDetail] = Field(None, description="Brief 匹配度评分详情")
|
||||
violations: list[Violation] = Field(default_factory=list, description="违规项列表")
|
||||
missing_points: Optional[list[str]] = Field(None, description="遗漏的卖点")
|
||||
missing_points: Optional[list[str]] = Field(None, description="遗漏的核心卖点")
|
||||
soft_warnings: list[SoftRiskWarning] = Field(default_factory=list, description="软性风控提示")
|
||||
ai_available: bool = Field(True, description="AI 服务是否可用(False 表示降级为纯关键词检测)")
|
||||
|
||||
|
||||
# ==================== 视频审核 ====================
|
||||
|
||||
@@ -51,6 +51,9 @@ class OpenAICompatibleClient:
|
||||
timeout: float = 180.0,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
# 自动补全 /v1 后缀(OpenAI SDK 需要完整路径)
|
||||
if not self.base_url.endswith("/v1"):
|
||||
self.base_url = self.base_url + "/v1"
|
||||
self.api_key = api_key
|
||||
self.provider = provider
|
||||
self.timeout = timeout
|
||||
|
||||
@@ -53,18 +53,24 @@ class AIServiceFactory:
|
||||
)
|
||||
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,
|
||||
)
|
||||
if config:
|
||||
# 解密 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,
|
||||
)
|
||||
else:
|
||||
# 回退到全局 .env 配置
|
||||
from app.config import settings
|
||||
if not settings.AI_API_KEY or not settings.AI_API_BASE_URL:
|
||||
return None
|
||||
client = OpenAICompatibleClient(
|
||||
base_url=settings.AI_API_BASE_URL,
|
||||
api_key=settings.AI_API_KEY,
|
||||
provider=settings.AI_PROVIDER,
|
||||
)
|
||||
|
||||
# 缓存客户端
|
||||
cls._cache[cache_key] = client
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
文档解析服务
|
||||
从 PDF/Word/Excel 文档中提取纯文本
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DocumentParser:
|
||||
"""从文档中提取纯文本"""
|
||||
@@ -38,23 +42,40 @@ class DocumentParser:
|
||||
# 回退:生成预签名 URL 后用 HTTP 下载
|
||||
content = await DocumentParser._download_via_signed_url(document_url)
|
||||
|
||||
# 跳过过大的文件(>20MB),解析可能非常慢且阻塞
|
||||
if len(content) > 20 * 1024 * 1024:
|
||||
logger.warning(f"文件 {document_name} 过大 ({len(content)//1024//1024}MB),已跳过")
|
||||
return ""
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
return DocumentParser.parse_file(tmp_path, document_name)
|
||||
# 文件解析可能很慢(CPU 密集),放到线程池执行
|
||||
return await asyncio.to_thread(DocumentParser.parse_file, tmp_path, document_name)
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
|
||||
# 图片提取限制
|
||||
MAX_IMAGES = 10
|
||||
MAX_IMAGE_SIZE = 2 * 1024 * 1024 # 2MB per image base64
|
||||
|
||||
@staticmethod
|
||||
async def download_and_get_images(document_url: str, document_name: str) -> Optional[list[str]]:
|
||||
"""
|
||||
下载 PDF 并将页面转为 base64 图片列表(用于图片型 PDF 的 AI 视觉解析)。
|
||||
非 PDF 或非图片型 PDF 返回 None。
|
||||
下载文档并提取嵌入的图片,返回 base64 编码列表。
|
||||
|
||||
支持格式:
|
||||
- PDF: 图片型 PDF 转页面图片
|
||||
- DOCX: 提取 word/media/ 中的嵌入图片
|
||||
- XLSX: 提取 worksheet 中的嵌入图片
|
||||
|
||||
Returns:
|
||||
base64 图片列表,无图片时返回 None
|
||||
"""
|
||||
ext = document_name.rsplit(".", 1)[-1].lower() if "." in document_name else ""
|
||||
if ext != "pdf":
|
||||
if ext not in ("pdf", "doc", "docx", "xls", "xlsx"):
|
||||
return None
|
||||
|
||||
tmp_path: Optional[str] = None
|
||||
@@ -63,12 +84,20 @@ class DocumentParser:
|
||||
if file_content is None:
|
||||
file_content = await DocumentParser._download_via_signed_url(document_url)
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=f".{ext}") as tmp:
|
||||
tmp.write(file_content)
|
||||
tmp_path = tmp.name
|
||||
|
||||
if DocumentParser.is_image_pdf(tmp_path):
|
||||
return DocumentParser.pdf_to_images_base64(tmp_path)
|
||||
if ext == "pdf":
|
||||
if DocumentParser.is_image_pdf(tmp_path):
|
||||
return DocumentParser.pdf_to_images_base64(tmp_path)
|
||||
return None
|
||||
elif ext in ("doc", "docx"):
|
||||
images = await asyncio.to_thread(DocumentParser._extract_docx_images, tmp_path)
|
||||
return images if images else None
|
||||
elif ext in ("xls", "xlsx"):
|
||||
images = await asyncio.to_thread(DocumentParser._extract_xlsx_images, tmp_path)
|
||||
return images if images else None
|
||||
return None
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
@@ -76,32 +105,40 @@ class DocumentParser:
|
||||
|
||||
@staticmethod
|
||||
async def _download_via_tos_sdk(document_url: str) -> Optional[bytes]:
|
||||
"""通过 TOS SDK 直接下载文件(私有桶安全访问)"""
|
||||
try:
|
||||
from app.config import settings
|
||||
from app.services.oss import parse_file_key_from_url
|
||||
import tos as tos_sdk
|
||||
"""通过 TOS SDK 直接下载文件(私有桶安全访问),在线程池中执行避免阻塞"""
|
||||
def _sync_download() -> Optional[bytes]:
|
||||
try:
|
||||
from app.config import settings
|
||||
from app.services.oss import parse_file_key_from_url
|
||||
import tos as tos_sdk
|
||||
|
||||
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||
if not settings.TOS_ACCESS_KEY_ID or not settings.TOS_SECRET_ACCESS_KEY:
|
||||
logger.debug("TOS SDK: AK/SK 未配置,跳过")
|
||||
return None
|
||||
|
||||
file_key = parse_file_key_from_url(document_url)
|
||||
if not file_key or file_key == document_url:
|
||||
logger.debug(f"TOS SDK: 无法从 URL 解析 file_key: {document_url}")
|
||||
return None
|
||||
|
||||
region = settings.TOS_REGION
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
|
||||
client = tos_sdk.TosClientV2(
|
||||
ak=settings.TOS_ACCESS_KEY_ID,
|
||||
sk=settings.TOS_SECRET_ACCESS_KEY,
|
||||
endpoint=f"https://{endpoint}",
|
||||
region=region,
|
||||
)
|
||||
resp = client.get_object(bucket=settings.TOS_BUCKET_NAME, key=file_key)
|
||||
data = resp.read()
|
||||
logger.info(f"TOS SDK: 下载成功, key={file_key}, size={len(data)}")
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"TOS SDK 下载失败,将回退 HTTP: {e}")
|
||||
return None
|
||||
|
||||
file_key = parse_file_key_from_url(document_url)
|
||||
if not file_key or file_key == document_url:
|
||||
return None
|
||||
|
||||
region = settings.TOS_REGION
|
||||
endpoint = settings.TOS_ENDPOINT or f"tos-cn-{region}.volces.com"
|
||||
|
||||
client = tos_sdk.TosClientV2(
|
||||
ak=settings.TOS_ACCESS_KEY_ID,
|
||||
sk=settings.TOS_SECRET_ACCESS_KEY,
|
||||
endpoint=f"https://{endpoint}",
|
||||
region=region,
|
||||
)
|
||||
resp = client.get_object(bucket=settings.TOS_BUCKET_NAME, key=file_key)
|
||||
return resp.read()
|
||||
except Exception:
|
||||
return None
|
||||
return await asyncio.to_thread(_sync_download)
|
||||
|
||||
@staticmethod
|
||||
async def _download_via_signed_url(document_url: str) -> bytes:
|
||||
@@ -110,10 +147,12 @@ class DocumentParser:
|
||||
|
||||
file_key = parse_file_key_from_url(document_url)
|
||||
signed_url = generate_presigned_url(file_key, expire_seconds=300)
|
||||
logger.info(f"HTTP 签名 URL 下载: key={file_key}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.get(signed_url)
|
||||
resp.raise_for_status()
|
||||
logger.info(f"HTTP 下载成功: {len(resp.content)} bytes")
|
||||
return resp.content
|
||||
|
||||
@staticmethod
|
||||
@@ -249,3 +288,62 @@ class DocumentParser:
|
||||
"""纯文本文件"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
@staticmethod
|
||||
def _extract_docx_images(path: str) -> list[str]:
|
||||
"""从 DOCX 文件中提取嵌入图片(DOCX 本质是 ZIP,图片在 word/media/ 目录)"""
|
||||
import zipfile
|
||||
import base64
|
||||
|
||||
images = []
|
||||
image_exts = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(path, "r") as zf:
|
||||
for name in zf.namelist():
|
||||
if not name.startswith("word/media/"):
|
||||
continue
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext not in image_exts:
|
||||
continue
|
||||
img_data = zf.read(name)
|
||||
b64 = base64.b64encode(img_data).decode()
|
||||
if len(b64) > DocumentParser.MAX_IMAGE_SIZE:
|
||||
logger.debug(f"跳过过大图片: {name} ({len(b64)} bytes)")
|
||||
continue
|
||||
images.append(b64)
|
||||
if len(images) >= DocumentParser.MAX_IMAGES:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"提取 DOCX 图片失败: {e}")
|
||||
|
||||
return images
|
||||
|
||||
@staticmethod
|
||||
def _extract_xlsx_images(path: str) -> list[str]:
|
||||
"""从 XLSX 文件中提取嵌入图片(通过 openpyxl 的 _images 属性)"""
|
||||
import base64
|
||||
|
||||
images = []
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
wb = load_workbook(path, read_only=False)
|
||||
for sheet in wb.worksheets:
|
||||
for img in getattr(sheet, "_images", []):
|
||||
try:
|
||||
img_data = img._data()
|
||||
b64 = base64.b64encode(img_data).decode()
|
||||
if len(b64) > DocumentParser.MAX_IMAGE_SIZE:
|
||||
continue
|
||||
images.append(b64)
|
||||
if len(images) >= DocumentParser.MAX_IMAGES:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if len(images) >= DocumentParser.MAX_IMAGES:
|
||||
break
|
||||
wb.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"提取 XLSX 图片失败: {e}")
|
||||
|
||||
return images
|
||||
|
||||
@@ -142,8 +142,6 @@ def get_file_url(file_key: str) -> str:
|
||||
def generate_presigned_url(
|
||||
file_key: str,
|
||||
expire_seconds: int = 3600,
|
||||
download: bool = False,
|
||||
filename: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
为私有桶中的文件生成预签名访问 URL (TOS V4 Query String Auth)
|
||||
@@ -179,21 +177,14 @@ def generate_presigned_url(
|
||||
# 对 file_key 中的路径段分别编码
|
||||
encoded_key = "/".join(quote(seg, safe="") for seg in file_key.split("/"))
|
||||
|
||||
# 查询参数(按字母序排列,TOS V4 签名要求严格字母序)
|
||||
params_dict: dict[str, str] = {
|
||||
"X-Tos-Algorithm": "TOS4-HMAC-SHA256",
|
||||
"X-Tos-Credential": quote(credential, safe=''),
|
||||
"X-Tos-Date": tos_date,
|
||||
"X-Tos-Expires": str(expire_seconds),
|
||||
"X-Tos-SignedHeaders": "host",
|
||||
}
|
||||
if download:
|
||||
dl_name = filename or file_key.split("/")[-1]
|
||||
params_dict["response-content-disposition"] = quote(
|
||||
f'attachment; filename="{dl_name}"', safe=''
|
||||
)
|
||||
|
||||
query_params = "&".join(f"{k}={v}" for k, v in sorted(params_dict.items()))
|
||||
# 查询参数(按字母序排列)
|
||||
query_params = (
|
||||
f"X-Tos-Algorithm=TOS4-HMAC-SHA256"
|
||||
f"&X-Tos-Credential={quote(credential, safe='')}"
|
||||
f"&X-Tos-Date={tos_date}"
|
||||
f"&X-Tos-Expires={expire_seconds}"
|
||||
f"&X-Tos-SignedHeaders=host"
|
||||
)
|
||||
|
||||
# CanonicalRequest
|
||||
canonical_request = (
|
||||
|
||||
@@ -459,6 +459,7 @@ async def list_tasks_for_agency(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
stage: Optional[TaskStage] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> Tuple[List[Task], int]:
|
||||
"""获取代理商的任务列表"""
|
||||
query = (
|
||||
@@ -473,6 +474,8 @@ async def list_tasks_for_agency(
|
||||
|
||||
if stage:
|
||||
query = query.where(Task.stage == stage)
|
||||
if project_id:
|
||||
query = query.where(Task.project_id == project_id)
|
||||
|
||||
query = query.order_by(Task.created_at.desc())
|
||||
|
||||
@@ -480,6 +483,8 @@ async def list_tasks_for_agency(
|
||||
count_query = select(func.count(Task.id)).where(Task.agency_id == agency_id)
|
||||
if stage:
|
||||
count_query = count_query.where(Task.stage == stage)
|
||||
if project_id:
|
||||
count_query = count_query.where(Task.project_id == project_id)
|
||||
count_result = await db.execute(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
@@ -497,12 +502,17 @@ async def list_tasks_for_brand(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
stage: Optional[TaskStage] = None,
|
||||
project_id: Optional[str] = None,
|
||||
) -> Tuple[List[Task], int]:
|
||||
"""获取品牌方的任务列表(通过项目关联)"""
|
||||
# 先获取品牌方的所有项目
|
||||
project_ids_query = select(Project.id).where(Project.brand_id == brand_id)
|
||||
project_ids_result = await db.execute(project_ids_query)
|
||||
project_ids = [row[0] for row in project_ids_result.all()]
|
||||
if project_id:
|
||||
# 指定了项目 ID,直接筛选该项目的任务
|
||||
project_ids = [project_id]
|
||||
else:
|
||||
# 未指定项目,获取品牌方的所有项目
|
||||
project_ids_query = select(Project.id).where(Project.brand_id == brand_id)
|
||||
project_ids_result = await db.execute(project_ids_query)
|
||||
project_ids = [row[0] for row in project_ids_result.all()]
|
||||
|
||||
if not project_ids:
|
||||
return [], 0
|
||||
|
||||
@@ -24,6 +24,9 @@ dependencies = [
|
||||
"pdfplumber>=0.10.0",
|
||||
"python-docx>=1.1.0",
|
||||
"openpyxl>=3.1.0",
|
||||
"PyMuPDF>=1.24.0",
|
||||
"tos>=2.7.0",
|
||||
"socksio>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+90
-8
@@ -189,9 +189,10 @@ async def seed_data() -> None:
|
||||
id=BRIEF_ID,
|
||||
project_id=PROJECT_ID,
|
||||
selling_points=[
|
||||
{"content": "SPF50+ PA++++,超强防晒", "required": True},
|
||||
{"content": "轻薄不油腻,适合日常通勤", "required": True},
|
||||
{"content": "添加玻尿酸成分,防晒同时保湿", "required": False},
|
||||
{"content": "SPF50+ PA++++,超强防晒", "priority": "core"},
|
||||
{"content": "轻薄不油腻,适合日常通勤", "priority": "core"},
|
||||
{"content": "添加玻尿酸成分,防晒同时保湿", "priority": "recommended"},
|
||||
{"content": "获得皮肤科医生推荐", "priority": "reference"},
|
||||
],
|
||||
blacklist_words=[
|
||||
{"word": "最好", "reason": "绝对化用语"},
|
||||
@@ -200,6 +201,7 @@ async def seed_data() -> None:
|
||||
],
|
||||
competitors=["安耐晒", "怡思丁", "薇诺娜"],
|
||||
brand_tone="年轻、活力、专业、可信赖",
|
||||
min_selling_points=2,
|
||||
min_duration=30,
|
||||
max_duration=60,
|
||||
other_requirements="请在视频中展示产品实际使用效果,包含户外场景拍摄",
|
||||
@@ -236,8 +238,38 @@ async def seed_data() -> None:
|
||||
script_ai_result={
|
||||
"score": 85,
|
||||
"summary": "脚本整体符合要求,卖点覆盖充分",
|
||||
"issues": [
|
||||
{"type": "soft_warning", "content": "建议增加产品成分说明"},
|
||||
"dimensions": {
|
||||
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"platform": {"score": 85, "passed": True, "issue_count": 1},
|
||||
"brand_safety": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brief_match": {"score": 80, "passed": True, "issue_count": 1},
|
||||
},
|
||||
"selling_point_matches": [
|
||||
{"content": "SPF50+ PA++++,超强防晒", "priority": "core", "matched": True, "evidence": "脚本中提到了SPF50+防晒参数"},
|
||||
{"content": "轻薄不油腻,适合日常通勤", "priority": "core", "matched": True, "evidence": "提到了轻薄质地不油腻"},
|
||||
{"content": "添加玻尿酸成分,防晒同时保湿", "priority": "recommended", "matched": False, "evidence": "未提及玻尿酸成分"},
|
||||
],
|
||||
"brief_match_detail": {
|
||||
"total_points": 3,
|
||||
"matched_points": 2,
|
||||
"required_points": 2,
|
||||
"coverage_score": 100,
|
||||
"overall_score": 75,
|
||||
"highlights": [
|
||||
"防晒参数描述准确,SPF50+ PA++++完整提及",
|
||||
"产品使用场景贴合Brief要求的日常通勤场景",
|
||||
],
|
||||
"issues": [
|
||||
"缺少玻尿酸保湿成分的说明,建议补充产品成分亮点",
|
||||
"脚本中使用了\"神器\"等夸张用语,需替换为更客观的表述",
|
||||
],
|
||||
"explanation": "脚本覆盖了2/2条要求卖点,核心卖点全部匹配。整体内容方向正确,但部分细节可优化。",
|
||||
},
|
||||
"violations": [
|
||||
{"type": "forbidden_word", "content": "神器", "severity": "medium", "suggestion": "建议替换为\"好物\"", "dimension": "platform"},
|
||||
],
|
||||
"soft_warnings": [
|
||||
{"type": "suggestion", "content": "建议增加产品成分说明", "suggestion": "可提及玻尿酸等核心成分"},
|
||||
],
|
||||
},
|
||||
script_ai_reviewed_at=NOW - timedelta(hours=1),
|
||||
@@ -258,7 +290,33 @@ async def seed_data() -> None:
|
||||
script_ai_result={
|
||||
"score": 92,
|
||||
"summary": "脚本质量优秀,完全符合 Brief 要求",
|
||||
"issues": [],
|
||||
"dimensions": {
|
||||
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"platform": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brand_safety": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brief_match": {"score": 90, "passed": True, "issue_count": 0},
|
||||
},
|
||||
"selling_point_matches": [
|
||||
{"content": "SPF50+ PA++++,超强防晒", "priority": "core", "matched": True, "evidence": "脚本完整提及防晒参数"},
|
||||
{"content": "轻薄不油腻,适合日常通勤", "priority": "core", "matched": True, "evidence": "详细描述了质地体验"},
|
||||
{"content": "添加玻尿酸成分,防晒同时保湿", "priority": "recommended", "matched": True, "evidence": "提及了玻尿酸保湿功能"},
|
||||
],
|
||||
"brief_match_detail": {
|
||||
"total_points": 3,
|
||||
"matched_points": 3,
|
||||
"required_points": 2,
|
||||
"coverage_score": 100,
|
||||
"overall_score": 90,
|
||||
"highlights": [
|
||||
"所有核心和推荐卖点均完整覆盖",
|
||||
"产品使用场景自然,与Brief要求高度一致",
|
||||
"成分说明准确,玻尿酸保湿功能表述清晰",
|
||||
],
|
||||
"issues": [],
|
||||
"explanation": "脚本覆盖了3/2条要求卖点(超出要求),与Brief整体匹配度优秀。",
|
||||
},
|
||||
"violations": [],
|
||||
"soft_warnings": [],
|
||||
},
|
||||
script_ai_reviewed_at=NOW - timedelta(days=2),
|
||||
script_agency_status=TaskStatus.PASSED,
|
||||
@@ -283,7 +341,19 @@ async def seed_data() -> None:
|
||||
script_file_name="防晒霜种草脚本v4.pdf",
|
||||
script_uploaded_at=NOW - timedelta(days=7),
|
||||
script_ai_score=90,
|
||||
script_ai_result={"score": 90, "summary": "符合要求", "issues": []},
|
||||
script_ai_result={
|
||||
"score": 90,
|
||||
"summary": "符合要求",
|
||||
"dimensions": {
|
||||
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"platform": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brand_safety": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brief_match": {"score": 85, "passed": True, "issue_count": 0},
|
||||
},
|
||||
"selling_point_matches": [],
|
||||
"violations": [],
|
||||
"soft_warnings": [],
|
||||
},
|
||||
script_ai_reviewed_at=NOW - timedelta(days=7),
|
||||
script_agency_status=TaskStatus.PASSED,
|
||||
script_agency_comment="通过",
|
||||
@@ -298,7 +368,19 @@ async def seed_data() -> None:
|
||||
video_duration=45,
|
||||
video_uploaded_at=NOW - timedelta(days=5),
|
||||
video_ai_score=88,
|
||||
video_ai_result={"score": 88, "summary": "视频质量良好", "issues": []},
|
||||
video_ai_result={
|
||||
"score": 88,
|
||||
"summary": "视频质量良好",
|
||||
"dimensions": {
|
||||
"legal": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"platform": {"score": 100, "passed": True, "issue_count": 0},
|
||||
"brand_safety": {"score": 85, "passed": True, "issue_count": 0},
|
||||
"brief_match": {"score": 80, "passed": True, "issue_count": 0},
|
||||
},
|
||||
"selling_point_matches": [],
|
||||
"violations": [],
|
||||
"soft_warnings": [],
|
||||
},
|
||||
video_ai_reviewed_at=NOW - timedelta(days=5),
|
||||
video_agency_status=TaskStatus.PASSED,
|
||||
video_agency_comment="视频效果好",
|
||||
|
||||
@@ -215,7 +215,11 @@ class TestSellingPointCheck:
|
||||
"content": "这个产品很好用",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"required_points": ["功效说明", "使用方法", "品牌名称"],
|
||||
"selling_points": [
|
||||
{"content": "功效说明", "priority": "core"},
|
||||
{"content": "使用方法", "priority": "core"},
|
||||
{"content": "品牌名称", "priority": "recommended"},
|
||||
],
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
@@ -223,6 +227,9 @@ class TestSellingPointCheck:
|
||||
|
||||
assert parsed.missing_points is not None
|
||||
assert isinstance(parsed.missing_points, list)
|
||||
# 验证多维度评分存在
|
||||
assert parsed.dimensions is not None
|
||||
assert parsed.dimensions.brief_match is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_points_covered(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||||
@@ -234,7 +241,11 @@ class TestSellingPointCheck:
|
||||
"content": "品牌A的护肤精华,每天早晚各用一次,可以让肌肤更水润",
|
||||
"platform": "douyin",
|
||||
"brand_id": brand_id,
|
||||
"required_points": ["品牌名称", "使用方法", "功效说明"],
|
||||
"selling_points": [
|
||||
{"content": "护肤精华", "priority": "core"},
|
||||
{"content": "早晚各用一次", "priority": "core"},
|
||||
{"content": "肌肤更水润", "priority": "recommended"},
|
||||
],
|
||||
}
|
||||
)
|
||||
data = response.json()
|
||||
|
||||
Reference in New Issue
Block a user