主要更新: - 更新代理商端文档,明确项目由品牌方分配流程 - 新增Brief配置详情页(已配置)设计稿 - 完善工作台紧急待办中品牌新任务功能 - 整理Pencil设计文件中代理商端页面顺序 - 新增后端FastAPI框架及核心API - 新增前端Next.js页面和组件库 - 添加.gitignore排除构建和缓存文件 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
319 lines
9.7 KiB
Python
319 lines
9.7 KiB
Python
"""
|
||
脚本预审 API
|
||
"""
|
||
import re
|
||
from typing import Optional
|
||
from fastapi import APIRouter, Depends, Header
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.database import get_db
|
||
from app.schemas.review import (
|
||
ScriptReviewRequest,
|
||
ScriptReviewResponse,
|
||
Violation,
|
||
ViolationType,
|
||
RiskLevel,
|
||
Position,
|
||
SoftRiskWarning,
|
||
)
|
||
from app.api.rules import (
|
||
get_whitelist_for_brand,
|
||
get_other_brands_whitelist_terms,
|
||
get_forbidden_words_for_tenant,
|
||
)
|
||
from app.services.soft_risk import evaluate_soft_risk
|
||
from app.services.ai_service import AIServiceFactory
|
||
|
||
router = APIRouter(prefix="/scripts", tags=["scripts"])
|
||
|
||
# 内置违禁词库(广告极限词)
|
||
ABSOLUTE_WORDS = ["最好", "第一", "最佳", "绝对", "100%"]
|
||
|
||
# 功效词库(医疗/功效宣称)
|
||
EFFICACY_WORDS = ["根治", "治愈", "治疗", "药效", "疗效", "特效"]
|
||
|
||
# 广告语境关键词(用于判断是否为广告场景)
|
||
AD_CONTEXT_KEYWORDS = ["产品", "购买", "销量", "品质", "推荐", "价格", "优惠", "促销"]
|
||
|
||
|
||
def _is_ad_context(content: str, word: str) -> bool:
|
||
"""
|
||
判断是否为广告语境
|
||
|
||
规则:
|
||
- 如果内容中包含广告关键词,认为是广告语境
|
||
- 如果违禁词出现在明显的非广告句式中,不是广告语境
|
||
"""
|
||
# 非广告语境模式
|
||
non_ad_patterns = [
|
||
r"他是第一[个名位]", # 他是第一个/名
|
||
r"[是为]第一[个名位]", # 是第一个
|
||
r"最开心|最高兴|最难忘", # 情感表达
|
||
r"第一[次个].*[到来抵达]", # 第一次到达
|
||
]
|
||
|
||
for pattern in non_ad_patterns:
|
||
if re.search(pattern, content):
|
||
return False
|
||
|
||
# 检查是否包含广告关键词
|
||
return any(kw in content for kw in AD_CONTEXT_KEYWORDS)
|
||
|
||
|
||
def _check_selling_point_coverage(content: str, required_points: list[str]) -> list[str]:
|
||
"""
|
||
检查卖点覆盖情况
|
||
|
||
使用语义匹配而非精确匹配
|
||
"""
|
||
missing = []
|
||
|
||
# 卖点关键词映射
|
||
point_keywords = {
|
||
"品牌名称": ["品牌", "牌子", "品牌A", "品牌B"],
|
||
"使用方法": ["使用", "用法", "早晚", "每天", "一次", "涂抹", "喷洒"],
|
||
"功效说明": ["功效", "效果", "水润", "美白", "保湿", "滋润", "改善"],
|
||
}
|
||
|
||
for point in required_points:
|
||
# 精确匹配
|
||
if point in content:
|
||
continue
|
||
|
||
# 关键词匹配
|
||
keywords = point_keywords.get(point, [])
|
||
if any(kw in content for kw in keywords):
|
||
continue
|
||
|
||
missing.append(point)
|
||
|
||
return missing
|
||
|
||
|
||
@router.post("/review", response_model=ScriptReviewResponse)
|
||
async def review_script(
|
||
request: ScriptReviewRequest,
|
||
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> ScriptReviewResponse:
|
||
"""
|
||
脚本预审
|
||
|
||
- 检测违禁词(支持语境感知)
|
||
- 检测功效词
|
||
- 检查必要卖点
|
||
- 应用白名单
|
||
- 可选 AI 深度分析
|
||
- 返回合规分数和修改建议
|
||
"""
|
||
violations = []
|
||
content = request.content
|
||
|
||
# 获取品牌白名单
|
||
whitelist = await get_whitelist_for_brand(x_tenant_id, request.brand_id, db)
|
||
|
||
# 获取租户自定义违禁词
|
||
tenant_forbidden_words = await get_forbidden_words_for_tenant(x_tenant_id, db)
|
||
|
||
# 1. 违禁词检测(广告极限词)
|
||
all_forbidden_words = ABSOLUTE_WORDS + [w["word"] for w in tenant_forbidden_words]
|
||
|
||
for word in all_forbidden_words:
|
||
# 白名单跳过
|
||
if word in whitelist:
|
||
continue
|
||
|
||
start = 0
|
||
while True:
|
||
pos = content.find(word, start)
|
||
if pos == -1:
|
||
break
|
||
|
||
# 语境感知:非广告语境跳过
|
||
if not _is_ad_context(content, word):
|
||
start = pos + 1
|
||
continue
|
||
|
||
violations.append(Violation(
|
||
type=ViolationType.FORBIDDEN_WORD,
|
||
content=word,
|
||
severity=RiskLevel.HIGH,
|
||
suggestion=f"建议删除或替换违禁词:{word}",
|
||
position=Position(start=pos, end=pos + len(word)),
|
||
))
|
||
start = pos + 1
|
||
|
||
# 2. 功效词检测
|
||
for word in EFFICACY_WORDS:
|
||
if word in whitelist:
|
||
continue
|
||
|
||
start = 0
|
||
while True:
|
||
pos = content.find(word, start)
|
||
if pos == -1:
|
||
break
|
||
|
||
violations.append(Violation(
|
||
type=ViolationType.EFFICACY_CLAIM,
|
||
content=word,
|
||
severity=RiskLevel.HIGH,
|
||
suggestion=f"功效宣称词违反广告法,建议删除:{word}",
|
||
position=Position(start=pos, end=pos + len(word)),
|
||
))
|
||
start = pos + 1
|
||
|
||
# 3. 检测其他品牌专属词(品牌安全风险)
|
||
other_brand_terms = await get_other_brands_whitelist_terms(x_tenant_id, request.brand_id, db)
|
||
for term, owner_brand in other_brand_terms:
|
||
if term in content:
|
||
violations.append(Violation(
|
||
type=ViolationType.BRAND_SAFETY,
|
||
content=term,
|
||
severity=RiskLevel.MEDIUM,
|
||
suggestion=f"使用了其他品牌的专属词汇:{term}",
|
||
position=Position(start=content.find(term), end=content.find(term) + len(term)),
|
||
))
|
||
|
||
# 4. 检查遗漏卖点
|
||
missing_points: list[str] | None = None
|
||
if request.required_points:
|
||
missing = _check_selling_point_coverage(content, request.required_points)
|
||
missing_points = missing if missing else []
|
||
|
||
# 5. 可选:AI 深度分析
|
||
ai_violations = await _ai_deep_analysis(x_tenant_id, content, db)
|
||
if ai_violations:
|
||
violations.extend(ai_violations)
|
||
|
||
# 6. 计算分数
|
||
score = 100 - len(violations) * 25
|
||
if missing_points:
|
||
score -= len(missing_points) * 5
|
||
score = max(0, score)
|
||
|
||
# 7. 生成摘要
|
||
parts = []
|
||
if violations:
|
||
parts.append(f"发现 {len(violations)} 处违规")
|
||
if missing_points:
|
||
parts.append(f"遗漏 {len(missing_points)} 个卖点")
|
||
|
||
if not parts:
|
||
summary = "脚本内容合规,未发现问题"
|
||
else:
|
||
summary = ",".join(parts)
|
||
|
||
# 8. 软性风控评估
|
||
soft_warnings: list[SoftRiskWarning] = []
|
||
if request.soft_risk_context:
|
||
soft_warnings = evaluate_soft_risk(request.soft_risk_context)
|
||
|
||
return ScriptReviewResponse(
|
||
score=score,
|
||
summary=summary,
|
||
violations=violations,
|
||
missing_points=missing_points,
|
||
soft_warnings=soft_warnings,
|
||
)
|
||
|
||
|
||
async def _ai_deep_analysis(
|
||
tenant_id: str,
|
||
content: str,
|
||
db: AsyncSession,
|
||
) -> list[Violation]:
|
||
"""
|
||
使用 AI 进行深度分析
|
||
|
||
AI 分析失败时返回空列表,降级到规则检测
|
||
"""
|
||
try:
|
||
# 获取 AI 客户端
|
||
ai_client = await AIServiceFactory.get_client(tenant_id, db)
|
||
if not ai_client:
|
||
return []
|
||
|
||
# 获取模型配置
|
||
config = await AIServiceFactory.get_config(tenant_id, db)
|
||
if not config:
|
||
return []
|
||
|
||
text_model = config.models.get("text", "gpt-4o")
|
||
|
||
# 构建分析提示
|
||
analysis_prompt = f"""作为广告合规审核专家,请分析以下广告脚本内容,检测潜在的合规风险:
|
||
|
||
脚本内容:
|
||
{content}
|
||
|
||
请检查以下方面:
|
||
1. 是否存在隐性的虚假宣传(如暗示疗效但不直接说明)
|
||
2. 是否存在容易引起误解的表述
|
||
3. 是否存在夸大描述
|
||
4. 是否存在可能违反广告法的其他内容
|
||
|
||
如果发现问题,请以 JSON 数组格式返回,每项包含:
|
||
- type: 违规类型 (forbidden_word/efficacy_claim/brand_safety)
|
||
- content: 违规内容
|
||
- severity: 严重程度 (high/medium/low)
|
||
- suggestion: 修改建议
|
||
|
||
如果未发现问题,返回空数组 []
|
||
|
||
请只返回 JSON 数组,不要包含其他内容。"""
|
||
|
||
response = await ai_client.chat_completion(
|
||
messages=[{"role": "user", "content": analysis_prompt}],
|
||
model=text_model,
|
||
temperature=0.3,
|
||
max_tokens=1000,
|
||
)
|
||
|
||
# 解析 AI 响应
|
||
import json
|
||
try:
|
||
# 清理响应内容(移除可能的 markdown 标记)
|
||
response_content = response.content.strip()
|
||
if response_content.startswith("```"):
|
||
response_content = response_content.split("\n", 1)[1]
|
||
if response_content.endswith("```"):
|
||
response_content = response_content.rsplit("\n", 1)[0]
|
||
|
||
ai_results = json.loads(response_content)
|
||
|
||
violations = []
|
||
for item in ai_results:
|
||
violation_type = item.get("type", "forbidden_word")
|
||
if violation_type == "forbidden_word":
|
||
vtype = ViolationType.FORBIDDEN_WORD
|
||
elif violation_type == "efficacy_claim":
|
||
vtype = ViolationType.EFFICACY_CLAIM
|
||
else:
|
||
vtype = ViolationType.BRAND_SAFETY
|
||
|
||
severity = item.get("severity", "medium")
|
||
if severity == "high":
|
||
slevel = RiskLevel.HIGH
|
||
elif severity == "low":
|
||
slevel = RiskLevel.LOW
|
||
else:
|
||
slevel = RiskLevel.MEDIUM
|
||
|
||
violations.append(Violation(
|
||
type=vtype,
|
||
content=item.get("content", ""),
|
||
severity=slevel,
|
||
suggestion=item.get("suggestion", "建议修改"),
|
||
))
|
||
|
||
return violations
|
||
|
||
except json.JSONDecodeError:
|
||
# JSON 解析失败,返回空列表
|
||
return []
|
||
|
||
except Exception:
|
||
# AI 调用失败,降级到规则检测
|
||
return []
|