feat: 规则冲突检测增强 — 后端接入 DB 规则 + 前端集成检查按钮

后端 validate_rules 端点改为 async,合并 DB active 平台规则与硬编码兜底规则,
新增 selling_points 字段支持和时长冲突检测。前端品牌方/代理商 Brief 页面
添加"检查规则冲突"按钮,支持选择平台后展示冲突详情弹窗。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-02-10 14:12:49 +08:00
parent c17c64cd11
commit 2f24dcfd34
4 changed files with 580 additions and 37 deletions

View File

@ -455,30 +455,67 @@ async def get_platform_rules(platform: str) -> PlatformRuleResponse:
# ==================== 规则冲突检测 ====================
@router.post("/validate", response_model=RuleValidateResponse)
async def validate_rules(request: RuleValidateRequest) -> RuleValidateResponse:
"""检测 Brief 与平台规则冲突"""
async def validate_rules(
request: RuleValidateRequest,
x_tenant_id: str = Header(..., alias="X-Tenant-ID"),
db: AsyncSession = Depends(get_db),
) -> RuleValidateResponse:
"""检测 Brief 与平台规则冲突(合并 DB 规则 + 硬编码兜底)"""
conflicts = []
platform_rule = _platform_rules.get(request.platform)
if not platform_rule:
return RuleValidateResponse(conflicts=[])
# 1. 收集违禁词DB active 规则优先,硬编码兜底
db_rules = await get_active_platform_rules(
x_tenant_id, request.brand_id, request.platform, db
)
forbidden_words: set[str] = set()
min_seconds: Optional[int] = None
max_seconds: Optional[int] = None
# 检查 required_phrases 是否包含违禁词
required_phrases = request.brief_rules.get("required_phrases", [])
platform_forbidden = []
for rule in platform_rule.get("rules", []):
if db_rules:
forbidden_words.update(db_rules.get("forbidden_words", []))
duration = db_rules.get("duration") or {}
min_seconds = duration.get("min_seconds")
max_seconds = duration.get("max_seconds")
# 硬编码兜底
hardcoded = _platform_rules.get(request.platform, {})
for rule in hardcoded.get("rules", []):
if rule.get("type") == "forbidden_word":
platform_forbidden.extend(rule.get("words", []))
forbidden_words.update(rule.get("words", []))
elif rule.get("type") == "duration" and min_seconds is None:
if rule.get("min_seconds") is not None:
min_seconds = rule["min_seconds"]
if rule.get("max_seconds") is not None and max_seconds is None:
max_seconds = rule["max_seconds"]
for phrase in required_phrases:
for word in platform_forbidden:
if word in phrase:
# 2. 检查卖点/必选短语与违禁词冲突
phrases = list(request.brief_rules.get("required_phrases", []))
phrases += list(request.brief_rules.get("selling_points", []))
for phrase in phrases:
for word in forbidden_words:
if word in str(phrase):
conflicts.append(RuleConflict(
brief_rule=f"要求使用:{phrase}",
platform_rule=f"平台禁止:{word}",
suggestion=f"Brief 要求的 '{phrase}' 包含平台违禁词 '{word}',建议修改",
brief_rule=f"卖点包含{phrase}",
platform_rule=f"{request.platform} 禁止使用{word}",
suggestion=f"卖点 '{phrase}' 包含违禁词 '{word}',建议修改表述",
))
# 3. 检查时长冲突
brief_min = request.brief_rules.get("min_duration")
brief_max = request.brief_rules.get("max_duration")
if min_seconds and brief_max and brief_max < min_seconds:
conflicts.append(RuleConflict(
brief_rule=f"Brief 最长时长:{brief_max}",
platform_rule=f"{request.platform} 最短要求:{min_seconds}",
suggestion=f"Brief 最长 {brief_max}s 低于平台最短要求 {min_seconds}s视频可能不达标",
))
if max_seconds and brief_min and brief_min > max_seconds:
conflicts.append(RuleConflict(
brief_rule=f"Brief 最短时长:{brief_min}",
platform_rule=f"{request.platform} 最长限制:{max_seconds}",
suggestion=f"Brief 最短 {brief_min}s 超过平台最长限制 {max_seconds}s建议调整",
))
return RuleValidateResponse(conflicts=conflicts)

View File

