后端: - 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度) - 卖点优先级从 required:bool 改为三级 (core/recommended/reference) - AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析 - BriefMatchDetail 评分详情 (覆盖率+亮点+问题点) - min_selling_points 代理商可配置最少卖点数 + Alembic 迁移 - AI 语境复核过滤误报 - Brief AI 解析 + 规则 AI 解析 - AI 未配置/异常时通知品牌方 - 种子数据更新 (新格式审核结果+brief_match_detail) 前端: - 三端审核页面展示四维度评分卡片 - 卖点编辑改为三级优先级选择器 - BriefMatchDetail 展示 (覆盖率进度条+亮点+问题) - min_selling_points 配置 UI - AI 配置页未配置时静默处理 - 文件预览/下载/签名 URL 优化 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
343 lines
12 KiB
Python
343 lines
12 KiB
Python
"""
|
||
脚本预审 API 测试 (TDD - 红色阶段)
|
||
测试覆盖: 脚本提交、违规检测、语境理解
|
||
"""
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
|
||
from app.schemas.review import ScriptReviewResponse, ViolationType, SoftRiskAction
|
||
|
||
|
||
class TestSubmitScript:
|
||
"""提交脚本预审"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_script_returns_200(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""提交脚本返回 200"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是一段测试脚本内容",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
assert response.status_code == 200
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_script_returns_review_result(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""提交脚本返回审核结果"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是一段测试脚本内容",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert isinstance(parsed.summary, str) and parsed.summary
|
||
assert 0 <= parsed.score <= 100
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_submit_empty_script_returns_422(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""提交空脚本返回 422"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
assert response.status_code == 422
|
||
|
||
|
||
class TestForbiddenWordDetection:
|
||
"""违禁词检测"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_detect_absolute_word(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""检测广告极限词:最好、第一"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "我们的产品是全网最好的,销量第一",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert len(parsed.violations) > 0
|
||
violation_types = [v.type for v in parsed.violations]
|
||
assert ViolationType.FORBIDDEN_WORD in violation_types
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_detect_efficacy_word(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""检测功效词:根治、治愈"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "使用我们的产品可以根治失眠问题",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
violation_types = [v.type for v in parsed.violations]
|
||
assert ViolationType.EFFICACY_CLAIM in violation_types
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_return_violation_position(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""返回违规词位置"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是最好的产品", # "最好"是违禁词
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert len(parsed.violations) > 0, "应检测到'最好'违规"
|
||
violation = parsed.violations[0]
|
||
assert violation.position is not None
|
||
assert violation.position.start >= 0
|
||
assert violation.position.end > violation.position.start
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_return_violation_suggestion(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""每个违规项包含修改建议"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是最好的产品", # "最好"是违禁词
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert len(parsed.violations) > 0, "应检测到'最好'违规"
|
||
assert isinstance(parsed.violations[0].suggestion, str)
|
||
assert parsed.violations[0].suggestion
|
||
|
||
|
||
class TestContextUnderstanding:
|
||
"""语境理解(降低误报)"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_non_ad_context_not_flagged(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""非广告语境不应标记为违规:最开心的一天"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "今天是我最开心的一天,因为见到了老朋友",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
forbidden_violations = [
|
||
v for v in parsed.violations
|
||
if v.type == ViolationType.FORBIDDEN_WORD and "最" in v.content
|
||
]
|
||
assert len(forbidden_violations) == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_story_context_not_flagged(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""故事情节语境不应标记:他是第一个到达的人"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "他是第一个到达终点的人,大家都为他鼓掌",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
forbidden_violations = [
|
||
v for v in parsed.violations
|
||
if v.type == ViolationType.FORBIDDEN_WORD and "第一" in v.content
|
||
]
|
||
assert len(forbidden_violations) == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_ad_context_flagged(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""广告语境应标记:我们的产品第一"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "我们的产品销量第一,品质最好",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert len(parsed.violations) > 0
|
||
|
||
|
||
class TestSellingPointCheck:
|
||
"""卖点遗漏检查"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_check_missing_selling_points(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""检查是否遗漏必要卖点"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这个产品很好用",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"selling_points": [
|
||
{"content": "功效说明", "priority": "core"},
|
||
{"content": "使用方法", "priority": "core"},
|
||
{"content": "品牌名称", "priority": "recommended"},
|
||
],
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
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):
|
||
"""所有卖点都覆盖时返回空"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "品牌A的护肤精华,每天早晚各用一次,可以让肌肤更水润",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"selling_points": [
|
||
{"content": "护肤精华", "priority": "core"},
|
||
{"content": "早晚各用一次", "priority": "core"},
|
||
{"content": "肌肤更水润", "priority": "recommended"},
|
||
],
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert parsed.missing_points == []
|
||
|
||
|
||
class TestScoreCalculation:
|
||
"""合规分数计算"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_clean_content_returns_high_score(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""合规内容返回高分(>=90)"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "今天给大家分享一个护肤小技巧,记得每天早晚洁面哦",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert parsed.score >= 90
|
||
high_risk = [v for v in parsed.violations if v.severity.value == "high"]
|
||
assert len(high_risk) == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_violation_content_returns_low_score(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""违规内容返回低分(<80)"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "这是最好的产品,可以根治所有问题,效果第一",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert parsed.score < 80
|
||
assert len(parsed.violations) > 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_score_range_valid(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""分数在有效范围内 0-100"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "任意内容",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
assert 0 <= parsed.score <= 100
|
||
|
||
|
||
class TestSoftRiskWarnings:
|
||
"""软性风控提示"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_near_threshold_returns_warning(self, client: AsyncClient, tenant_id: str, brand_id: str):
|
||
"""临界值接近阈值时返回软性提示(不阻断)"""
|
||
response = await client.post(
|
||
"/api/v1/scripts/review",
|
||
headers={"X-Tenant-ID": tenant_id},
|
||
json={
|
||
"content": "内容正常但指标接近阈值",
|
||
"platform": "douyin",
|
||
"brand_id": brand_id,
|
||
"soft_risk_context": {
|
||
"violation_rate": 0.045,
|
||
"violation_threshold": 0.05,
|
||
}
|
||
}
|
||
)
|
||
data = response.json()
|
||
parsed = ScriptReviewResponse.model_validate(data)
|
||
|
||
matched = [
|
||
w for w in parsed.soft_warnings
|
||
if w.code == "NEAR_THRESHOLD" and w.action_required == SoftRiskAction.CONFIRM
|
||
]
|
||
assert matched, "应返回临界值软性提示"
|
||
assert all(w.blocking is False for w in matched)
|