From 0b3dfa3c52b43875733e656def7f2791c3e1576f Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 11 Feb 2026 20:24:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20=E5=AE=A1=E6=A0=B8=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E9=A9=B3=E5=9B=9E=20+=20=E5=8A=9F=E6=95=88=E8=AF=8D?= =?UTF-8?q?=E5=8F=AF=E9=85=8D=E7=BD=AE=20+=20UI=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 自动驳回:法规/品牌安全 HIGH 违规或总分<40 自动打回上传阶段 - 功效词可配置:从硬编码改为品牌方在规则页面自行管理 - 驳回通知:AI 驳回时只通知达人,含具体原因 - 达人端:脚本/视频页面展示 AI 驳回原因 + 重新上传入口 - 规则页面:新增"功效词"分类 - 种子数据:新增 6 条默认功效词 - 其他:代理商管理下拉修复、AI 配置模型列表扩展、视觉模型标签修正、规则编辑放开限制 Co-Authored-By: Claude Opus 4.6 --- backend/app/api/scripts.py | 17 +- backend/app/api/tasks.py | 230 +++++++++++------- backend/app/services/task_service.py | 57 ++++- backend/scripts/seed.py | 9 +- frontend/app/brand/agencies/page.tsx | 101 +++++--- frontend/app/brand/ai-config/page.tsx | 134 ++++++++-- frontend/app/brand/rules/page.tsx | 61 ++--- .../app/creator/task/[id]/script/page.tsx | 28 ++- frontend/app/creator/task/[id]/video/page.tsx | 26 +- frontend/types/task.ts | 3 + 10 files changed, 476 insertions(+), 190 deletions(-) diff --git a/backend/app/api/scripts.py b/backend/app/api/scripts.py index 6cb4030..977d9d5 100644 --- a/backend/app/api/scripts.py +++ b/backend/app/api/scripts.py @@ -38,8 +38,8 @@ router = APIRouter(prefix="/scripts", tags=["scripts"]) # 内置违禁词库(广告极限词) ABSOLUTE_WORDS = ["最好", "第一", "最佳", "绝对", "100%"] -# 功效词库(医疗/功效宣称) -EFFICACY_WORDS = ["根治", "治愈", "治疗", "药效", "疗效", "特效"] +# 默认功效词库(品牌方未配置时的兜底) +DEFAULT_EFFICACY_WORDS = ["根治", "治愈", "治疗", "药效", "疗效", "特效"] # 广告语境关键词(用于判断是否为广告场景) AD_CONTEXT_KEYWORDS = ["产品", "购买", "销量", "品质", "推荐", "价格", "优惠", "促销"] @@ -282,7 +282,12 @@ async def review_script( # 获取品牌方配置的所有规则数据 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) + all_tenant_words = await get_forbidden_words_for_tenant(x_tenant_id, db) + # 分离功效词和普通违禁词 + efficacy_words = [w["word"] for w in all_tenant_words if w.get("category") == "功效词"] + if not efficacy_words: + efficacy_words = list(DEFAULT_EFFICACY_WORDS) + tenant_forbidden_words = [w for w in all_tenant_words if w.get("category") != "功效词"] 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, @@ -310,8 +315,8 @@ async def review_script( )) start = pos + 1 - # 1b. 功效词检测 - for word in EFFICACY_WORDS: + # 1b. 功效词检测(从品牌方配置加载,未配置则用默认列表) + for word in efficacy_words: if word in whitelist: continue start = 0 @@ -372,7 +377,7 @@ async def review_script( start = pos + 1 # ===== Step 2: 平台规则检测 (platform) ===== - already_checked = set(ABSOLUTE_WORDS + [w["word"] for w in tenant_forbidden_words]) + already_checked = set(ABSOLUTE_WORDS + efficacy_words + [w["word"] for w in tenant_forbidden_words]) platform_forbidden_words: list[str] = [] platform_restricted_words: list[dict] = [] platform_content_requirements: list[str] = [] diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 4b0a9f3..4b04b3b 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -132,53 +132,80 @@ async def _run_script_ai_review(task_id: str, tenant_id: str): ) await db.commit() - logger.info(f"任务 {task_id} AI 审核完成,得分: {result.score}") + ai_auto_rejected = task.script_ai_result and task.script_ai_result.get("ai_auto_rejected") + logger.info(f"任务 {task_id} AI 审核完成,得分: {result.score},自动驳回: {ai_auto_rejected}") - # 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}, + if ai_auto_rejected: + # AI 自动驳回:只通知达人 + try: + creator_result = await db.execute( + select(Creator).where(Creator.id == task.creator_id) ) - 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="系统", + creator_obj = creator_result.scalar_one_or_none() + if creator_obj: + reject_reason = task.script_ai_result.get("ai_reject_reason", "") + await notify_task_updated( + task_id=task.id, + user_ids=[creator_obj.user_id], + data={"action": "ai_auto_rejected", "stage": task.stage.value, "score": result.score}, + ) + await create_message( + db=db, + user_id=creator_obj.user_id, + type="task", + title="脚本未通过 AI 审核", + content=f"任务「{task.name}」未通过 AI 审核({result.score} 分),原因:{reject_reason}。请修改后重新上传。", + related_task_id=task.id, + sender_name="系统", + ) + await db.commit() + except Exception: + pass + else: + # 正常通过:SSE 通知达人和代理商 + 消息通知代理商 + try: + user_ids = [] + creator_result = await db.execute( + select(Creator).where(Creator.id == task.creator_id) ) - await db.commit() - except Exception: - pass + 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: @@ -307,53 +334,80 @@ async def _run_video_ai_review(task_id: str, tenant_id: str): ) await db.commit() - logger.info(f"任务 {task_id} 视频 AI 审核完成,得分: {video_score}") + ai_auto_rejected = task.video_ai_result and task.video_ai_result.get("ai_auto_rejected") + logger.info(f"任务 {task_id} 视频 AI 审核完成,得分: {video_score},自动驳回: {ai_auto_rejected}") - # 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}, + if ai_auto_rejected: + # AI 自动驳回:只通知达人 + try: + creator_result = await db.execute( + select(Creator).where(Creator.id == task.creator_id) ) - 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="系统", + creator_obj = creator_result.scalar_one_or_none() + if creator_obj: + reject_reason = task.video_ai_result.get("ai_reject_reason", "") + await notify_task_updated( + task_id=task.id, + user_ids=[creator_obj.user_id], + data={"action": "ai_auto_rejected", "stage": task.stage.value, "score": video_score}, + ) + await create_message( + db=db, + user_id=creator_obj.user_id, + type="task", + title="视频未通过 AI 审核", + content=f"任务「{task.name}」视频未通过 AI 审核({video_score} 分),原因:{reject_reason}。请修改后重新上传。", + related_task_id=task.id, + sender_name="系统", + ) + await db.commit() + except Exception: + pass + else: + # 正常通过:SSE 通知达人和代理商 + 消息通知代理商 + try: + user_ids = [] + creator_result = await db.execute( + select(Creator).where(Creator.id == task.creator_id) ) - await db.commit() - except Exception: - pass + 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: diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py index ad7b7bf..257db5b 100644 --- a/backend/app/services/task_service.py +++ b/backend/app/services/task_service.py @@ -195,6 +195,42 @@ async def upload_video( return task +AI_AUTO_REJECT_SCORE = 40 + + +def _check_ai_auto_reject(score: int, result: dict) -> tuple[bool, str]: + """ + 判断 AI 审核结果是否应自动驳回 + + 触发条件(任一): + 1. 法规合规维度存在 HIGH 级违规(违禁词/功效词) + 2. 品牌安全维度存在 HIGH 级违规(竞品提及) + 3. 总分 < 40 + """ + reasons = [] + violations = result.get("violations", []) + + # 条件1: 法规 HIGH + high_legal = [v for v in violations if v.get("dimension") == "legal" and v.get("severity") == "high"] + if high_legal: + words = [v.get("content", "") for v in high_legal[:5]] + reasons.append(f"法规违规:{', '.join(words)}") + + # 条件2: 品牌安全 HIGH + high_brand = [v for v in violations if v.get("dimension") == "brand_safety" and v.get("severity") == "high"] + if high_brand: + words = [v.get("content", "") for v in high_brand[:5]] + reasons.append(f"品牌安全违规:{', '.join(words)}") + + # 条件3: 总分过低 + if score < AI_AUTO_REJECT_SCORE: + reasons.append(f"综合评分 {score} 分,低于合格线 {AI_AUTO_REJECT_SCORE} 分") + + if reasons: + return True, ";".join(reasons) + return False, "" + + async def complete_ai_review( db: AsyncSession, task: Task, @@ -206,9 +242,16 @@ async def complete_ai_review( 完成 AI 审核 - 更新 AI 审核结果 - - 状态流转到代理商审核 + - 自动驳回:法规/品牌安全 HIGH 违规或总分 < 40 → 回到上传阶段 + - 正常:流转到代理商审核 """ now = datetime.now(timezone.utc) + auto_rejected, reject_reason = _check_ai_auto_reject(score, result) + + # 将自动驳回信息写入 result,前端可据此展示 + if auto_rejected: + result["ai_auto_rejected"] = True + result["ai_reject_reason"] = reject_reason if review_type == "script": if task.stage != TaskStage.SCRIPT_AI_REVIEW: @@ -217,7 +260,11 @@ async def complete_ai_review( task.script_ai_score = score task.script_ai_result = result task.script_ai_reviewed_at = now - task.stage = TaskStage.SCRIPT_AGENCY_REVIEW + + if auto_rejected: + task.stage = TaskStage.SCRIPT_UPLOAD + else: + task.stage = TaskStage.SCRIPT_AGENCY_REVIEW elif review_type == "video": if task.stage != TaskStage.VIDEO_AI_REVIEW: @@ -226,7 +273,11 @@ async def complete_ai_review( task.video_ai_score = score task.video_ai_result = result task.video_ai_reviewed_at = now - task.stage = TaskStage.VIDEO_AGENCY_REVIEW + + if auto_rejected: + task.stage = TaskStage.VIDEO_UPLOAD + else: + task.stage = TaskStage.VIDEO_AGENCY_REVIEW else: raise ValueError(f"不支持的审核类型: {review_type}") diff --git a/backend/scripts/seed.py b/backend/scripts/seed.py index aaf9ddd..18e1047 100644 --- a/backend/scripts/seed.py +++ b/backend/scripts/seed.py @@ -403,10 +403,17 @@ async def seed_data() -> None: ForbiddenWord(id="FW100003", tenant_id=TENANT_ID, word="最好", category="绝对化用语", severity="medium"), ForbiddenWord(id="FW100004", tenant_id=TENANT_ID, word="第一", category="绝对化用语", severity="medium"), ForbiddenWord(id="FW100005", tenant_id=TENANT_ID, word="纯天然", category="虚假宣传", severity="medium"), + # 功效词(品牌方可自行增删) + ForbiddenWord(id="FW100006", tenant_id=TENANT_ID, word="根治", category="功效词", severity="high"), + ForbiddenWord(id="FW100007", tenant_id=TENANT_ID, word="治愈", category="功效词", severity="high"), + ForbiddenWord(id="FW100008", tenant_id=TENANT_ID, word="治疗", category="功效词", severity="high"), + ForbiddenWord(id="FW100009", tenant_id=TENANT_ID, word="药效", category="功效词", severity="high"), + ForbiddenWord(id="FW100010", tenant_id=TENANT_ID, word="疗效", category="功效词", severity="high"), + ForbiddenWord(id="FW100011", tenant_id=TENANT_ID, word="特效", category="功效词", severity="high"), ] db.add_all(forbidden_words) await db.flush() - print(" ✓ 违禁词已创建: 5 条") + print(" ✓ 违禁词已创建: 11 条(含 6 条功效词)") competitors = [ Competitor(id="CP100001", tenant_id=TENANT_ID, brand_id=BRAND_ID, name="安耐晒", keywords=["安耐晒", "ANESSA", "资生堂防晒"]), diff --git a/frontend/app/brand/agencies/page.tsx b/frontend/app/brand/agencies/page.tsx index ad78ec2..ffbf352 100644 --- a/frontend/app/brand/agencies/page.tsx +++ b/frontend/app/brand/agencies/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Card, CardContent } from '@/components/ui/Card' import { Button } from '@/components/ui/Button' import { Modal } from '@/components/ui/Modal' @@ -69,6 +69,30 @@ export default function AgenciesManagePage() { // 操作菜单状态 const [openMenuId, setOpenMenuId] = useState(null) + const [menuPos, setMenuPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }) + const menuRef = useRef(null) + + const handleToggleMenu = (agencyId: string, e: React.MouseEvent) => { + if (openMenuId === agencyId) { + setOpenMenuId(null) + return + } + const rect = e.currentTarget.getBoundingClientRect() + setMenuPos({ top: rect.bottom + 4, left: rect.right - 160 }) // 160 = menu width + setOpenMenuId(agencyId) + } + + // 点击外部关闭菜单 + useEffect(() => { + if (!openMenuId) return + const handleClickOutside = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpenMenuId(null) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [openMenuId]) // 删除确认弹窗状态 const [deleteModal, setDeleteModal] = useState<{ open: boolean; agency: AgencyDetail | null }>({ open: false, agency: null }) @@ -351,35 +375,13 @@ export default function AgenciesManagePage() { -
- - {openMenuId === agency.id && ( -
- - -
- )} -
+ ))} @@ -396,6 +398,38 @@ export default function AgenciesManagePage() { + {/* 操作菜单(fixed 定位,不受 overflow 裁剪) */} + {openMenuId && ( +
+ + +
+ )} + {/* 邀请代理商弹窗 */}
@@ -539,13 +573,6 @@ export default function AgenciesManagePage() {
- {/* 点击其他地方关闭菜单 */} - {openMenuId && ( -
setOpenMenuId(null)} - /> - )}
) } diff --git a/frontend/app/brand/ai-config/page.tsx b/frontend/app/brand/ai-config/page.tsx index be11561..11fdd46 100644 --- a/frontend/app/brand/ai-config/page.tsx +++ b/frontend/app/brand/ai-config/page.tsx @@ -34,21 +34,67 @@ const providerOptions: { value: AIProvider | string; label: string }[] = [ { value: 'moonshot', label: 'Moonshot' }, ] -// Mock 可用模型列表 +// 预设可用模型列表 const mockModels: Record = { text: [ + // Anthropic Claude { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' }, + { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }, { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' }, + // OpenAI { id: 'gpt-4o', name: 'GPT-4o' }, - { id: 'deepseek-chat', name: 'DeepSeek Chat' }, + { id: 'gpt-4o-mini', name: 'GPT-4o Mini' }, + { id: 'gpt-4-turbo', name: 'GPT-4 Turbo' }, + { id: 'o1', name: 'o1' }, + { id: 'o3-mini', name: 'o3-mini' }, + // Google + { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' }, + { id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' }, + { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' }, + // DeepSeek + { id: 'deepseek-chat', name: 'DeepSeek V3' }, + { id: 'deepseek-reasoner', name: 'DeepSeek R1' }, + // 通义千问 + { id: 'qwen-max', name: '通义千问 Max' }, + { id: 'qwen-plus', name: '通义千问 Plus' }, + { id: 'qwen-turbo', name: '通义千问 Turbo' }, + // 豆包 + { id: 'doubao-pro-256k', name: '豆包 Pro 256K' }, + { id: 'doubao-pro-32k', name: '豆包 Pro 32K' }, + // 智谱 + { id: 'glm-4-plus', name: 'GLM-4 Plus' }, + { id: 'glm-4', name: 'GLM-4' }, + // Moonshot + { id: 'moonshot-v1-128k', name: 'Moonshot V1 128K' }, + { id: 'moonshot-v1-32k', name: 'Moonshot V1 32K' }, ], vision: [ + // Anthropic Claude (原生多模态) { id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' }, + { id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }, + { id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4' }, + { id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' }, + // OpenAI { id: 'gpt-4o', name: 'GPT-4o' }, + { id: 'gpt-4o-mini', name: 'GPT-4o Mini' }, + // Google + { id: 'gemini-2.0-flash', name: 'Gemini 2.0 Flash' }, + { id: 'gemini-2.0-pro', name: 'Gemini 2.0 Pro' }, + { id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro' }, + // 通义千问 VL + { id: 'qwen-vl-max', name: '通义千问 VL Max' }, + { id: 'qwen-vl-plus', name: '通义千问 VL Plus' }, + // 智谱 + { id: 'glm-4v-plus', name: 'GLM-4V Plus' }, + { id: 'glm-4v', name: 'GLM-4V' }, ], audio: [ { id: 'whisper-large-v3', name: 'Whisper Large V3' }, + { id: 'whisper-large-v3-turbo', name: 'Whisper Large V3 Turbo' }, { id: 'whisper-medium', name: 'Whisper Medium' }, + { id: 'sensevoice-v1', name: 'SenseVoice V1 (阿里)' }, + { id: 'paraformer-realtime-v2', name: 'Paraformer V2 (阿里)' }, ], } @@ -83,6 +129,9 @@ export default function AIConfigPage() { const [maxTokens, setMaxTokens] = useState(2000) const [availableModels, setAvailableModels] = useState>(mockModels) + const [customLlmModel, setCustomLlmModel] = useState('') + const [customVisionModel, setCustomVisionModel] = useState('') + const [customAsrModel, setCustomAsrModel] = useState('') const [testResults, setTestResults] = useState>({ text: { status: 'idle' }, @@ -101,9 +150,26 @@ export default function AIConfigPage() { setBaseUrl(config.base_url) setApiKey('') // API key is masked, don't fill it setIsConfigured(config.is_configured) - setLlmModel(config.models.text) - setVisionModel(config.models.vision) - setAsrModel(config.models.audio) + const models = availableModels + // 如果后端返回的模型不在预设列表中,设为自定义 + if (config.models.text && !(models.text || []).some(m => m.id === config.models.text)) { + setLlmModel('__custom__') + setCustomLlmModel(config.models.text) + } else { + setLlmModel(config.models.text) + } + if (config.models.vision && !(models.vision || []).some(m => m.id === config.models.vision)) { + setVisionModel('__custom__') + setCustomVisionModel(config.models.vision) + } else { + setVisionModel(config.models.vision) + } + if (config.models.audio && !(models.audio || []).some(m => m.id === config.models.audio)) { + setAsrModel('__custom__') + setCustomAsrModel(config.models.audio) + } else { + setAsrModel(config.models.audio) + } setTemperature(config.parameters.temperature) setMaxTokens(config.parameters.max_tokens) if (config.available_models && Object.keys(config.available_models).length > 0) { @@ -126,6 +192,10 @@ export default function AIConfigPage() { useEffect(() => { loadConfig() }, [loadConfig]) + // 获取实际使用的模型 ID(自定义时用输入值) + const getActualModel = (selected: string, custom: string) => + selected === '__custom__' ? custom : selected + const handleTestConnection = async () => { setTestResults({ text: { status: 'testing' }, @@ -148,7 +218,7 @@ export default function AIConfigPage() { provider: provider as AIProvider, base_url: baseUrl, api_key: apiKey || '***', // use existing key if not changed - models: { text: llmModel, vision: visionModel, audio: asrModel }, + models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) }, }) const newResults: Record = {} for (const [key, r] of Object.entries(result.results)) { @@ -184,7 +254,7 @@ export default function AIConfigPage() { provider: provider as AIProvider, base_url: baseUrl, api_key: apiKey || '***', - models: { text: llmModel, vision: visionModel, audio: asrModel }, + models: { text: getActualModel(llmModel, customLlmModel), vision: getActualModel(visionModel, customVisionModel), audio: getActualModel(asrModel, customAsrModel) }, parameters: { temperature, max_tokens: maxTokens }, }) } @@ -304,32 +374,52 @@ export default function AIConfigPage() { -