@ -345,7 +345,7 @@ class TestRuleConflictDetection:
@pytest.mark.asyncio
async def test_detect_brief_platform_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""检测 Brief 与平台规则冲突"""
"""检测 Brief 与平台规则冲突required_phrases"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
@ -353,7 +353,7 @@ class TestRuleConflictDetection:
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"required_phrases": ["绝对有效"], # 可能违反平台规则
"required_phrases": ["绝对有效"],
}
}
)
@ -386,6 +386,190 @@ class TestRuleConflictDetection:
assert "platform_rule" in conflict
assert "suggestion" in conflict
@pytest.mark.asyncio
async def test_selling_points_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""selling_points 字段也参与冲突检测"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
json={
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"selling_points": ["100%纯天然成分", "绝对安全"],
}
}
)
data = response.json()
assert len(data["conflicts"]) >= 2 # "100%" 和 "绝对" 都命中
@pytest.mark.asyncio
async def test_no_conflict_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""无冲突时返回空列表"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
json={
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"selling_points": ["温和护肤", "适合敏感肌"],
}
}
)
data = response.json()
assert data["conflicts"] == []
@pytest.mark.asyncio
async def test_duration_conflict_brief_max_below_platform_min(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""Brief 最长时长低于平台最短要求"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
json={
"brand_id": brand_id,
"platform": "douyin", # 硬编码 min_seconds=7
"brief_rules": {
"max_duration": 5,
}
}
)
data = response.json()
assert len(data["conflicts"]) >= 1
assert any("时长" in c["brief_rule"] for c in data["conflicts"])
@pytest.mark.asyncio
async def test_db_rules_participate_in_conflict_detection(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""DB 中 active 的规则参与冲突检测"""
headers = {"X-Tenant-ID": tenant_id}
# 创建并确认一条包含自定义违禁词的 DB 平台规则
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
rule_id = create_resp.json()["id"]
custom_rules = {
"forbidden_words": ["自定义违禁词ABC"],
"restricted_words": [],
"duration": {"min_seconds": 15, "max_seconds": 120},
"content_requirements": [],
"other_rules": [],
}
await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers=headers,
json={"parsed_rules": custom_rules},
)
# 验证 DB 违禁词参与检测
response = await client.post(
"/api/v1/rules/validate",
headers=headers,
json={
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"selling_points": ["这个自定义违禁词ABC很好"],
}
}
)
data = response.json()
assert len(data["conflicts"]) >= 1
assert any("自定义违禁词ABC" in c["suggestion"] for c in data["conflicts"])
@pytest.mark.asyncio
async def test_db_duration_conflict(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""DB 规则中的时长限制参与检测"""
headers = {"X-Tenant-ID": tenant_id}
# 创建 DB 规则max_seconds=60
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="xiaohongshu")
rule_id = create_resp.json()["id"]
custom_rules = {
"forbidden_words": [],
"restricted_words": [],
"duration": {"min_seconds": 10, "max_seconds": 60},
"content_requirements": [],
"other_rules": [],
}
await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers=headers,
json={"parsed_rules": custom_rules},
)
# Brief 最短时长 90s > 平台最长 60s → 冲突
response = await client.post(
"/api/v1/rules/validate",
headers=headers,
json={
"brand_id": brand_id,
"platform": "xiaohongshu",
"brief_rules": {
"min_duration": 90,
}
}
)
data = response.json()
assert len(data["conflicts"]) >= 1
assert any("最长限制" in c["platform_rule"] for c in data["conflicts"])
@pytest.mark.asyncio
async def test_db_and_hardcoded_rules_merge(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""DB 规则与硬编码规则合并检测"""
headers = {"X-Tenant-ID": tenant_id}
# DB 规则只包含自定义违禁词
create_resp = await _create_platform_rule(client, tenant_id, brand_id, platform="douyin")
rule_id = create_resp.json()["id"]
await client.put(
f"/api/v1/rules/platform-rules/{rule_id}/confirm",
headers=headers,
json={"parsed_rules": {
"forbidden_words": ["DB专属词"],
"restricted_words": [],
"duration": None,
"content_requirements": [],
"other_rules": [],
}},
)
# selling_points 同时包含 DB 违禁词和硬编码违禁词
response = await client.post(
"/api/v1/rules/validate",
headers=headers,
json={
"brand_id": brand_id,
"platform": "douyin",
"brief_rules": {
"selling_points": ["这是DB专属词内容", "最好的选择"],
}
}
)
data = response.json()
# 应同时检出 DB 违禁词和硬编码违禁词
suggestions = [c["suggestion"] for c in data["conflicts"]]
assert any("DB专属词" in s for s in suggestions)
assert any("最好" in s for s in suggestions)
@pytest.mark.asyncio
async def test_unknown_platform_returns_empty(self, client: AsyncClient, tenant_id: str, brand_id: str):
"""未知平台返回空冲突(无硬编码规则,无 DB 规则)"""
response = await client.post(
"/api/v1/rules/validate",
headers={"X-Tenant-ID": tenant_id},
json={
"brand_id": brand_id,
"platform": "unknown_platform",
"brief_rules": {
"selling_points": ["最好的产品"],
}
}
)
data = response.json()
assert data["conflicts"] == []
# ==================== 品牌方平台规则(文档上传 + AI 解析) ====================

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
@ -27,11 +27,13 @@ import {
Upload,
Trash2,
File,
Loader2
Loader2,
Search
} from 'lucide-react'
import { getPlatformInfo } from '@/lib/platforms'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
import type { RuleConflict } from '@/types/rules'
import type { BriefResponse, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
import type { ProjectResponse } from '@/types/project'
@ -181,6 +183,7 @@ export default function BriefConfigPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const { user } = useAuth()
const projectId = params.id as string
// 加载状态
@ -205,6 +208,81 @@ export default function BriefConfigPage() {
const [isAIParsing, setIsAIParsing] = useState(false)
const [isUploading, setIsUploading] = useState(false)
// 规则冲突检测
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [showConflictModal, setShowConflictModal] = useState(false)
const [ruleConflicts, setRuleConflicts] = useState<RuleConflict[]>([])
const [showPlatformSelect, setShowPlatformSelect] = useState(false)
const platformDropdownRef = useRef<HTMLDivElement>(null)
const platformSelectOptions = [
{ value: 'douyin', label: '抖音' },
{ value: 'xiaohongshu', label: '小红书' },
{ value: 'bilibili', label: 'B站' },
]
// 点击外部关闭平台选择下拉
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (platformDropdownRef.current && !platformDropdownRef.current.contains(e.target as Node)) {
setShowPlatformSelect(false)
}
}
if (showPlatformSelect) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showPlatformSelect])
const handleCheckConflicts = async (platform: string) => {
setShowPlatformSelect(false)
setIsCheckingConflicts(true)
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
setRuleConflicts([
{
brief_rule: '卖点包含100%纯天然成分',
platform_rule: `${platform} 禁止使用100%`,
suggestion: "卖点 '100%纯天然成分' 包含违禁词 '100%',建议修改表述",
},
{
brief_rule: 'Brief 最长时长5秒',
platform_rule: `${platform} 最短要求7秒`,
suggestion: 'Brief 最长 5s 低于平台最短要求 7s视频可能不达标',
},
])
setShowConflictModal(true)
setIsCheckingConflicts(false)
return
}
try {
// 代理商角色可能没有 brand_id从 brandBrief 取关联品牌的 ID
const brandId = user?.brand_id || brandBrief.id || ''
const briefRules: Record<string, unknown> = {
selling_points: agencyConfig.sellingPoints.map(sp => sp.content),
}
const result = await api.validateRules({
brand_id: brandId,
platform,
brief_rules: briefRules,
})
setRuleConflicts(result.conflicts)
if (result.conflicts.length > 0) {
setShowConflictModal(true)
} else {
toast.success('未发现规则冲突')
}
} catch (err) {
console.error('规则冲突检测失败:', err)
toast.error('规则冲突检测失败')
} finally {
setIsCheckingConflicts(false)
}
}
// 加载数据
const loadData = useCallback(async () => {
if (USE_MOCK) {
@ -470,6 +548,39 @@ export default function BriefConfigPage() {
{brandBrief.brandName}
</p>
</div>
<div className="relative" ref={platformDropdownRef}>
<Button
variant="secondary"
onClick={() => setShowPlatformSelect(!showPlatformSelect)}
disabled={isCheckingConflicts}
>
{isCheckingConflicts ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Search size={16} />
</>
)}
</Button>
{showPlatformSelect && (
<div className="absolute right-0 top-full mt-2 w-40 bg-bg-card border border-border-subtle rounded-xl shadow-lg z-50 overflow-hidden">
{platformSelectOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleCheckConflicts(opt.value)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated transition-colors"
>
{opt.label}
</button>
))}
</div>
)}
</div>
<Button variant="secondary" onClick={handleExportRules} disabled={isExporting}>
<FileDown size={16} />
{isExporting ? '导出中...' : '导出规则'}
@ -1024,6 +1135,54 @@ export default function BriefConfigPage() {
</div>
</div>
</Modal>
{/* 规则冲突检测结果弹窗 */}
<Modal
isOpen={showConflictModal}
onClose={() => setShowConflictModal(false)}
title="规则冲突检测结果"
size="lg"
>
<div className="space-y-4">
{ruleConflicts.length === 0 ? (
<div className="py-8 text-center">
<CheckCircle size={48} className="mx-auto text-accent-green mb-3" />
<p className="text-text-primary font-medium"></p>
<p className="text-sm text-text-secondary mt-1">Brief </p>
</div>
) : (
<>
<div className="flex items-center gap-2 p-3 bg-accent-amber/10 rounded-lg border border-accent-amber/30">
<AlertTriangle size={16} className="text-accent-amber flex-shrink-0" />
<p className="text-sm text-accent-amber">
{ruleConflicts.length}
</p>
</div>
{ruleConflicts.map((conflict, index) => (
<div key={index} className="p-4 bg-bg-elevated rounded-xl border border-border-subtle space-y-2">
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-amber bg-accent-amber/15 px-2 py-0.5 rounded">Brief</span>
<span className="text-sm text-text-primary">{conflict.brief_rule}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-coral bg-accent-coral/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-primary">{conflict.platform_rule}</span>
</div>
<div className="flex items-start gap-2 pt-1 border-t border-border-subtle">
<span className="text-xs font-medium text-accent-indigo bg-accent-indigo/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-secondary">{conflict.suggestion}</span>
</div>
</div>
))}
</>
)}
<div className="flex justify-end pt-2">
<Button variant="secondary" onClick={() => setShowConflictModal(false)}>
</Button>
</div>
</div>
</Modal>
</div>
)
}

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useRouter, useParams } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent } from '@/components/ui/Card'
@ -20,10 +20,13 @@ import {
Upload,
ChevronDown,
ChevronUp,
Loader2
Loader2,
Search
} from 'lucide-react'
import { Modal } from '@/components/ui/Modal'
import { api } from '@/lib/api'
import { USE_MOCK } from '@/contexts/AuthContext'
import { USE_MOCK, useAuth } from '@/contexts/AuthContext'
import type { RuleConflict } from '@/types/rules'
import { useOSSUpload } from '@/hooks/useOSSUpload'
import type { BriefResponse, BriefCreateRequest, SellingPoint, BlacklistWord, BriefAttachment } from '@/types/brief'
@ -109,6 +112,7 @@ export default function ProjectConfigPage() {
const router = useRouter()
const params = useParams()
const toast = useToast()
const { user } = useAuth()
const projectId = params.id as string
const { upload, isUploading, progress: uploadProgress } = useOSSUpload('general')
@ -133,6 +137,82 @@ export default function ProjectConfigPage() {
const [isSaving, setIsSaving] = useState(false)
const [activeSection, setActiveSection] = useState<string | null>('brief')
// 规则冲突检测
const [isCheckingConflicts, setIsCheckingConflicts] = useState(false)
const [showConflictModal, setShowConflictModal] = useState(false)
const [conflicts, setConflicts] = useState<RuleConflict[]>([])
const [showPlatformSelect, setShowPlatformSelect] = useState(false)
const platformDropdownRef = useRef<HTMLDivElement>(null)
const platformOptions = [
{ value: 'douyin', label: '抖音' },
{ value: 'xiaohongshu', label: '小红书' },
{ value: 'bilibili', label: 'B站' },
]
// 点击外部关闭平台选择下拉
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (platformDropdownRef.current && !platformDropdownRef.current.contains(e.target as Node)) {
setShowPlatformSelect(false)
}
}
if (showPlatformSelect) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showPlatformSelect])
const handleCheckConflicts = async (platform: string) => {
setShowPlatformSelect(false)
setIsCheckingConflicts(true)
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
setConflicts([
{
brief_rule: '卖点包含100%纯天然成分',
platform_rule: `${platform} 禁止使用100%`,
suggestion: "卖点 '100%纯天然成分' 包含违禁词 '100%',建议修改表述",
},
{
brief_rule: 'Brief 最长时长5秒',
platform_rule: `${platform} 最短要求7秒`,
suggestion: 'Brief 最长 5s 低于平台最短要求 7s视频可能不达标',
},
])
setShowConflictModal(true)
setIsCheckingConflicts(false)
return
}
try {
const brandId = user?.brand_id || ''
const briefRules: Record<string, unknown> = {
selling_points: sellingPoints.map(sp => sp.content),
min_duration: minDuration,
max_duration: maxDuration,
}
const result = await api.validateRules({
brand_id: brandId,
platform,
brief_rules: briefRules,
})
setConflicts(result.conflicts)
if (result.conflicts.length > 0) {
setShowConflictModal(true)
} else {
toast.success('未发现规则冲突')
}
} catch (err) {
console.error('规则冲突检测失败:', err)
toast.error('规则冲突检测失败')
} finally {
setIsCheckingConflicts(false)
}
}
// Input fields
const [newSellingPoint, setNewSellingPoint] = useState('')
const [newBlacklistWord, setNewBlacklistWord] = useState('')
@ -336,19 +416,54 @@ export default function ProjectConfigPage() {
</p>
</div>
</div>
<Button variant="primary" onClick={handleSaveBrief} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Save size={16} />
</>
)}
</Button>
<div className="flex items-center gap-2">
<div className="relative" ref={platformDropdownRef}>
<Button
variant="secondary"
onClick={() => setShowPlatformSelect(!showPlatformSelect)}
disabled={isCheckingConflicts}
>
{isCheckingConflicts ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Search size={16} />
</>
)}
</Button>
{showPlatformSelect && (
<div className="absolute right-0 top-full mt-2 w-40 bg-bg-card border border-border-subtle rounded-xl shadow-lg z-50 overflow-hidden">
{platformOptions.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => handleCheckConflicts(opt.value)}
className="w-full px-4 py-2.5 text-left text-sm text-text-primary hover:bg-bg-elevated transition-colors"
>
{opt.label}
</button>
))}
</div>
)}
</div>
<Button variant="primary" onClick={handleSaveBrief} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 size={16} className="animate-spin" />
...
</>
) : (
<>
<Save size={16} />
</>
)}
</Button>
</div>
</div>
{/* Brief配置 */}
@ -757,6 +872,54 @@ export default function ProjectConfigPage() {
</CardContent>
)}
</Card>
{/* 规则冲突检测结果弹窗 */}
<Modal
isOpen={showConflictModal}
onClose={() => setShowConflictModal(false)}
title="规则冲突检测结果"
size="lg"
>
<div className="space-y-4">
{conflicts.length === 0 ? (
<div className="py-8 text-center">
<CheckCircle size={48} className="mx-auto text-accent-green mb-3" />
<p className="text-text-primary font-medium"></p>
<p className="text-sm text-text-secondary mt-1">Brief </p>
</div>
) : (
<>
<div className="flex items-center gap-2 p-3 bg-accent-amber/10 rounded-lg border border-accent-amber/30">
<AlertTriangle size={16} className="text-accent-amber flex-shrink-0" />
<p className="text-sm text-accent-amber">
{conflicts.length}
</p>
</div>
{conflicts.map((conflict, index) => (
<div key={index} className="p-4 bg-bg-elevated rounded-xl border border-border-subtle space-y-2">
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-amber bg-accent-amber/15 px-2 py-0.5 rounded">Brief</span>
<span className="text-sm text-text-primary">{conflict.brief_rule}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-xs font-medium text-accent-coral bg-accent-coral/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-primary">{conflict.platform_rule}</span>
</div>
<div className="flex items-start gap-2 pt-1 border-t border-border-subtle">
<span className="text-xs font-medium text-accent-indigo bg-accent-indigo/15 px-2 py-0.5 rounded"></span>
<span className="text-sm text-text-secondary">{conflict.suggestion}</span>
</div>
</div>
))}
</>
)}
<div className="flex justify-end pt-2">
<Button variant="secondary" onClick={() => setShowConflictModal(false)}>
</Button>
</div>
</div>
</Modal>
</div>
)
}