diff --git a/backend/alembic/versions/261778c01ef8_add_min_selling_points_to_briefs.py b/backend/alembic/versions/261778c01ef8_add_min_selling_points_to_briefs.py new file mode 100644 index 0000000..cac6963 --- /dev/null +++ b/backend/alembic/versions/261778c01ef8_add_min_selling_points_to_briefs.py @@ -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') diff --git a/backend/app/api/briefs.py b/backend/app/api/briefs.py index 42d6703..27db316 100644 --- a/backend/app/api/briefs.py +++ b/backend/app/api/briefs.py @@ -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) diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py index 306b44f..77fe2e2 100644 --- a/backend/app/api/projects.py +++ b/backend/app/api/projects.py @@ -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) diff --git a/backend/app/api/rules.py b/backend/app/api/rules.py index c1bea53..68a4651 100644 --- a/backend/app/api/rules.py +++ b/backend/app/api/rules.py @@ -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, diff --git a/backend/app/api/scripts.py b/backend/app/api/scripts.py index c70e562..6cb4030 100644 --- a/backend/app/api/scripts.py +++ b/backend/app/api/scripts.py @@ -16,16 +16,22 @@ from app.schemas.review import ( Position, SoftRiskWarning, SoftRiskAction, + ReviewDimension, + ReviewDimensions, + SellingPointMatch, + BriefMatchDetail, ) from app.api.rules import ( get_whitelist_for_brand, get_other_brands_whitelist_terms, get_forbidden_words_for_tenant, get_active_platform_rules, + get_competitors_for_brand, _platform_rules, ) from app.services.soft_risk import evaluate_soft_risk from app.services.ai_service import AIServiceFactory +from app.services.document_parser import DocumentParser router = APIRouter(prefix="/scripts", tags=["scripts"]) @@ -63,34 +69,176 @@ def _is_ad_context(content: str, word: str) -> bool: return any(kw in content for kw in AD_CONTEXT_KEYWORDS) -def _check_selling_point_coverage(content: str, required_points: list[str]) -> list[str]: +def _normalize_selling_points(raw_points: list[dict] | None) -> list[dict]: """ - 检查卖点覆盖情况 - - 使用语义匹配而非精确匹配 + 标准化卖点列表,兼容旧 required:bool 格式 + 返回 [{content, priority}] """ - missing = [] - - # 卖点关键词映射 - point_keywords = { - "品牌名称": ["品牌", "牌子", "品牌A", "品牌B"], - "使用方法": ["使用", "用法", "早晚", "每天", "一次", "涂抹", "喷洒"], - "功效说明": ["功效", "效果", "水润", "美白", "保湿", "滋润", "改善"], - } - - for point in required_points: - # 精确匹配 - if point in content: + if not raw_points: + return [] + result = [] + for sp in raw_points: + content = sp.get("content", "") + if not content: continue + # 兼容旧格式 + if "priority" in sp: + priority = sp["priority"] + elif "required" in sp: + priority = "core" if sp["required"] else "recommended" + else: + priority = "recommended" + result.append({"content": content, "priority": priority}) + return result - # 关键词匹配 - keywords = point_keywords.get(point, []) - if any(kw in content for kw in keywords): - continue - missing.append(point) +async def _ai_selling_point_analysis( + ai_client, content: str, selling_points: list[dict], model: str +) -> list[SellingPointMatch]: + """ + AI 语义匹配卖点覆盖 - return missing + 只检查 core 和 recommended,跳过 reference。 + AI 不可用时回退:简单文本包含检测。 + """ + # 过滤出需要检查的卖点 + points_to_check = [sp for sp in selling_points if sp["priority"] in ("core", "recommended")] + reference_points = [sp for sp in selling_points if sp["priority"] == "reference"] + + # reference 卖点直接标记为匹配 + results: list[SellingPointMatch] = [ + SellingPointMatch(content=sp["content"], priority="reference", matched=True, evidence="参考信息,不检查") + for sp in reference_points + ] + + if not points_to_check: + return results + + if not ai_client: + # 回退:简单文本包含 + for sp in points_to_check: + matched = sp["content"] in content + results.append(SellingPointMatch( + content=sp["content"], priority=sp["priority"], matched=matched, + evidence="文本匹配" if matched else "未检测到相关内容", + )) + return results + + try: + points_text = "\n".join(f"- [{sp['priority']}] {sp['content']}" for sp in points_to_check) + prompt = f"""作为广告合规审核专家,请判断以下脚本内容是否覆盖了每个卖点。 + +脚本内容: +{content} + +需要检查的卖点: +{points_text} + +请以 JSON 数组返回,每项包含: +- content: 卖点原文 +- matched: true/false(脚本中是否传达了该卖点的含义,语义匹配即可,不要求原文出现) +- evidence: 匹配依据(如果匹配,指出脚本中对应的表述;如果不匹配,说明原因) + +请只返回 JSON 数组,不要包含其他内容。""" + + response = await ai_client.chat_completion( + messages=[{"role": "user", "content": prompt}], + model=model, + temperature=0.2, + max_tokens=1000, + ) + + import json + 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) + + # 构建结果映射 + ai_map = {item.get("content", ""): item for item in ai_results} + for sp in points_to_check: + ai_item = ai_map.get(sp["content"], {}) + results.append(SellingPointMatch( + content=sp["content"], + priority=sp["priority"], + matched=ai_item.get("matched", False), + evidence=ai_item.get("evidence", ""), + )) + except Exception: + # AI 失败时回退 + for sp in points_to_check: + matched = sp["content"] in content + results.append(SellingPointMatch( + content=sp["content"], priority=sp["priority"], matched=matched, + evidence="文本匹配(AI不可用)" if matched else "未检测到(AI不可用)", + )) + + return results + + +async def _ai_brief_overall_analysis( + ai_client, content: str, selling_points: list[dict], model: str +) -> dict: + """ + AI 分析脚本与 Brief 的整体匹配度,输出亮点和问题点。 + 返回 {"overall_score": int, "highlights": [...], "issues": [...]} + AI 不可用时返回空结果。 + """ + if not ai_client: + return {} + + try: + sp_text = "\n".join(f"- [{sp['priority']}] {sp['content']}" for sp in selling_points) if selling_points else "(无卖点要求)" + prompt = f"""作为广告内容审核专家,请分析以下脚本与 Brief 要求的整体匹配程度。 + +脚本内容: +{content} + +Brief 卖点要求: +{sp_text} + +请从以下角度综合分析,以 JSON 返回: +{{ + "overall_score": 0-100 的整数(整体匹配度评分), + "highlights": ["亮点1", "亮点2"], + "issues": ["问题1", "问题2"] +}} + +分析角度: +- 卖点传达是否清晰自然(不要求死板对照,语义传达即可) +- 内容氛围和场景是否贴合产品定位 +- 表达语气和风格是否合适 +- 内容结构和节奏是否流畅 +- 是否有吸引力和说服力 + +要求: +- highlights: 脚本做得好的方面,每条一句话,简明具体(如"开头用痛点切入,吸引力强") +- issues: 可以改进的方面,每条一句话,简明具体(如"缺少产品使用演示环节") +- 每项最多给 4 条,只写最重要的 +- 如果整体不错,issues 可以为空数组 +- overall_score: 综合考虑各角度的整体分数 + +请只返回 JSON,不要包含其他内容。""" + + response = await ai_client.chat_completion( + messages=[{"role": "user", "content": prompt}], + model=model, + temperature=0.3, + max_tokens=800, + ) + + import json + resp = response.content.strip() + if resp.startswith("```"): + resp = resp.split("\n", 1)[1] + if resp.endswith("```"): + resp = resp.rsplit("\n", 1)[0] + return json.loads(resp) + except Exception: + return {} @router.post("/review", response_model=ScriptReviewResponse) @@ -100,123 +248,86 @@ async def review_script( db: AsyncSession = Depends(get_db), ) -> ScriptReviewResponse: """ - 脚本预审 + 脚本预审(多维度评分) - - 检测违禁词(支持语境感知) - - 检测功效词 - - 检查必要卖点 - - 应用白名单 - - 可选 AI 深度分析 - - 返回合规分数和修改建议 + 四个独立维度: + - legal: 法规合规(违禁词、功效词、Brief黑名单词) + - platform: 平台规则 + - brand_safety: 品牌安全(竞品、其他品牌词) + - brief_match: Brief 匹配度(卖点覆盖) """ - violations = [] + violations: list[Violation] = [] content = request.content + image_data: list[str] | None = None - # 获取品牌白名单 + # 如果提供了文件 URL,自动解析文本和提取图片 + if request.file_url and request.file_name: + try: + file_text = await DocumentParser.download_and_parse( + request.file_url, request.file_name + ) + if file_text: + content = content + "\n\n" + file_text if content.strip() else file_text + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"文件文本解析失败: {e}") + + try: + image_data = await DocumentParser.download_and_get_images( + request.file_url, request.file_name + ) + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"文件图片提取失败: {e}") + + # 获取品牌方配置的所有规则数据 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) + competitors = await get_competitors_for_brand(x_tenant_id, request.brand_id, db) + db_platform_rules = await get_active_platform_rules( + x_tenant_id, request.brand_id, request.platform.value, db, + ) - # 1. 违禁词检测(广告极限词) - all_forbidden_words = ABSOLUTE_WORDS + [w["word"] for w in tenant_forbidden_words] + # ===== Step 1: 法规合规检测 (legal) ===== - for word in all_forbidden_words: - # 白名单跳过 + # 1a. 内置违禁词(广告极限词) + for word in ABSOLUTE_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, + content=word, severity=RiskLevel.HIGH, dimension="legal", suggestion=f"建议删除或替换违禁词:{word}", position=Position(start=pos, end=pos + len(word)), )) start = pos + 1 - # 2. 功效词检测 + # 1b. 功效词检测 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, + content=word, severity=RiskLevel.HIGH, dimension="legal", 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)), - )) - - # 3A. 平台规则违禁词(优先从 DB 读取,硬编码兜底) - already_checked = set(ABSOLUTE_WORDS + [w["word"] for w in tenant_forbidden_words]) - platform_forbidden_words: list[str] = [] - - # 优先从 DB 获取品牌方上传的 active 平台规则 - db_platform_rules = await get_active_platform_rules( - x_tenant_id, request.brand_id, request.platform.value, db, - ) - if db_platform_rules: - platform_forbidden_words = db_platform_rules.get("forbidden_words", []) - else: - # 兜底:从硬编码 _platform_rules 读取 - platform_rule = _platform_rules.get(request.platform.value) - if platform_rule: - for rule in platform_rule.get("rules", []): - if rule.get("type") == "forbidden_word": - platform_forbidden_words.extend(rule.get("words", [])) - - for word in platform_forbidden_words: - if word in already_checked or 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.MEDIUM, - suggestion=f"违反{request.platform.value}平台规则,建议删除:{word}", - position=Position(start=pos, end=pos + len(word)), - )) - start = pos + 1 - - # 3B. Brief 黑名单词 + # 1c. Brief 黑名单词 if request.blacklist_words: for item in request.blacklist_words: word = item.get("word", "") @@ -233,87 +344,551 @@ async def review_script( suggestion += f"({reason})" violations.append(Violation( type=ViolationType.FORBIDDEN_WORD, - content=word, - severity=RiskLevel.HIGH, + content=word, severity=RiskLevel.HIGH, dimension="legal", suggestion=suggestion, position=Position(start=pos, end=pos + len(word)), )) start_pos = pos + 1 - # 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 [] + # 1d. 租户自定义违禁词 → legal 维度 + for fw in tenant_forbidden_words: + word = fw["word"] + if word in whitelist or word in ABSOLUTE_WORDS: + 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, dimension="legal", + suggestion=f"建议删除或替换违禁词:{word}", + position=Position(start=pos, end=pos + len(word)), + )) + start = pos + 1 - # 5. 可选:AI 深度分析(返回 violations + warnings) - ai_violations, ai_warnings = await _ai_deep_analysis(x_tenant_id, content, db) + # ===== Step 2: 平台规则检测 (platform) ===== + already_checked = set(ABSOLUTE_WORDS + [w["word"] for w in tenant_forbidden_words]) + platform_forbidden_words: list[str] = [] + platform_restricted_words: list[dict] = [] + platform_content_requirements: list[str] = [] + platform_other_rules: list[dict] = [] + + # 优先使用品牌方上传的 DB 平台规则,否则用硬编码兜底 + if db_platform_rules: + platform_forbidden_words = db_platform_rules.get("forbidden_words", []) + platform_restricted_words = db_platform_rules.get("restricted_words", []) + platform_content_requirements = db_platform_rules.get("content_requirements", []) + platform_other_rules = db_platform_rules.get("other_rules", []) + else: + platform_rule = _platform_rules.get(request.platform.value) + if platform_rule: + for rule in platform_rule.get("rules", []): + if rule.get("type") == "forbidden_word": + platform_forbidden_words.extend(rule.get("words", [])) + + # 2a. 平台违禁词检测 + for word in platform_forbidden_words: + if word in already_checked or 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.MEDIUM, dimension="platform", + suggestion=f"违反{request.platform.value}平台规则,建议删除:{word}", + position=Position(start=pos, end=pos + len(word)), + )) + start = pos + 1 + + # 2b. 平台限制词检测(有条件限制的词语) + for rw in platform_restricted_words: + word = rw.get("word", "") + if not word or word in whitelist: + continue + if word in content: + suggestion = rw.get("suggestion", f"「{word}」为平台限制用语") + condition = rw.get("condition", "") + if condition: + suggestion = f"「{word}」限制条件:{condition}。{suggestion}" + violations.append(Violation( + type=ViolationType.FORBIDDEN_WORD, + content=word, severity=RiskLevel.LOW, dimension="platform", + suggestion=suggestion, + position=Position(start=content.find(word), end=content.find(word) + len(word)), + )) + + # ===== Step 3: 品牌安全检测 (brand_safety) ===== + + # 3a. 其他品牌专属词 + 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, dimension="brand_safety", + suggestion=f"使用了其他品牌的专属词汇:{term}", + position=Position(start=content.find(term), end=content.find(term) + len(term)), + )) + + # 3b. 竞品名称和关键词检测 + for comp in competitors: + comp_name = comp["name"] + if comp_name in whitelist: + continue + if comp_name in content: + violations.append(Violation( + type=ViolationType.BRAND_SAFETY, + content=comp_name, severity=RiskLevel.HIGH, dimension="brand_safety", + suggestion=f"脚本中出现竞品品牌名「{comp_name}」,请删除或替换", + position=Position(start=content.find(comp_name), end=content.find(comp_name) + len(comp_name)), + )) + for kw in comp.get("keywords", []): + if not kw or kw in whitelist: + continue + if kw in content: + violations.append(Violation( + type=ViolationType.BRAND_SAFETY, + content=kw, severity=RiskLevel.MEDIUM, dimension="brand_safety", + suggestion=f"脚本中出现竞品「{comp_name}」的关联词「{kw}」,请确认是否需要删除", + position=Position(start=content.find(kw), end=content.find(kw) + len(kw)), + )) + + # ===== Step 4: AI 深度分析 ===== + # 构建品牌方规则上下文传给 AI + brand_rules_context = _build_brand_rules_context( + competitors=competitors, + tenant_forbidden_words=tenant_forbidden_words, + whitelist=whitelist, + db_platform_rules=db_platform_rules, + platform_content_requirements=platform_content_requirements, + platform_other_rules=platform_other_rules, + ) + ai_violations, ai_warnings = await _ai_deep_analysis( + x_tenant_id, content, db, + image_data=image_data, + platform=request.platform.value, + brand_rules_context=brand_rules_context, + ) if ai_violations: - violations.extend(ai_violations) + for v in ai_violations: + # 根据类型分配维度 + if v.type in (ViolationType.FORBIDDEN_WORD, ViolationType.EFFICACY_CLAIM): + v.dimension = "legal" + elif v.type == ViolationType.COMPETITOR_LOGO: + v.dimension = "brand_safety" + else: + v.dimension = "brand_safety" + violations.append(v) - # 6. 计算分数(按严重程度加权) - score = 100 - for v in violations: - if v.severity == RiskLevel.HIGH: - score -= 25 - elif v.severity == RiskLevel.MEDIUM: - score -= 15 - else: - score -= 5 - if missing_points: - score -= len(missing_points) * 5 - score = max(0, score) - - # 7. 生成摘要 - parts = [] + # ===== Step 4b: AI 语境复核(过滤误报) ===== + # 将关键词匹配到的违规项交给 AI 复核上下文语义,去除误判 if violations: - parts.append(f"发现 {len(violations)} 处违规") - if missing_points: - parts.append(f"遗漏 {len(missing_points)} 个卖点") + violations = await _ai_context_verify( + x_tenant_id, content, violations, db, + ) + # ===== Step 5: 卖点语义匹配 + 整体 Brief 匹配分析 ===== + selling_points = _normalize_selling_points(request.selling_points) + selling_point_matches: list[SellingPointMatch] = [] + brief_overall: dict = {} + + ai_client = None + ai_available = False + text_model = "gpt-4o" + try: + ai_client = await AIServiceFactory.get_client(x_tenant_id, db) + if ai_client: + ai_available = True + config = await AIServiceFactory.get_config(x_tenant_id, db) + if config: + text_model = config.models.get("text", "gpt-4o") + except Exception: + pass + + if selling_points: + selling_point_matches = await _ai_selling_point_analysis( + ai_client, content, selling_points, text_model + ) + + # AI 整体 Brief 匹配分析(亮点 + 问题点) + brief_overall = await _ai_brief_overall_analysis( + ai_client, content, selling_points, text_model + ) + + # ===== Step 6: 各维度独立评分 ===== + def _calc_dimension_score(dim: str) -> tuple[int, int]: + dim_violations = [v for v in violations if v.dimension == dim] + score = 100 + for v in dim_violations: + if v.severity == RiskLevel.HIGH: + score -= 25 + elif v.severity == RiskLevel.MEDIUM: + score -= 15 + else: + score -= 5 + return max(0, score), len(dim_violations) + + legal_score, legal_count = _calc_dimension_score("legal") + platform_score, platform_count = _calc_dimension_score("platform") + brand_safety_score, brand_safety_count = _calc_dimension_score("brand_safety") + + # brief_match 评分:基于 min_selling_points 覆盖率 + AI 整体匹配度 + checkable = [spm for spm in selling_point_matches if spm.priority in ("core", "recommended")] + matched_count = sum(1 for spm in checkable if spm.matched) + total_checkable = len(checkable) + + # 代理商要求的最少体现条数(默认 = 全部 core 数量) + core_count = sum(1 for spm in checkable if spm.priority == "core") + min_required = request.min_selling_points if request.min_selling_points is not None else core_count + # 确保不超过可检查的总数 + min_required = min(min_required, total_checkable) if total_checkable > 0 else 0 + + # 覆盖率得分:matched / min_required(满足要求 = 100 分) + if min_required > 0: + coverage_ratio = min(matched_count / min_required, 1.0) + coverage_score = round(coverage_ratio * 100) + elif total_checkable > 0: + # 没有要求但有卖点 → 按全量比例 + coverage_score = round(matched_count / total_checkable * 100) + else: + coverage_score = 100 # 无卖点要求 + + # AI 整体匹配度得分 + ai_overall_score = brief_overall.get("overall_score", coverage_score) + ai_overall_score = max(0, min(100, ai_overall_score)) + + # 综合 brief_match 得分 = 覆盖率 60% + 整体匹配度 40% + brief_match_score = round(coverage_score * 0.6 + ai_overall_score * 0.4) + brief_match_score = max(0, min(100, brief_match_score)) + + # 构建 BriefMatchDetail + highlights = brief_overall.get("highlights", [])[:4] + issues_list = brief_overall.get("issues", [])[:4] + + # 生成评分说明 + if min_required > 0: + explanation = f"要求至少体现 {min_required} 条卖点,实际匹配 {matched_count} 条(覆盖率 {coverage_score}%),整体匹配度 {ai_overall_score}%" + elif total_checkable > 0: + explanation = f"共 {total_checkable} 条卖点,匹配 {matched_count} 条(覆盖率 {coverage_score}%),整体匹配度 {ai_overall_score}%" + else: + explanation = f"整体匹配度 {ai_overall_score}%" + + brief_match_detail = BriefMatchDetail( + total_points=total_checkable, + matched_points=matched_count, + required_points=min_required, + coverage_score=coverage_score, + overall_score=ai_overall_score, + highlights=highlights, + issues=issues_list, + explanation=explanation, + ) + + # 加权总分 + total_score = round( + legal_score * 0.35 + + platform_score * 0.25 + + brand_safety_score * 0.25 + + brief_match_score * 0.15 + ) + total_score = max(0, min(100, total_score)) + + # ===== Step 7: 各维度 passed 判定 ===== + has_high_legal = any( + v.dimension == "legal" and v.severity == RiskLevel.HIGH for v in violations + ) + legal_passed = legal_score >= 60 and not has_high_legal + platform_passed = platform_score >= 60 + brand_safety_passed = brand_safety_score >= 70 + # brief_match passed: 覆盖率达标(matched >= min_required) + brief_match_passed = matched_count >= min_required if min_required > 0 else True + + dimensions = ReviewDimensions( + legal=ReviewDimension(score=legal_score, passed=legal_passed, issue_count=legal_count), + platform=ReviewDimension(score=platform_score, passed=platform_passed, issue_count=platform_count), + brand_safety=ReviewDimension(score=brand_safety_score, passed=brand_safety_passed, issue_count=brand_safety_count), + brief_match=ReviewDimension( + score=brief_match_score, passed=brief_match_passed, + issue_count=sum(1 for spm in checkable if not spm.matched), + ), + ) + + # 向后兼容 missing_points + missing_points: list[str] | None = None + if selling_point_matches: + core_missing = [spm.content for spm in selling_point_matches if spm.priority == "core" and not spm.matched] + missing_points = core_missing + + # 生成摘要 + parts = [] + if not legal_passed: + parts.append(f"法规合规问题 {legal_count} 处") + if not platform_passed: + parts.append(f"平台规则问题 {platform_count} 处") + if not brand_safety_passed: + parts.append(f"品牌安全问题 {brand_safety_count} 处") + if not brief_match_passed: + unmatched = min_required - matched_count + parts.append(f"卖点覆盖不足(还差 {unmatched} 条)") 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) - - # 合并 AI 产出的 soft_warnings if ai_warnings: soft_warnings.extend(ai_warnings) - - # 遗漏卖点也加入 soft_warnings if missing_points: soft_warnings.append(SoftRiskWarning( code="missing_selling_points", - message=f"遗漏 {len(missing_points)} 个卖点:{', '.join(missing_points)}", + message=f"核心卖点未覆盖:{', '.join(missing_points)}", action_required=SoftRiskAction.NOTE, blocking=False, )) return ScriptReviewResponse( - score=score, + score=total_score, summary=summary, + dimensions=dimensions, + selling_point_matches=selling_point_matches, + brief_match_detail=brief_match_detail, violations=violations, missing_points=missing_points, soft_warnings=soft_warnings, + ai_available=ai_available, ) +async def _ai_context_verify( + tenant_id: str, + content: str, + violations: list[Violation], + db: AsyncSession, +) -> list[Violation]: + """ + AI 语境复核:将关键词匹配到的违规项交给 AI 判断上下文语义。 + + 例如违禁词"小孩",如果脚本写"这不是小孩玩的",则属于否定语境,不构成违规。 + AI 不可用时直接返回原列表(降级为纯关键词匹配)。 + """ + if not violations: + return violations + + try: + ai_client = await AIServiceFactory.get_client(tenant_id, db) + if not ai_client: + return violations + + config = await AIServiceFactory.get_config(tenant_id, db) + if not config: + return violations + + text_model = config.models.get("text", "gpt-4o") + + # 构建违规项列表 + items_text = [] + for i, v in enumerate(violations): + # 提取违规词周围的上下文(前后各 40 字符) + ctx = "" + if v.position and v.position.start is not None: + ctx_start = max(0, v.position.start - 40) + ctx_end = min(len(content), v.position.end + 40) + ctx = content[ctx_start:ctx_end] + else: + # 没有位置信息,尝试找上下文 + pos = content.find(v.content) + if pos != -1: + ctx_start = max(0, pos - 40) + ctx_end = min(len(content), pos + len(v.content) + 40) + ctx = content[ctx_start:ctx_end] + + items_text.append( + f"{i}. 词语「{v.content}」| 维度: {v.dimension} | 上下文: ...{ctx}..." + ) + + prompt = f"""你是广告合规审核专家。以下脚本中通过关键词匹配检测到了一些疑似违规项。 +请根据脚本的完整上下文语义,判断每一项是否真正构成违规。 + +完整脚本内容: +{content} + +检测到的疑似违规项: +{chr(10).join(items_text)} + +判断标准: +- 如果该词出现在否定语境中(如"不是XX"、"不含XX"、"避免XX"),通常不构成违规 +- 如果该词用于客观描述、对比说明或免责声明中,需要根据具体语境判断 +- 如果该词用于正面宣传、推荐、承诺等语境中,构成违规 +- 仅当你非常确定不构成违规时才标记为 false + +请以 JSON 数组返回,每项包含: +- index: 违规项编号(对应上面的编号) +- is_violation: true/false(在上下文中是否真正构成违规) +- reason: 简要说明判断理由(20字以内) + +请只返回 JSON 数组,不要包含其他内容。""" + + response = await ai_client.chat_completion( + messages=[{"role": "user", "content": prompt}], + model=text_model, + temperature=0.1, + max_tokens=1000, + ) + + import json as _json + 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) + + # 构建复核结果映射 + verify_map: dict[int, dict] = {} + for item in ai_results: + idx = item.get("index") + if idx is not None: + verify_map[idx] = item + + # 过滤误报 + verified = [] + import logging + _logger = logging.getLogger(__name__) + for i, v in enumerate(violations): + result = verify_map.get(i) + if result and not result.get("is_violation", True): + reason = result.get("reason", "") + _logger.info(f"AI 语境复核排除误报: 「{v.content}」— {reason}") + continue + verified.append(v) + + return verified + + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"AI 语境复核失败,保留原始结果: {e}") + return violations + + +def _build_brand_rules_context( + competitors: list[dict], + tenant_forbidden_words: list[dict], + whitelist: list[str], + db_platform_rules: dict | None, + platform_content_requirements: list[str], + platform_other_rules: list[dict], +) -> str: + """构建品牌方规则上下文文本,注入 AI prompt""" + sections = [] + + # 竞品列表 + if competitors: + comp_lines = [] + for c in competitors: + kws = ", ".join(c.get("keywords", [])) + line = f" - {c['name']}" + if kws: + line += f"(关键词:{kws})" + comp_lines.append(line) + sections.append("【竞品品牌列表】脚本中不得出现以下竞品品牌名或关联词:\n" + "\n".join(comp_lines)) + + # 自定义违禁词 + if tenant_forbidden_words: + words = [w["word"] for w in tenant_forbidden_words] + sections.append(f"【品牌方自定义违禁词】以下词语禁止使用:{', '.join(words)}") + + # 白名单 + if whitelist: + sections.append(f"【白名单】以下词语已获授权可以使用,不应标记为违规:{', '.join(whitelist)}") + + # DB 平台规则中的内容要求和其他规则 + if platform_content_requirements: + sections.append("【平台内容要求】\n" + "\n".join(f" - {r}" for r in platform_content_requirements)) + + if platform_other_rules: + other_lines = [] + for r in platform_other_rules: + rule_name = r.get("rule", "") + rule_desc = r.get("description", "") + other_lines.append(f" - {rule_name}:{rule_desc}") + sections.append("【平台其他规则】\n" + "\n".join(other_lines)) + + # DB 平台规则中的限制词 + if db_platform_rules: + restricted = db_platform_rules.get("restricted_words", []) + if restricted: + rw_lines = [] + for rw in restricted: + word = rw.get("word", "") + condition = rw.get("condition", "") + rw_lines.append(f" - 「{word}」— {condition}") + sections.append("【平台限制用语】以下词语有使用条件限制:\n" + "\n".join(rw_lines)) + + return "\n\n".join(sections) if sections else "" + + async def _ai_deep_analysis( tenant_id: str, content: str, db: AsyncSession, + image_data: list[str] | None = None, + platform: str = "douyin", + brand_rules_context: str = "", ) -> tuple[list[Violation], list[SoftRiskWarning]]: """ - 使用 AI 进行深度分析 + 使用 AI 进行深度分析(支持纯文本和多模态图片审核) + + Args: + tenant_id: 租户 ID + content: 脚本文本内容 + db: 数据库会话 + image_data: 可选的 base64 图片列表(从文档中提取) + platform: 投放平台 + brand_rules_context: 品牌方配置的规则上下文 返回 (violations, soft_warnings) AI 分析失败时返回空列表,降级到规则检测 """ + platform_labels = { + "douyin": "抖音", "xiaohongshu": "小红书", "bilibili": "B站", + "kuaishou": "快手", "weibo": "微博", "wechat": "微信", + } + platform_label = platform_labels.get(platform, platform) + + # 获取平台特定规则(硬编码兜底) + platform_rule_details = _platform_rules.get(platform, {}) + platform_rule_text = "" + if platform_rule_details: + rule_items = [] + for rule in platform_rule_details.get("rules", []): + if rule.get("type") == "forbidden_word": + rule_items.append(f"- 平台违禁词:{', '.join(rule.get('words', []))}") + elif rule.get("type") == "duration": + if rule.get("min_seconds"): + rule_items.append(f"- 最短时长要求:{rule['min_seconds']}秒") + if rule_items: + platform_rule_text = f"\n\n{platform_label}平台基础规则:\n" + "\n".join(rule_items) + + # 品牌方配置的规则上下文 + brand_context_text = "" + if brand_rules_context: + brand_context_text = f"\n\n===== 品牌方审核规则配置 =====\n{brand_rules_context}\n=============================" + try: # 获取 AI 客户端 ai_client = await AIServiceFactory.get_client(tenant_id, db) @@ -327,39 +902,65 @@ async def _ai_deep_analysis( text_model = config.models.get("text", "gpt-4o") - # 构建分析提示(两类输出) - analysis_prompt = f"""作为广告合规审核专家,请分析以下广告脚本内容,检测潜在的合规风险: + # 构建基础分析提示 + base_prompt = f"""作为广告合规审核专家,请分析以下将在「{platform_label}」平台发布的广告脚本内容,检测潜在的合规风险: 脚本内容: {content} +{platform_rule_text}{brand_context_text} -请检查以下方面: +请结合上述所有规则配置,重点检查以下方面: 1. 是否存在隐性的虚假宣传(如暗示疗效但不直接说明) 2. 是否存在容易引起误解的表述 3. 是否存在夸大描述 4. 是否存在可能违反广告法的其他内容 +5. 是否违反{platform_label}平台的内容规范和社区规则 +6. 是否出现竞品品牌名称或关联词汇(如有竞品列表) +7. 是否符合平台内容要求(如有具体要求)""" + + # 有图片时追加图片审核要点 + if image_data: + base_prompt += """ +5. 图片中是否出现竞品品牌 logo 或商标 +6. 图片中是否存在违规画面(涉黄、暴力、敏感内容等) +7. 图片中是否存在虚假对比图或误导性图片 +8. 图片中的文字是否包含违禁词或夸大宣传""" + + base_prompt += """ 请以 JSON 数组返回,每项包含: - category: "violation"(硬性违规,明确违法/违规)或 "warning"(软性提醒,需人工判断) -- type: 违规类型 (forbidden_word/efficacy_claim/brand_safety) +- type: 违规类型 (forbidden_word/efficacy_claim/brand_safety/competitor_logo) - content: 问题内容 - severity: 严重程度 (high/medium/low) - suggestion: 修改建议 分类标准: -- violation: 违禁词、功效宣称、品牌安全等明确违规 +- violation: 违禁词、功效宣称、品牌安全、竞品露出等明确违规 - warning: 夸大描述、易误解表述、潜在风险 如果未发现问题,返回空数组 [] 请只返回 JSON 数组,不要包含其他内容。""" - response = await ai_client.chat_completion( - messages=[{"role": "user", "content": analysis_prompt}], - model=text_model, - temperature=0.3, - max_tokens=1000, - ) + # 根据是否有图片选择纯文本或多模态分析 + if image_data: + vision_model = config.models.get("vision", text_model) + image_urls = [f"data:image/png;base64,{b64}" for b64 in image_data] + response = await ai_client.vision_analysis( + image_urls=image_urls, + prompt=base_prompt, + model=vision_model, + temperature=0.3, + max_tokens=1500, + ) + else: + response = await ai_client.chat_completion( + messages=[{"role": "user", "content": base_prompt}], + model=text_model, + temperature=0.3, + max_tokens=1000, + ) # 解析 AI 响应 import json @@ -383,6 +984,8 @@ async def _ai_deep_analysis( vtype = ViolationType.FORBIDDEN_WORD elif violation_type == "efficacy_claim": vtype = ViolationType.EFFICACY_CLAIM + elif violation_type == "competitor_logo": + vtype = ViolationType.COMPETITOR_LOGO else: vtype = ViolationType.BRAND_SAFETY diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 98565d8..4b0a9f3 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -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], diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py index 47bcac4..128a66d 100644 --- a/backend/app/api/upload.py +++ b/backend/app/api/upload.py @@ -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(...), diff --git a/backend/app/models/brief.py b/backend/app/models/brief.py index abd4116..725c08f 100644 --- a/backend/app/models/brief.py +++ b/backend/app/models/brief.py @@ -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) diff --git a/backend/app/schemas/brief.py b/backend/app/schemas/brief.py index 83cf708..bb86895 100644 --- a/backend/app/schemas/brief.py +++ b/backend/app/schemas/brief.py @@ -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 diff --git a/backend/app/schemas/review.py b/backend/app/schemas/review.py index 4ca7294..da50d8a 100644 --- a/backend/app/schemas/review.py +++ b/backend/app/schemas/review.py @@ -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 表示降级为纯关键词检测)") # ==================== 视频审核 ==================== diff --git a/backend/app/services/ai_client.py b/backend/app/services/ai_client.py index 7683ee4..8f96643 100644 --- a/backend/app/services/ai_client.py +++ b/backend/app/services/ai_client.py @@ -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 diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 4a259db..a8d3ec5 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -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 diff --git a/backend/app/services/document_parser.py b/backend/app/services/document_parser.py index 334dd4f..e1bb222 100644 --- a/backend/app/services/document_parser.py +++ b/backend/app/services/document_parser.py @@ -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 diff --git a/backend/app/services/oss.py b/backend/app/services/oss.py index 8f59b68..cd27c79 100644 --- a/backend/app/services/oss.py +++ b/backend/app/services/oss.py @@ -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 = ( diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index 3a93853..ad7b7bf 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 335e974..050aec4 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -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] diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py index 2042414..aaf9ddd 100644 --- a/backend/scripts/seed.py +++ b/backend/scripts/seed.py @@ -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="视频效果好", diff --git a/backend/tests/test_script_review_api.py b/backend/tests/test_script_review_api.py index 587ae04..f43b89f 100644 --- a/backend/tests/test_script_review_api.py +++ b/backend/tests/test_script_review_api.py @@ -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() diff --git a/frontend/app/agency/briefs/[id]/page.tsx b/frontend/app/agency/briefs/[id]/page.tsx index 179ade0..ef882b2 100644 --- a/frontend/app/agency/briefs/[id]/page.tsx +++ b/frontend/app/agency/briefs/[id]/page.tsx @@ -30,12 +30,14 @@ import { Loader2, Search, AlertCircle, - RotateCcw + RotateCcw, + Users, + UserPlus } from 'lucide-react' import { getPlatformInfo } from '@/lib/platforms' import { api } from '@/lib/api' import { USE_MOCK, useAuth } from '@/contexts/AuthContext' -import type { RuleConflict } from '@/types/rules' +import type { RuleConflict, ParsedRulesData } from '@/types/rules' // 单个文件上传状态 interface UploadingFileItem { @@ -49,6 +51,9 @@ interface UploadingFileItem { } import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief' import type { ProjectResponse } from '@/types/project' +import type { TaskResponse } from '@/types/task' +import type { CreatorDetail } from '@/types/organization' +import { mapTaskToUI } from '@/lib/taskStageMapper' // 文件类型 type BriefFile = { @@ -121,11 +126,11 @@ const mockAgencyConfig = { }, // 代理商配置的卖点(可编辑) sellingPoints: [ - { id: 'sp1', content: 'SPF50+ PA++++', required: true }, - { id: 'sp2', content: '轻薄质地,不油腻', required: true }, - { id: 'sp3', content: '延展性好,易推开', required: false }, - { id: 'sp4', content: '适合敏感肌', required: false }, - { id: 'sp5', content: '夏日必备防晒', required: true }, + { id: 'sp1', content: 'SPF50+ PA++++', priority: 'core' as const }, + { id: 'sp2', content: '轻薄质地,不油腻', priority: 'core' as const }, + { id: 'sp3', content: '延展性好,易推开', priority: 'recommended' as const }, + { id: 'sp4', content: '适合敏感肌', priority: 'reference' as const }, + { id: 'sp5', content: '夏日必备防晒', priority: 'core' as const }, ], // 代理商配置的违禁词(可编辑) blacklistWords: [ @@ -136,32 +141,36 @@ const mockAgencyConfig = { ], } -// 平台规则 -const platformRules = { - douyin: { - name: '抖音', - rules: [ - { category: '广告法违禁词', items: ['最', '第一', '顶级', '极致', '绝对', '永久', '万能', '特效'] }, - { category: '医疗相关禁用', items: ['治疗', '药用', '医学', '临床', '处方'] }, - { category: '虚假宣传', items: ['100%', '纯天然', '无副作用', '立竿见影'] }, - ], - }, - xiaohongshu: { - name: '小红书', - rules: [ - { category: '广告法违禁词', items: ['最', '第一', '顶级', '极品', '绝对'] }, - { category: '功效承诺禁用', items: ['包治', '根治', '祛除', '永久'] }, - ], - }, - bilibili: { - name: 'B站', - rules: [ - { category: '广告法违禁词', items: ['最', '第一', '顶级', '极致'] }, - { category: '虚假宣传', items: ['100%', '纯天然', '无副作用'] }, - ], - }, +// 平台规则类型 +interface PlatformRuleCategory { + category: string + items: string[] } +// 将后端 ParsedRulesData 转为 UI 展示格式 +function parsedRulesToCategories(parsed: ParsedRulesData): PlatformRuleCategory[] { + const categories: PlatformRuleCategory[] = [] + if (parsed.forbidden_words?.length) { + categories.push({ category: '违禁词', items: parsed.forbidden_words }) + } + if (parsed.restricted_words?.length) { + categories.push({ category: '限制用语', items: parsed.restricted_words.map(w => w.word) }) + } + if (parsed.content_requirements?.length) { + categories.push({ category: '内容要求', items: parsed.content_requirements }) + } + if (parsed.other_rules?.length) { + categories.push({ category: '其他规则', items: parsed.other_rules.map(r => r.rule) }) + } + return categories +} + +// Mock 模式下的默认平台规则 +const mockPlatformRules: PlatformRuleCategory[] = [ + { category: '广告法违禁词', items: ['最', '第一', '顶级', '极致', '绝对', '永久', '万能', '特效'] }, + { category: '功效承诺禁用', items: ['包治', '根治', '祛除', '永久'] }, +] + // ==================== 工具函数 ==================== function formatFileSize(bytes: number): string { @@ -225,6 +234,7 @@ export default function BriefConfigPage() { const [agencyConfig, setAgencyConfig] = useState(mockAgencyConfig) const [newSellingPoint, setNewSellingPoint] = useState('') const [newBlacklistWord, setNewBlacklistWord] = useState('') + const [minSellingPoints, setMinSellingPoints] = useState(null) // 弹窗状态 const [showFilesModal, setShowFilesModal] = useState(false) @@ -236,6 +246,16 @@ export default function BriefConfigPage() { const [isAIParsing, setIsAIParsing] = useState(false) const isUploading = uploadingFiles.some(f => f.status === 'uploading') + // 动态平台规则 + const [dynamicPlatformRules, setDynamicPlatformRules] = useState(mockPlatformRules) + const [platformRuleName, setPlatformRuleName] = useState('') + + // 任务管理 + const [projectTasks, setProjectTasks] = useState([]) + const [availableCreators, setAvailableCreators] = useState([]) + const [showCreatorModal, setShowCreatorModal] = useState(false) + const [creatingTask, setCreatingTask] = useState(false) + // 规则冲突检测 const [isCheckingConflicts, setIsCheckingConflicts] = useState(false) const [showConflictModal, setShowConflictModal] = useState(false) @@ -263,6 +283,38 @@ export default function BriefConfigPage() { return () => document.removeEventListener('mousedown', handleClickOutside) }, [showPlatformSelect]) + const handleCreateTask = async (creatorId: string) => { + setCreatingTask(true) + try { + if (USE_MOCK) { + await new Promise(resolve => setTimeout(resolve, 500)) + const creator = availableCreators.find(c => c.id === creatorId) + const seq = projectTasks.length + 1 + setProjectTasks(prev => [...prev, { + id: `TK-mock-${Date.now()}`, name: `${brandBrief.projectName} #${seq}`, sequence: seq, + stage: 'script_upload', + project: { id: projectId, name: brandBrief.projectName }, + agency: { id: 'AG000001', name: '星辰传媒' }, + creator: { id: creatorId, name: creator?.name || '未知达人' }, + appeal_count: 0, is_appeal: false, + created_at: new Date().toISOString(), updated_at: new Date().toISOString(), + }]) + toast.success('任务创建成功') + setShowCreatorModal(false) + } else { + await api.createTask({ project_id: projectId, creator_id: creatorId }) + const tasksResp = await api.listTasks(1, 100, undefined, projectId) + setProjectTasks(tasksResp.items) + toast.success('任务创建成功') + setShowCreatorModal(false) + } + } catch { + toast.error('创建任务失败') + } finally { + setCreatingTask(false) + } + } + const handleCheckConflicts = async (platform: string) => { setShowPlatformSelect(false) setIsCheckingConflicts(true) @@ -315,6 +367,29 @@ export default function BriefConfigPage() { const loadData = useCallback(async () => { if (USE_MOCK) { // Mock 模式使用默认数据 + setProjectTasks([ + { + id: 'TK000001', name: 'XX品牌618推广 #1', sequence: 1, stage: 'script_upload', + project: { id: 'proj-001', name: 'XX品牌618推广' }, + agency: { id: 'AG000001', name: '星辰传媒' }, + creator: { id: 'CR000001', name: '李小红' }, + appeal_count: 0, is_appeal: false, + created_at: '2026-02-01T10:00:00', updated_at: '2026-02-01T10:00:00', + }, + { + id: 'TK000002', name: 'XX品牌618推广 #2', sequence: 2, stage: 'script_agency_review', + project: { id: 'proj-001', name: 'XX品牌618推广' }, + agency: { id: 'AG000001', name: '星辰传媒' }, + creator: { id: 'CR000002', name: '张大力' }, + appeal_count: 0, is_appeal: false, + created_at: '2026-02-02T10:00:00', updated_at: '2026-02-03T10:00:00', + }, + ]) + setAvailableCreators([ + { id: 'CR000001', name: '李小红', douyin_account: 'lixiaohong', xiaohongshu_account: null, bilibili_account: null }, + { id: 'CR000002', name: '张大力', douyin_account: 'zhangdali', xiaohongshu_account: 'zhangdali_xhs', bilibili_account: null }, + { id: 'CR000003', name: '王美丽', douyin_account: null, xiaohongshu_account: 'wangmeili', bilibili_account: null }, + ]) setLoading(false) return } @@ -367,6 +442,11 @@ export default function BriefConfigPage() { // 映射到代理商配置视图 const hasBrief = !!(brief?.selling_points?.length || brief?.blacklist_words?.length || brief?.brand_tone) + // 加载最少卖点数配置 + if (brief?.min_selling_points != null) { + setMinSellingPoints(brief.min_selling_points) + } + setAgencyConfig({ status: hasBrief ? 'configured' : 'pending', configuredAt: hasBrief ? (brief!.updated_at.split('T')[0]) : '', @@ -377,17 +457,25 @@ export default function BriefConfigPage() { uploadedAt: brief!.updated_at?.split('T')[0] || '', url: att.url, })), - aiParsedContent: { - productName: brief?.brand_tone || '待解析', - targetAudience: '待解析', - contentRequirements: brief?.min_duration && brief?.max_duration - ? `视频时长 ${brief.min_duration}-${brief.max_duration} 秒` - : (brief?.other_requirements || '待解析'), - }, + aiParsedContent: (() => { + // brand_tone 存储格式: "产品名称\n目标受众" + const toneParts = (brief?.brand_tone || '').split('\n') + const productName = toneParts[0] || '' + const targetAudience = toneParts[1] || '' + const contentRequirements = brief?.other_requirements || '' + return { + productName: productName || '待解析', + targetAudience: targetAudience || '待解析', + contentRequirements: contentRequirements + || (brief?.min_duration && brief?.max_duration + ? `视频时长 ${brief.min_duration}-${brief.max_duration} 秒` + : '待解析'), + } + })(), sellingPoints: (brief?.selling_points || []).map((sp, i) => ({ id: `sp-${i}`, content: sp.content, - required: sp.required, + priority: (sp.priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference', })), blacklistWords: (brief?.blacklist_words || []).map((bw, i) => ({ id: `bw-${i}`, @@ -395,6 +483,38 @@ export default function BriefConfigPage() { reason: bw.reason, })), }) + + // 3. 获取平台规则 + const platformKey = project.platform || 'douyin' + const platformInfo = getPlatformInfo(platformKey) + setPlatformRuleName(platformInfo?.name || platformKey) + try { + const rulesResp = await api.listBrandPlatformRules({ platform: platformKey, status: 'active' }) + if (rulesResp.items.length > 0) { + const categories = parsedRulesToCategories(rulesResp.items[0].parsed_rules) + if (categories.length > 0) { + setDynamicPlatformRules(categories) + } + } + } catch (e) { + console.warn('获取平台规则失败,使用默认规则:', e) + } + + // 4. 获取项目任务列表 + try { + const tasksResp = await api.listTasks(1, 100, undefined, projectId) + setProjectTasks(tasksResp.items) + } catch (e) { + console.warn('获取项目任务列表失败:', e) + } + + // 5. 获取可选达人列表 + try { + const creatorsResp = await api.listAgencyCreators() + setAvailableCreators(creatorsResp.items) + } catch (e) { + console.warn('获取达人列表失败:', e) + } } catch (err) { console.error('加载 Brief 详情失败:', err) toast.error('加载 Brief 详情失败') @@ -408,7 +528,6 @@ export default function BriefConfigPage() { }, [loadData]) const platform = getPlatformInfo(brandBrief.platform) - const rules = platformRules[brandBrief.platform as keyof typeof platformRules] || platformRules.douyin // 下载文件 const handleDownload = async (file: BriefFile) => { @@ -417,17 +536,7 @@ export default function BriefConfigPage() { return } try { - const signedUrl = await api.getSignedUrl(file.url) - const resp = await fetch(signedUrl) - const blob = await resp.blob() - const blobUrl = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = blobUrl - a.download = file.name - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(blobUrl) + await api.downloadFile(file.url, file.name) } catch { toast.error('下载失败') } @@ -442,8 +551,8 @@ export default function BriefConfigPage() { if (!USE_MOCK && file.url) { setPreviewLoading(true) try { - const signedUrl = await api.getSignedUrl(file.url) - setPreviewUrl(signedUrl) + const blobUrl = await api.getPreviewUrl(file.url) + setPreviewUrl(blobUrl) } catch { toast.error('获取预览链接失败') } finally { @@ -463,9 +572,68 @@ export default function BriefConfigPage() { // AI 解析 const handleAIParse = async () => { setIsAIParsing(true) - await new Promise(resolve => setTimeout(resolve, 2000)) - setIsAIParsing(false) - toast.success('AI 解析完成!') + + if (USE_MOCK) { + await new Promise(resolve => setTimeout(resolve, 2000)) + setIsAIParsing(false) + toast.success('AI 解析完成!') + return + } + + try { + const result = await api.parseBrief(projectId) + + // 更新 AI 解析结果 + setAgencyConfig(prev => ({ + ...prev, + aiParsedContent: { + productName: result.product_name || prev.aiParsedContent.productName, + targetAudience: result.target_audience || prev.aiParsedContent.targetAudience, + contentRequirements: result.content_requirements || prev.aiParsedContent.contentRequirements, + }, + // 如果 AI 解析出了卖点且当前没有卖点,则自动填充 + sellingPoints: result.selling_points?.length + ? result.selling_points.map((sp, i) => ({ + id: `sp-ai-${i}`, + content: sp.content, + priority: ((sp as any).priority || (sp.required ? 'core' : 'recommended')) as 'core' | 'recommended' | 'reference', + })) + : prev.sellingPoints, + // 如果 AI 解析出了违禁词且当前没有违禁词,则自动填充 + blacklistWords: result.blacklist_words?.length + ? result.blacklist_words.map((bw, i) => ({ + id: `bw-ai-${i}`, + word: bw.word, + reason: bw.reason, + })) + : prev.blacklistWords, + })) + + // AI 解析成功后自动保存到后端 + if (!USE_MOCK) { + try { + await api.updateBriefByAgency(projectId, { + brand_tone: [result.product_name, result.target_audience].filter(Boolean).join('\n'), + other_requirements: result.content_requirements || undefined, + selling_points: result.selling_points?.length + ? result.selling_points.map(sp => ({ content: sp.content, priority: (sp as any).priority || (sp.required ? 'core' : 'recommended') })) as any + : undefined, + blacklist_words: result.blacklist_words?.length + ? result.blacklist_words.map(bw => ({ word: bw.word, reason: bw.reason })) + : undefined, + }) + } catch (e) { + console.warn('保存 AI 解析结果失败:', e) + } + } + + toast.success('AI 解析完成!') + } catch (err: any) { + const msg = err?.message || 'AI 解析失败' + toast.error(msg) + } finally { + setIsAIParsing(false) + } } // 保存配置 @@ -474,32 +642,24 @@ export default function BriefConfigPage() { if (!USE_MOCK) { try { - const payload = { + // 代理商通过专用 PATCH 端点保存 + await api.updateBriefByAgency(projectId, { selling_points: agencyConfig.sellingPoints.map(sp => ({ content: sp.content, - required: sp.required, - })), + priority: sp.priority, + })) as any, blacklist_words: agencyConfig.blacklistWords.map(bw => ({ word: bw.word, reason: bw.reason, })), - competitors: brandBrief.brandRules.competitors, - brand_tone: agencyConfig.aiParsedContent.productName, - other_requirements: brandBrief.brandRules.restrictions, agency_attachments: agencyConfig.agencyFiles.map(f => ({ id: f.id, name: f.name, url: f.url || '', size: f.size, })), - } - - // 尝试更新,如果 Brief 不存在则创建 - try { - await api.updateBrief(projectId, payload) - } catch { - await api.createBrief(projectId, payload) - } + min_selling_points: minSellingPoints, + }) setIsSaving(false) toast.success('配置已保存!') @@ -523,7 +683,7 @@ export default function BriefConfigPage() { if (!newSellingPoint.trim()) return setAgencyConfig(prev => ({ ...prev, - sellingPoints: [...prev.sellingPoints, { id: `sp${Date.now()}`, content: newSellingPoint, required: false }] + sellingPoints: [...prev.sellingPoints, { id: `sp${Date.now()}`, content: newSellingPoint, priority: 'recommended' as const }] })) setNewSellingPoint('') } @@ -535,12 +695,15 @@ export default function BriefConfigPage() { })) } - const toggleRequired = (id: string) => { + const cyclePriority = (id: string) => { + const order: Array<'core' | 'recommended' | 'reference'> = ['core', 'recommended', 'reference'] setAgencyConfig(prev => ({ ...prev, - sellingPoints: prev.sellingPoints.map(sp => - sp.id === id ? { ...sp, required: !sp.required } : sp - ) + sellingPoints: prev.sellingPoints.map(sp => { + if (sp.id !== id) return sp + const idx = order.indexOf(sp.priority) + return { ...sp, priority: order[(idx + 1) % order.length] } + }) })) } @@ -561,6 +724,20 @@ export default function BriefConfigPage() { })) } + // 自动保存代理商附件到后端(防止刷新丢失) + const autoSaveAgencyFiles = useCallback(async (files: AgencyFile[]) => { + if (USE_MOCK) return + try { + await api.updateBriefByAgency(projectId, { + agency_attachments: files.map(f => ({ + id: f.id, name: f.name, url: f.url || '', size: f.size, + })), + }) + } catch (e) { + console.warn('自动保存代理商附件失败:', e) + } + }, [projectId]) + // 上传单个代理商文件 const uploadSingleAgencyFile = async (file: File, fileId: string) => { if (USE_MOCK) { @@ -589,7 +766,12 @@ export default function BriefConfigPage() { id: fileId, name: file.name, size: formatFileSize(file.size), uploadedAt: new Date().toISOString().split('T')[0], url: result.url, } - setAgencyConfig(prev => ({ ...prev, agencyFiles: [...prev.agencyFiles, newFile] })) + setAgencyConfig(prev => { + const updated = [...prev.agencyFiles, newFile] + // 文件上传成功后自动保存到后端 + autoSaveAgencyFiles(updated) + return { ...prev, agencyFiles: updated } + }) setUploadingFiles(prev => prev.filter(f => f.id !== fileId)) } catch (err) { const msg = err instanceof Error ? err.message : '上传失败' @@ -639,10 +821,12 @@ export default function BriefConfigPage() { } const removeAgencyFile = (id: string) => { - setAgencyConfig(prev => ({ - ...prev, - agencyFiles: prev.agencyFiles.filter(f => f.id !== id) - })) + setAgencyConfig(prev => { + const updated = prev.agencyFiles.filter(f => f.id !== id) + // 删除后也自动保存 + autoSaveAgencyFiles(updated) + return { ...prev, agencyFiles: updated } + }) } const [previewAgencyUrl, setPreviewAgencyUrl] = useState(null) @@ -653,8 +837,8 @@ export default function BriefConfigPage() { if (!USE_MOCK && file.url) { setPreviewAgencyLoading(true) try { - const signedUrl = await api.getSignedUrl(file.url) - setPreviewAgencyUrl(signedUrl) + const blobUrl = await api.getPreviewUrl(file.url) + setPreviewAgencyUrl(blobUrl) } catch { toast.error('获取预览链接失败') } finally { @@ -669,17 +853,7 @@ export default function BriefConfigPage() { return } try { - const signedUrl = await api.getSignedUrl(file.url) - const resp = await fetch(signedUrl) - const blob = await resp.blob() - const blobUrl = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = blobUrl - a.download = file.name - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - URL.revokeObjectURL(blobUrl) + await api.downloadFile(file.url, file.name) } catch { toast.error('下载失败') } @@ -855,6 +1029,126 @@ export default function BriefConfigPage() { + {/* ===== 任务管理区块 ===== */} + + + + + 项目任务 + + {projectTasks.length} 个任务 + + + + + + {projectTasks.length > 0 ? ( +
+ {projectTasks.map((task) => { + const uiState = mapTaskToUI(task) + return ( +
+
+
+ + {task.creator.name.charAt(0)} + +
+
+
+ {task.name} +
+
+ 达人: {task.creator.name} + 创建: {task.created_at.split('T')[0]} +
+
+
+ + {uiState.statusLabel} + +
+ ) + })} +
+ ) : ( +
+ +

暂无任务

+

点击「分配达人」创建任务

+
+ )} +
+
+ + {/* 达人选择弹窗 */} + setShowCreatorModal(false)} + title="选择达人" + size="md" + > +
+