用于 Brief 解析、语义分析、报告生成

+ {llmModel === '__custom__' && ( + setCustomLlmModel(e.target.value)} + placeholder="输入模型 ID,如 deepseek-chat" + /> + )} +

用于 Brief 解析、脚本语义审核、卖点匹配分析

- {/* 视频分析模型 */} + {/* 视觉理解模型 */}
- 视频分析模型 (Vision) + 视觉理解模型 (Vision) {getTestStatusIcon('vision')}
-

用于画面语义分析、场景/风险识别

+ {visionModel === '__custom__' && ( + setCustomVisionModel(e.target.value)} + placeholder="输入模型 ID,如 gpt-4o" + /> + )} +

用于脚本文档中的图片审核(竞品 logo、违规画面识别)及视频帧分析

{/* 音频解析模型 */} @@ -342,12 +432,22 @@ export default function AIConfigPage() { + {asrModel === '__custom__' && ( + setCustomAsrModel(e.target.value)} + placeholder="输入模型 ID,如 whisper-large-v3" + /> + )}

用于语音转文字、口播内容提取

diff --git a/frontend/app/brand/rules/page.tsx b/frontend/app/brand/rules/page.tsx index 8cac998..a6614ff 100644 --- a/frontend/app/brand/rules/page.tsx +++ b/frontend/app/brand/rules/page.tsx @@ -119,6 +119,7 @@ const mockWhitelist: WhitelistResponse[] = [ const categoryOptions = [ { value: '极限词', label: '极限词' }, + { value: '功效词', label: '功效词' }, { value: '虚假宣称', label: '虚假宣称' }, { value: '价格欺诈', label: '价格欺诈' }, { value: '平台规则', label: '平台规则' }, @@ -720,9 +721,9 @@ export default function RulesPage() { type="button" onClick={() => viewRuleDetail(rule)} className="p-1.5 rounded-lg text-text-tertiary hover:text-accent-indigo hover:bg-accent-indigo/10 transition-colors" - title={rule.status === 'draft' ? '确认规则' : '查看详情'} + title="编辑规则" > - {rule.status === 'draft' ? : } + - )} + ))} - {selectedRule.status === 'draft' && ( -
- setEditingForbiddenInput(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addForbiddenWord() } }} - placeholder="添加违禁词..." - className="flex-1 px-3 py-1.5 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-indigo" - /> - -
- )} +
+ setEditingForbiddenInput(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addForbiddenWord() } }} + placeholder="添加违禁词..." + className="flex-1 px-3 py-1.5 border border-border-subtle rounded-lg bg-bg-elevated text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-accent-indigo" + /> + +
{/* 限制词 */} @@ -1116,11 +1113,9 @@ export default function RulesPage() {
{req} - {selectedRule.status === 'draft' && ( - - )} +
))} @@ -1145,14 +1140,8 @@ export default function RulesPage() { {/* 操作按钮 */}
- {selectedRule.status === 'draft' && ( - - )} {selectedRule.status === 'active' && (
)} diff --git a/frontend/app/creator/task/[id]/script/page.tsx b/frontend/app/creator/task/[id]/script/page.tsx index 25ffa96..d88fbc4 100644 --- a/frontend/app/creator/task/[id]/script/page.tsx +++ b/frontend/app/creator/task/[id]/script/page.tsx @@ -35,6 +35,8 @@ type ScriptTaskUI = { brandName: string scriptStatus: string scriptFile: string | null + aiAutoRejected?: boolean + aiRejectReason?: string aiResult: null | { score: number dimensions?: ReviewDimensions @@ -56,8 +58,11 @@ type BriefUI = { function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI { const stage = task.stage let status = 'pending_upload' + const aiAutoRejected = task.script_ai_result?.ai_auto_rejected === true switch (stage) { - case 'script_upload': status = 'pending_upload'; break + case 'script_upload': + status = aiAutoRejected ? 'ai_rejected' : 'pending_upload' + break case 'script_ai_review': status = 'ai_reviewing'; break case 'script_agency_review': status = 'agent_reviewing'; break case 'script_brand_review': status = 'brand_reviewing'; break @@ -99,6 +104,8 @@ function mapApiToScriptUI(task: TaskResponse): ScriptTaskUI { brandName: task.project?.brand_name || '', scriptStatus: status, scriptFile: task.script_file_name || null, + aiAutoRejected, + aiRejectReason: task.script_ai_result?.ai_reject_reason, aiResult, agencyReview, brandReview, @@ -596,7 +603,7 @@ export default function CreatorScriptPage() { const getStatusDisplay = () => { const map: Record = { - pending_upload: '待上传脚本', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成', + pending_upload: '待上传脚本', ai_rejected: 'AI 审核未通过', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成', agent_reviewing: '代理商审核中', agent_rejected: '代理商驳回', brand_reviewing: '品牌方终审中', brand_passed: '审核通过', brand_rejected: '品牌方驳回', } @@ -622,6 +629,23 @@ export default function CreatorScriptPage() { {task.scriptStatus === 'pending_upload' && } + {task.scriptStatus === 'ai_rejected' && ( + <> + + +
+ +
+

AI 审核未通过,请修改后重新上传

+ {task.aiRejectReason &&

{task.aiRejectReason}

} +
+
+
+
+ + + + )} {task.scriptStatus === 'ai_reviewing' && } {task.scriptStatus === 'ai_result' && <>} {task.scriptStatus === 'agent_reviewing' && <>} diff --git a/frontend/app/creator/task/[id]/video/page.tsx b/frontend/app/creator/task/[id]/video/page.tsx index ed2da0a..0307fc5 100644 --- a/frontend/app/creator/task/[id]/video/page.tsx +++ b/frontend/app/creator/task/[id]/video/page.tsx @@ -22,6 +22,8 @@ type VideoTaskUI = { brandName: string videoStatus: string videoFile: string | null + aiAutoRejected?: boolean + aiRejectReason?: string aiResult: null | { score: number hardViolations: Array<{ type: string; content: string; timestamp: number; suggestion: string }> @@ -36,8 +38,9 @@ type VideoTaskUI = { function mapApiToVideoUI(task: TaskResponse): VideoTaskUI { const stage = task.stage let status = 'pending_upload' + const aiAutoRejected = task.video_ai_result?.ai_auto_rejected === true switch (stage) { - case 'video_upload': status = 'pending_upload'; break + case 'video_upload': status = aiAutoRejected ? 'ai_rejected' : 'pending_upload'; break case 'video_ai_review': status = 'ai_reviewing'; break case 'video_agency_review': status = 'agent_reviewing'; break case 'video_brand_review': status = 'brand_reviewing'; break @@ -80,6 +83,8 @@ function mapApiToVideoUI(task: TaskResponse): VideoTaskUI { brandName: task.project?.brand_name || '', videoStatus: status, videoFile: task.video_file_name || null, + aiAutoRejected, + aiRejectReason: task.video_ai_result?.ai_reject_reason, aiResult, agencyReview, brandReview, @@ -371,7 +376,7 @@ export default function CreatorVideoPage() { const getStatusDisplay = () => { const map: Record = { - pending_upload: '待上传视频', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成', + pending_upload: '待上传视频', ai_rejected: 'AI 审核未通过', ai_reviewing: 'AI 审核中', ai_result: 'AI 审核完成', agent_reviewing: '代理商审核中', agent_rejected: '代理商驳回', brand_reviewing: '品牌方终审中', brand_passed: '审核通过', brand_rejected: '品牌方驳回', } @@ -395,6 +400,23 @@ export default function CreatorVideoPage() { {task.videoStatus === 'pending_upload' && } + {task.videoStatus === 'ai_rejected' && ( + <> + + +
+ +
+

视频 AI 审核未通过,请修改后重新上传

+ {task.aiRejectReason &&

{task.aiRejectReason}

} +
+
+
+
+ + + + )} {task.videoStatus === 'ai_reviewing' && } {task.videoStatus === 'ai_result' && <>} {task.videoStatus === 'agent_reviewing' && <>} diff --git a/frontend/types/task.ts b/frontend/types/task.ts index 692fd35..2d5b24b 100644 --- a/frontend/types/task.ts +++ b/frontend/types/task.ts @@ -85,6 +85,9 @@ export interface AIReviewResult { dimensions?: ReviewDimensions selling_point_matches?: SellingPointMatchResult[] brief_match_detail?: BriefMatchDetail + ai_auto_rejected?: boolean + ai_reject_reason?: string + ai_available?: boolean violations: Array<{ type: string content: string