video-compliance-ai/backend/tests/test_script_review_api.py
Your Name 0ef7650c09 feat: 审核体系全面改造 — 多维度评分 + 卖点优先级 + AI 语义匹配 + 品牌方 AI 状态通知
后端:
- 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度)
- 卖点优先级从 required:bool 改为三级 (core/recommended/reference)
- AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析
- BriefMatchDetail 评分详情 (覆盖率+亮点+问题点)
- min_selling_points 代理商可配置最少卖点数 + Alembic 迁移
- AI 语境复核过滤误报
- Brief AI 解析 + 规则 AI 解析
- AI 未配置/异常时通知品牌方
- 种子数据更新 (新格式审核结果+brief_match_detail)

前端:
- 三端审核页面展示四维度评分卡片
- 卖点编辑改为三级优先级选择器
- BriefMatchDetail 展示 (覆盖率进度条+亮点+问题)
- min_selling_points 配置 UI
- AI 配置页未配置时静默处理
- 文件预览/下载/签名 URL 优化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 19:11:54 +08:00

343 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
脚本预审 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)