+ 选择一位达人为其创建任务。同一达人可多次选择(用于拍摄多个视频)。 +

+ {availableCreators.length > 0 ? ( +
+ {availableCreators.map((creator) => { + const taskCount = projectTasks.filter(t => t.creator.id === creator.id).length + return ( + + ) + })} +
+ ) : ( +
+ +

暂无可选达人

+

请先在「达人管理」中添加达人

+
+ )} +
+
+ {/* ===== 第二部分:代理商配置(可编辑)===== */}
@@ -1035,12 +1329,14 @@ export default function BriefConfigPage() {
{sp.content}
+ {/* 最少卖点数配置 */} +
+
+
+

最少体现卖点数

+

+ AI 审核时按此数量计算覆盖率评分,不设置则默认要求覆盖全部核心+推荐卖点 +

+
+
+ + + {minSellingPoints ?? '-'} + + + {minSellingPoints !== null && ( + + )} +
+
+
@@ -1075,7 +1413,7 @@ export default function BriefConfigPage() { - {rules.name}平台规则 + {platformRuleName || platform?.name || ''}平台规则 {previewFile && ( @@ -1337,7 +1676,7 @@ export default function BriefConfigPage() { {/* 代理商文档预览弹窗 */} { setPreviewAgencyFile(null); setPreviewAgencyUrl(null) }} + onClose={() => { setPreviewAgencyFile(null); if (previewAgencyUrl) { URL.revokeObjectURL(previewAgencyUrl); setPreviewAgencyUrl(null) } }} title={previewAgencyFile?.name || '文件预览'} size="lg" > @@ -1374,7 +1713,7 @@ export default function BriefConfigPage() { )}
- {previewAgencyFile && ( diff --git a/frontend/app/agency/briefs/page.tsx b/frontend/app/agency/briefs/page.tsx index e48d9cf..36062d3 100644 --- a/frontend/app/agency/briefs/page.tsx +++ b/frontend/app/agency/briefs/page.tsx @@ -204,8 +204,8 @@ export default function AgencyBriefsPage() { {/* 页面标题 */}
-

Brief 配置

-

配置项目 Brief,设置审核规则

+

任务配置

+

配置项目 Brief,分配达人任务

diff --git a/frontend/app/agency/creators/page.tsx b/frontend/app/agency/creators/page.tsx index 6b27b7c..53b02f2 100644 --- a/frontend/app/agency/creators/page.tsx +++ b/frontend/app/agency/creators/page.tsx @@ -477,15 +477,36 @@ export default function AgencyCreatorsPage() { setOpenMenuId(null) } - // 确认分配项目 - const handleConfirmAssign = () => { + // 确认分配项目(创建任务) + const handleConfirmAssign = async () => { const projectList = USE_MOCK ? mockProjects : projects - if (assignModal.creator && selectedProject) { - const project = projectList.find(p => p.id === selectedProject) + if (!assignModal.creator || !selectedProject) return + + const project = projectList.find(p => p.id === selectedProject) + + if (USE_MOCK) { toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}」`) + setAssignModal({ open: false, creator: null }) + setSelectedProject('') + return + } + + setSubmitting(true) + try { + await api.createTask({ + project_id: selectedProject, + creator_id: assignModal.creator.creatorId, + }) + toast.success(`已将达人「${assignModal.creator.name}」分配到项目「${project?.name}」`) + setAssignModal({ open: false, creator: null }) + setSelectedProject('') + await fetchData() // 刷新列表 + } catch (err) { + const message = err instanceof Error ? err.message : '分配失败' + toast.error(message) + } finally { + setSubmitting(false) } - setAssignModal({ open: false, creator: null }) - setSelectedProject('') } // 骨架屏 @@ -761,43 +782,45 @@ export default function AgencyCreatorsPage() { {creator.joinedAt} -
- - {/* 下拉菜单 */} - {openMenuId === creator.id && ( -
- - - -
- )} + + 分配项目 + + +
+ + {openMenuId === creator.id && ( +
+ +
+ )} +
@@ -1042,8 +1065,8 @@ export default function AgencyCreatorsPage() { -
diff --git a/frontend/app/agency/page.tsx b/frontend/app/agency/page.tsx index f207120..89d5427 100644 --- a/frontend/app/agency/page.tsx +++ b/frontend/app/agency/page.tsx @@ -96,8 +96,12 @@ function getTaskUrgencyLevel(task: TaskResponse): string { } function getTaskUrgencyTitle(task: TaskResponse): string { - const type = task.stage.includes('video') ? '视频' : '脚本' - return `${task.creator.name}${type} - ${task.name}` + return `${task.project.name} · ${task.name}` +} + +function getPlatformLabel(platform?: string | null): string { + const map: Record = { douyin: '抖音', xiaohongshu: '小红书', bilibili: 'B站', kuaishou: '快手' } + return platform ? (map[platform] || platform) : '' } function getTaskTimeAgo(dateStr: string): string { @@ -182,13 +186,19 @@ export default function AgencyDashboard() { if (loading || !stats) return // Build urgent todos from pending tasks (top 3) - const urgentTodos = pendingTasks.slice(0, 3).map(task => ({ - id: task.id, - title: getTaskUrgencyTitle(task), - description: task.project.name, - time: getTaskTimeAgo(task.updated_at), - level: getTaskUrgencyLevel(task), - })) + const urgentTodos = pendingTasks.slice(0, 3).map(task => { + const type = task.stage.includes('video') ? '视频' : '脚本' + const platformLabel = getPlatformLabel(task.project.platform) + const brandLabel = task.project.brand_name || '' + const desc = [task.creator.name, brandLabel, platformLabel, type].filter(Boolean).join(' · ') + return { + id: task.id, + title: getTaskUrgencyTitle(task), + description: desc, + time: getTaskTimeAgo(task.updated_at), + level: getTaskUrgencyLevel(task), + } + }) return (
@@ -316,6 +326,9 @@ export default function AgencyDashboard() { {project.brand_name && ( ({project.brand_name}) )} + {project.platform && ( + {getPlatformLabel(project.platform)} + )}
{project.task_count} 个任务 @@ -356,6 +369,7 @@ export default function AgencyDashboard() { 类型 达人 品牌 + 平台 AI评分 提交时间 操作 @@ -369,7 +383,9 @@ export default function AgencyDashboard() {
-
{task.name}
+
+
{task.project.name} · {task.name}
+
{task.is_appeal && ( 申诉 @@ -385,7 +401,8 @@ export default function AgencyDashboard() { {task.creator.name} - {task.project.brand_name || task.project.name} + {task.project.brand_name || '-'} + {getPlatformLabel(task.project.platform) || '-'} {aiScore != null ? ( - 暂无待审核任务 + 暂无待审核任务 )} diff --git a/frontend/app/agency/review/[id]/page.tsx b/frontend/app/agency/review/[id]/page.tsx index 14b8ec7..6cf0fef 100644 --- a/frontend/app/agency/review/[id]/page.tsx +++ b/frontend/app/agency/review/[id]/page.tsx @@ -1,577 +1,60 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useEffect, useState } from 'react' import { useRouter, useParams } from 'next/navigation' -import { useToast } from '@/components/ui/Toast' -import { ArrowLeft, Play, Pause, AlertTriangle, Shield, Radio, Loader2 } from 'lucide-react' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' -import { Button } from '@/components/ui/Button' -import { SuccessTag, WarningTag, ErrorTag } from '@/components/ui/Tag' -import { Modal, ConfirmModal } from '@/components/ui/Modal' -import { ReviewSteps, getAgencyReviewSteps } from '@/components/ui/ReviewSteps' import { api } from '@/lib/api' import { USE_MOCK } from '@/contexts/AuthContext' -import { useSSE } from '@/contexts/SSEContext' -import type { TaskResponse, AIReviewResult } from '@/types/task' +import { Loader2 } from 'lucide-react' -// ==================== Mock 数据 ==================== -const mockTask: TaskResponse = { - id: 'task-001', - name: '夏日护肤推广', - sequence: 1, - stage: 'script_agency_review', - project: { id: 'proj-001', name: 'XX品牌618推广', brand_name: 'XX护肤品牌' }, - agency: { id: 'ag-001', name: '优创代理' }, - creator: { id: 'cr-001', name: '小美护肤' }, - script_ai_score: 85, - script_ai_result: { - score: 85, - violations: [ - { - type: '违禁词', - content: '效果最好', - severity: 'high', - suggestion: '建议替换为"效果显著"', - timestamp: 15.5, - source: 'speech', - }, - { - type: '竞品露出', - content: '疑似竞品Logo', - severity: 'high', - suggestion: '需人工确认是否为竞品露出', - timestamp: 42.0, - source: 'visual', - }, - ], - soft_warnings: [ - { type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' }, - ], - summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认', - }, - video_ai_score: 85, - video_ai_result: { - score: 85, - violations: [ - { - type: '违禁词', - content: '效果最好', - severity: 'high', - suggestion: '建议替换为"效果显著"', - timestamp: 15.5, - source: 'speech', - }, - { - type: '竞品露出', - content: '疑似竞品Logo', - severity: 'high', - suggestion: '需人工确认是否为竞品露出', - timestamp: 42.0, - source: 'visual', - }, - ], - soft_warnings: [ - { type: '油腻预警', content: '达人表情过于夸张,建议检查', suggestion: '软性风险仅作提示' }, - ], - summary: '视频整体合规,发现2处硬性问题和1处舆情提示需人工确认', - }, - appeal_count: 0, - is_appeal: false, - created_at: '2026-02-03T10:30:00Z', - updated_at: '2026-02-03T10:35:00Z', -} - -// ==================== 工具函数 ==================== - -function getReviewStepStatus(task: TaskResponse): string { - if (task.stage.includes('agency_review')) return 'agent_reviewing' - if (task.stage.includes('brand_review')) return 'brand_reviewing' - if (task.stage === 'completed') return 'completed' - return 'agent_reviewing' -} - -function formatTimestamp(seconds: number): string { - const mins = Math.floor(seconds / 60) - const secs = Math.floor(seconds % 60) - return `${mins}:${secs.toString().padStart(2, '0')}` -} - -// ==================== 子组件 ==================== - -function ReviewProgressBar({ taskStatus }: { taskStatus: string }) { - const steps = getAgencyReviewSteps(taskStatus) - const currentStep = steps.find(s => s.status === 'current') - - return ( - - -
- 审核流程 - - 当前:{currentStep?.label || '代理商审核'} - -
- -
-
- ) -} - -function RiskLevelTag({ level }: { level: string }) { - if (level === 'high') return 高风险 - if (level === 'medium') return 中风险 - return 低风险 -} - -function ReviewSkeleton() { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ) -} - -// ==================== 主页面 ==================== - -export default function ReviewPage() { +/** + * Redirect page: detects task type (script/video) and redirects + * to the appropriate review detail page. + */ +export default function ReviewRedirectPage() { const router = useRouter() const params = useParams() - const toast = useToast() const taskId = params.id as string - const { subscribe } = useSSE() + const [error, setError] = useState('') - const [task, setTask] = useState(null) - const [loading, setLoading] = useState(true) - const [submitting, setSubmitting] = useState(false) - const [isPlaying, setIsPlaying] = useState(false) - const [showApproveModal, setShowApproveModal] = useState(false) - const [showRejectModal, setShowRejectModal] = useState(false) - const [showForcePassModal, setShowForcePassModal] = useState(false) - const [rejectReason, setRejectReason] = useState('') - const [forcePassReason, setForcePassReason] = useState('') - const [saveAsException, setSaveAsException] = useState(false) - const [checkedViolations, setCheckedViolations] = useState>({}) - - const loadTask = useCallback(async () => { + useEffect(() => { if (USE_MOCK) { - setTask(mockTask) - setLoading(false) + router.replace(`/agency/review/script/${taskId}`) return } - try { - const data = await api.getTask(taskId) - setTask(data) - } catch (err) { - console.error('Failed to load task:', err) - toast.error('加载任务失败') - } finally { - setLoading(false) - } - }, [taskId, toast]) - - useEffect(() => { - loadTask() - }, [loadTask]) - - useEffect(() => { - const unsub1 = subscribe('task_updated', (data: any) => { - if (data?.task_id === taskId) loadTask() - }) - const unsub2 = subscribe('review_completed', (data: any) => { - if (data?.task_id === taskId) loadTask() - }) - return () => { unsub1(); unsub2() } - }, [subscribe, taskId, loadTask]) - - if (loading || !task) return - - // Determine if this is script or video review - const isVideoReview = task.stage.includes('video') - const aiResult: AIReviewResult | null | undefined = isVideoReview ? task.video_ai_result : task.script_ai_result - const aiScore = isVideoReview ? task.video_ai_score : task.script_ai_score - - const violations = aiResult?.violations || [] - const softWarnings = aiResult?.soft_warnings || [] - const aiSummary = aiResult?.summary || '暂无 AI 分析总结' - - const handleApprove = async () => { - setSubmitting(true) - try { - if (!USE_MOCK) { - if (isVideoReview) { - await api.reviewVideo(taskId, { action: 'pass' }) - } else { - await api.reviewScript(taskId, { action: 'pass' }) - } + async function redirect() { + try { + const task = await api.getTask(taskId) + const isVideo = task.stage.includes('video') + const path = isVideo + ? `/agency/review/video/${taskId}` + : `/agency/review/script/${taskId}` + router.replace(path) + } catch { + setError('加载任务失败,请返回重试') } - toast.success('审核已通过') - setShowApproveModal(false) - router.push('/agency/review') - } catch (err) { - console.error('Failed to approve:', err) - toast.error('操作失败,请重试') - } finally { - setSubmitting(false) } + redirect() + }, [taskId, router]) + + if (error) { + return ( +
+

{error}

+ +
+ ) } - const handleReject = async () => { - if (!rejectReason.trim()) { - toast.error('请填写驳回原因') - return - } - setSubmitting(true) - try { - if (!USE_MOCK) { - if (isVideoReview) { - await api.reviewVideo(taskId, { action: 'reject', comment: rejectReason }) - } else { - await api.reviewScript(taskId, { action: 'reject', comment: rejectReason }) - } - } - toast.success('已驳回') - setShowRejectModal(false) - router.push('/agency/review') - } catch (err) { - console.error('Failed to reject:', err) - toast.error('操作失败,请重试') - } finally { - setSubmitting(false) - } - } - - const handleForcePass = async () => { - if (!forcePassReason.trim()) { - toast.error('请填写强制通过原因') - return - } - setSubmitting(true) - try { - if (!USE_MOCK) { - if (isVideoReview) { - await api.reviewVideo(taskId, { action: 'force_pass', comment: forcePassReason }) - } else { - await api.reviewScript(taskId, { action: 'force_pass', comment: forcePassReason }) - } - } - toast.success('已强制通过') - setShowForcePassModal(false) - router.push('/agency/review') - } catch (err) { - console.error('Failed to force pass:', err) - toast.error('操作失败,请重试') - } finally { - setSubmitting(false) - } - } - - // 时间线标记 - const timelineMarkers = [ - ...violations.filter(v => v.timestamp != null).map(v => ({ time: v.timestamp!, type: 'hard' as const })), - ].sort((a, b) => a.time - b.time) - - const maxTime = Math.max(120, ...timelineMarkers.map(m => m.time + 10)) - return ( -
- {/* 顶部导航 */} -
- -
-

{task.name}

-

- {task.creator.name} · {task.project.brand_name || task.project.name} · {isVideoReview ? '视频审核' : '脚本审核'} -

-
- {task.is_appeal && ( - - 申诉重审 - - )} -
- - {/* 申诉理由 */} - {task.is_appeal && task.appeal_reason && ( - - -

申诉理由

-

{task.appeal_reason}

-
-
- )} - - {/* 审核流程进度条 */} - - -
- {/* 左侧:视频/脚本播放器 (3/5) */} -
- - - {isVideoReview ? ( -
- -
- ) : ( -
-
-

脚本预览区域

-

{task.script_file_name || '脚本文件'}

-
-
- )} - - {/* 智能进度条(仅视频且有时间标记时显示) */} - {isVideoReview && timelineMarkers.length > 0 && ( -
-
智能进度条(点击跳转)
-
- {timelineMarkers.map((marker, idx) => ( -
-
- 0:00 - {formatTimestamp(maxTime)} -
-
- - - 硬性问题 - - - - 舆情提示 - - - - 卖点覆盖 - -
-
- )} -
-
- - {/* AI 分析总结 */} - - -
- AI 分析总结 - {aiScore != null && ( - = 80 ? 'text-accent-green' : aiScore >= 60 ? 'text-yellow-400' : 'text-accent-coral'}`}> - {aiScore}分 - - )} -
-

{aiSummary}

-
-
-
- - {/* 右侧:AI 检查单 (2/5) */} -
- {/* 硬性合规 */} - - - - - 硬性合规 ({violations.length}) - - - - {violations.length > 0 ? violations.map((v, idx) => { - const key = `v-${idx}` - return ( -
-
- setCheckedViolations((prev) => ({ ...prev, [key]: !prev[key] }))} - className="mt-1 accent-accent-indigo" - /> -
-
- {v.type} - {v.timestamp != null && ( - {formatTimestamp(v.timestamp)} - )} -
-

「{v.content}」

-

{v.suggestion}

-
-
-
- ) - }) : ( -
无硬性违规
- )} -
-
- - {/* 舆情雷达 */} - {softWarnings.length > 0 && ( - - - - - 舆情雷达(仅提示) - - - - {softWarnings.map((w, idx) => ( -
-
- {w.type} -
-

{w.content}

-

软性风险仅作提示,不强制拦截

-
- ))} -
-
- )} -
-
- - {/* 底部决策栏 */} - - -
-
- 已检查 {Object.values(checkedViolations).filter(Boolean).length}/{violations.length} 个问题 -
-
- - - -
-
-
-
- - {/* 通过确认弹窗 */} - setShowApproveModal(false)} - onConfirm={handleApprove} - title="确认通过" - message={`确定要通过此${isVideoReview ? '视频' : '脚本'}的审核吗?通过后达人将收到通知。`} - confirmText="确认通过" - /> - - {/* 驳回弹窗 */} - setShowRejectModal(false)} title="驳回审核"> -
-

请填写驳回原因,已勾选的问题将自动打包发送给达人。

-
-

- 已选问题 ({Object.values(checkedViolations).filter(Boolean).length}) -

- {violations.filter((_, idx) => checkedViolations[`v-${idx}`]).map((v, idx) => ( -
- {v.type}: {v.content}
- ))} - {Object.values(checkedViolations).filter(Boolean).length === 0 && ( -
未选择任何问题
- )} -
-
- -