spine-framework-cortex 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/CortexSidebar.tsx +130 -0
- package/functions/custom_anonymous-sessions.ts +356 -0
- package/functions/custom_case_analysis.ts +507 -0
- package/functions/custom_community-escalation.ts +234 -0
- package/functions/custom_cortex-chunks.ts +52 -0
- package/functions/custom_cortex-handler.ts +35 -0
- package/functions/custom_funnel-scoring.ts +256 -0
- package/functions/custom_funnel-signal.ts +678 -0
- package/functions/custom_funnel-timers.ts +449 -0
- package/functions/custom_kb-chunker-test.ts +364 -0
- package/functions/custom_kb-chunker.ts +576 -0
- package/functions/custom_kb-embeddings.ts +481 -0
- package/functions/custom_kb-ingestion.ts +448 -0
- package/functions/custom_support-triage.ts +649 -0
- package/functions/custom_tag_management.ts +314 -0
- package/index.tsx +103 -0
- package/manifest.json +82 -0
- package/package.json +29 -0
- package/pages/CortexDashboard.tsx +97 -0
- package/pages/community/CommunityPage.tsx +159 -0
- package/pages/courses/CoursesPage.tsx +231 -0
- package/pages/crm/AccountDetailPage.tsx +393 -0
- package/pages/crm/AccountsPage.tsx +164 -0
- package/pages/crm/ActivityPage.tsx +82 -0
- package/pages/crm/ContactDetailPage.tsx +184 -0
- package/pages/crm/ContactsPage.tsx +87 -0
- package/pages/crm/DealDetailPage.tsx +191 -0
- package/pages/crm/DealsPage.tsx +169 -0
- package/pages/crm/HealthPage.tsx +109 -0
- package/pages/intelligence/IntelligencePage.tsx +314 -0
- package/pages/kb/KBEditorPage.tsx +328 -0
- package/pages/kb/KBIngestionPage.tsx +409 -0
- package/pages/kb/KBPage.tsx +258 -0
- package/pages/support/RedactionReview.tsx +562 -0
- package/pages/support/SupportPage.tsx +395 -0
- package/pages/support/TicketDetailPage.tsx +919 -0
- package/seed/accounts.json +9 -0
- package/seed/link-types.json +44 -0
- package/seed/triggers.json +80 -0
- package/seed/types.json +352 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from 'react'
|
|
2
|
+
import { resolveTypeId } from '../../../lib/resolveTypeId'
|
|
3
|
+
import { useParams, useNavigate } from 'react-router-dom'
|
|
4
|
+
import { apiFetch } from '@core/lib/api'
|
|
5
|
+
import { Button } from '@core/components/ui/button'
|
|
6
|
+
import { Badge } from '@core/components/ui/badge'
|
|
7
|
+
import { ScrollArea } from '@core/components/ui/scroll-area'
|
|
8
|
+
import { Textarea } from '@core/components/ui/textarea'
|
|
9
|
+
import { Input } from '@core/components/ui/input'
|
|
10
|
+
import { Label } from '@core/components/ui/label'
|
|
11
|
+
import { Checkbox } from '@core/components/ui/checkbox'
|
|
12
|
+
import { ArrowLeft, AlertCircle, EyeOff, Eye, CheckCircle, BookOpen, AlertTriangle } from 'lucide-react'
|
|
13
|
+
|
|
14
|
+
interface RedactionSuggestion {
|
|
15
|
+
id: string
|
|
16
|
+
start_index: number
|
|
17
|
+
end_index: number
|
|
18
|
+
original_text: string
|
|
19
|
+
redacted_text: string
|
|
20
|
+
sensitivity_level: 'high' | 'medium' | 'low'
|
|
21
|
+
reasoning: string
|
|
22
|
+
category: 'pii' | 'confidential' | 'account_specific' | 'internal_reference'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface RedactionAnalysis {
|
|
26
|
+
original_content: string
|
|
27
|
+
redacted_content: string
|
|
28
|
+
suggestions: RedactionSuggestion[]
|
|
29
|
+
confidence_score: number
|
|
30
|
+
processing_metadata: {
|
|
31
|
+
model_used: string
|
|
32
|
+
temperature: number
|
|
33
|
+
tokens_consumed: number
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface Ticket {
|
|
38
|
+
id: string
|
|
39
|
+
title: string
|
|
40
|
+
description?: string
|
|
41
|
+
data?: {
|
|
42
|
+
ai_metadata?: {
|
|
43
|
+
problem_statement?: string
|
|
44
|
+
solution_path?: string
|
|
45
|
+
}
|
|
46
|
+
postmortem?: {
|
|
47
|
+
kb_draft_id?: string
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const SENSITIVITY_COLORS = {
|
|
53
|
+
high: 'bg-red-100 text-red-700 border-red-200',
|
|
54
|
+
medium: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
|
55
|
+
low: 'bg-blue-100 text-blue-700 border-blue-200',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SENSITIVITY_ICONS = {
|
|
59
|
+
high: AlertCircle,
|
|
60
|
+
medium: AlertTriangle,
|
|
61
|
+
low: EyeOff,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function parseContentWithRedactions(content: string, suggestions: RedactionSuggestion[]): (string | RedactionSuggestion)[] {
|
|
65
|
+
if (!suggestions.length) return [content]
|
|
66
|
+
|
|
67
|
+
const parts: (string | RedactionSuggestion)[] = []
|
|
68
|
+
let lastIndex = 0
|
|
69
|
+
|
|
70
|
+
// Sort by start index
|
|
71
|
+
const sorted = [...suggestions].sort((a, b) => a.start_index - b.start_index)
|
|
72
|
+
|
|
73
|
+
for (const suggestion of sorted) {
|
|
74
|
+
if (suggestion.start_index > lastIndex) {
|
|
75
|
+
parts.push(content.slice(lastIndex, suggestion.start_index))
|
|
76
|
+
}
|
|
77
|
+
parts.push(suggestion)
|
|
78
|
+
lastIndex = suggestion.end_index
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (lastIndex < content.length) {
|
|
82
|
+
parts.push(content.slice(lastIndex))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return parts
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export default function RedactionReview() {
|
|
89
|
+
const { id } = useParams<{ id: string }>()
|
|
90
|
+
const navigate = useNavigate()
|
|
91
|
+
const [ticket, setTicket] = useState<Ticket | null>(null)
|
|
92
|
+
const [analysis, setAnalysis] = useState<RedactionAnalysis | null>(null)
|
|
93
|
+
const [loading, setLoading] = useState(true)
|
|
94
|
+
const [processing, setProcessing] = useState(false)
|
|
95
|
+
const [acceptedSuggestions, setAcceptedSuggestions] = useState<Set<string>>(new Set())
|
|
96
|
+
const [rejectedSuggestions, setRejectedSuggestions] = useState<Set<string>>(new Set())
|
|
97
|
+
const [customRedactions, setCustomRedactions] = useState<RedactionSuggestion[]>([])
|
|
98
|
+
const [newRedaction, setNewRedaction] = useState({ text: '', reason: '', level: 'medium' as const })
|
|
99
|
+
const [finalContent, setFinalContent] = useState('')
|
|
100
|
+
const [articleTitle, setArticleTitle] = useState('')
|
|
101
|
+
const [viewMode, setViewMode] = useState<'review' | 'final'>('review')
|
|
102
|
+
|
|
103
|
+
// Load ticket and trigger redaction analysis
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!id) return
|
|
106
|
+
|
|
107
|
+
const loadAndAnalyze = async () => {
|
|
108
|
+
setLoading(true)
|
|
109
|
+
try {
|
|
110
|
+
// Load ticket
|
|
111
|
+
const tRes = await apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`).then(r => r.json())
|
|
112
|
+
const ticketData = tRes?.data ?? tRes ?? null
|
|
113
|
+
setTicket(ticketData)
|
|
114
|
+
|
|
115
|
+
// Generate article title from ticket
|
|
116
|
+
if (ticketData?.title) {
|
|
117
|
+
setArticleTitle(`KB: ${ticketData.title}`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Trigger redaction analysis via AI agent
|
|
121
|
+
const analysisRes = await apiFetch('/api/ai-agents', {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
action: 'run_redaction_analysis',
|
|
126
|
+
ticket_id: id,
|
|
127
|
+
content: buildContentForAnalysis(ticketData),
|
|
128
|
+
}),
|
|
129
|
+
}).then(r => r.json())
|
|
130
|
+
|
|
131
|
+
if (analysisRes?.analysis) {
|
|
132
|
+
setAnalysis(analysisRes.analysis)
|
|
133
|
+
// Initially accept all high-sensitivity suggestions
|
|
134
|
+
const autoAccept = new Set(
|
|
135
|
+
analysisRes.analysis.suggestions
|
|
136
|
+
.filter((s: RedactionSuggestion) => s.sensitivity_level === 'high')
|
|
137
|
+
.map((s: RedactionSuggestion) => s.id)
|
|
138
|
+
)
|
|
139
|
+
setAcceptedSuggestions(autoAccept)
|
|
140
|
+
updateFinalContent(analysisRes.analysis, autoAccept, new Set(), [])
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
console.error('Failed to analyze:', err)
|
|
144
|
+
} finally {
|
|
145
|
+
setLoading(false)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
loadAndAnalyze()
|
|
150
|
+
}, [id])
|
|
151
|
+
|
|
152
|
+
// Build content from ticket for analysis
|
|
153
|
+
function buildContentForAnalysis(ticket: Ticket): string {
|
|
154
|
+
const parts: string[] = []
|
|
155
|
+
if (ticket.title) parts.push(`Title: ${ticket.title}`)
|
|
156
|
+
if (ticket.description) parts.push(`Description: ${ticket.description}`)
|
|
157
|
+
if (ticket.data?.ai_metadata?.problem_statement) {
|
|
158
|
+
parts.push(`Problem: ${ticket.data.ai_metadata.problem_statement}`)
|
|
159
|
+
}
|
|
160
|
+
if (ticket.data?.ai_metadata?.solution_path) {
|
|
161
|
+
parts.push(`Solution: ${ticket.data.ai_metadata.solution_path}`)
|
|
162
|
+
}
|
|
163
|
+
return parts.join('\n\n')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Update final content based on accepted/rejected suggestions
|
|
167
|
+
const updateFinalContent = useCallback((
|
|
168
|
+
analysis: RedactionAnalysis | null,
|
|
169
|
+
accepted: Set<string>,
|
|
170
|
+
rejected: Set<string>,
|
|
171
|
+
custom: RedactionSuggestion[]
|
|
172
|
+
) => {
|
|
173
|
+
if (!analysis) return
|
|
174
|
+
|
|
175
|
+
let content = analysis.original_content
|
|
176
|
+
|
|
177
|
+
// Get all accepted redactions (both AI and custom), sorted by position (reverse)
|
|
178
|
+
const allAccepted = [
|
|
179
|
+
...analysis.suggestions.filter(s => accepted.has(s.id) && !rejected.has(s.id)),
|
|
180
|
+
...custom.filter(c => !rejected.has(c.id)),
|
|
181
|
+
].sort((a, b) => b.start_index - a.start_index)
|
|
182
|
+
|
|
183
|
+
// Apply redactions from end to start to preserve indices
|
|
184
|
+
for (const redaction of allAccepted) {
|
|
185
|
+
content = content.slice(0, redaction.start_index) + redaction.redacted_text + content.slice(redaction.end_index)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
setFinalContent(content)
|
|
189
|
+
}, [])
|
|
190
|
+
|
|
191
|
+
// Toggle suggestion acceptance
|
|
192
|
+
const toggleSuggestion = (suggestionId: string) => {
|
|
193
|
+
const newAccepted = new Set(acceptedSuggestions)
|
|
194
|
+
const newRejected = new Set(rejectedSuggestions)
|
|
195
|
+
|
|
196
|
+
if (newAccepted.has(suggestionId)) {
|
|
197
|
+
newAccepted.delete(suggestionId)
|
|
198
|
+
newRejected.add(suggestionId)
|
|
199
|
+
} else {
|
|
200
|
+
newAccepted.add(suggestionId)
|
|
201
|
+
newRejected.delete(suggestionId)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
setAcceptedSuggestions(newAccepted)
|
|
205
|
+
setRejectedSuggestions(newRejected)
|
|
206
|
+
updateFinalContent(analysis, newAccepted, newRejected, customRedactions)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Add custom redaction
|
|
210
|
+
const addCustomRedaction = () => {
|
|
211
|
+
if (!newRedaction.text || !analysis) return
|
|
212
|
+
|
|
213
|
+
const startIndex = analysis.original_content.indexOf(newRedaction.text)
|
|
214
|
+
if (startIndex === -1) return
|
|
215
|
+
|
|
216
|
+
const customRedaction: RedactionSuggestion = {
|
|
217
|
+
id: `custom-${Date.now()}`,
|
|
218
|
+
start_index: startIndex,
|
|
219
|
+
end_index: startIndex + newRedaction.text.length,
|
|
220
|
+
original_text: newRedaction.text,
|
|
221
|
+
redacted_text: '[REDACTED]',
|
|
222
|
+
sensitivity_level: newRedaction.level,
|
|
223
|
+
reasoning: newRedaction.reason || 'Manually added',
|
|
224
|
+
category: 'account_specific',
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const newCustom = [...customRedactions, customRedaction]
|
|
228
|
+
setCustomRedactions(newCustom)
|
|
229
|
+
setAcceptedSuggestions(new Set([...acceptedSuggestions, customRedaction.id]))
|
|
230
|
+
setNewRedaction({ text: '', reason: '', level: 'medium' })
|
|
231
|
+
updateFinalContent(analysis, acceptedSuggestions, rejectedSuggestions, newCustom)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Publish KB article
|
|
235
|
+
const publishKB = async () => {
|
|
236
|
+
if (!id || !finalContent || !articleTitle) return
|
|
237
|
+
|
|
238
|
+
setProcessing(true)
|
|
239
|
+
try {
|
|
240
|
+
const kbArticleTypeId = await resolveTypeId('kb_article')
|
|
241
|
+
// Create KB article
|
|
242
|
+
const kbRes = await apiFetch('/api/admin-data?action=create&entity=items', {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'Content-Type': 'application/json' },
|
|
245
|
+
body: JSON.stringify({
|
|
246
|
+
type_id: kbArticleTypeId,
|
|
247
|
+
title: articleTitle,
|
|
248
|
+
status: 'published',
|
|
249
|
+
description: finalContent,
|
|
250
|
+
data: {
|
|
251
|
+
kb_type: 'article',
|
|
252
|
+
priority: 'medium',
|
|
253
|
+
security_level: 'internal',
|
|
254
|
+
source_info: {
|
|
255
|
+
source_type: 'redaction_review',
|
|
256
|
+
source_ticket_id: id,
|
|
257
|
+
redaction_review: {
|
|
258
|
+
ai_confidence: analysis?.confidence_score,
|
|
259
|
+
accepted_redactions: acceptedSuggestions.size,
|
|
260
|
+
rejected_redactions: rejectedSuggestions.size,
|
|
261
|
+
custom_redactions: customRedactions.length,
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
}).then(r => r.json())
|
|
267
|
+
|
|
268
|
+
if (kbRes?.id || kbRes?.data?.id) {
|
|
269
|
+
const kbId = kbRes.id || kbRes.data.id
|
|
270
|
+
|
|
271
|
+
// Update ticket with KB reference
|
|
272
|
+
await apiFetch(`/api/admin-data?action=update&entity=items&id=${id}`, {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
headers: { 'Content-Type': 'application/json' },
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
data: {
|
|
277
|
+
...ticket?.data,
|
|
278
|
+
postmortem: {
|
|
279
|
+
...ticket?.data?.postmortem,
|
|
280
|
+
kb_generated: true,
|
|
281
|
+
kb_draft_id: kbId,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
navigate(`/cortex/kb/${kbId}`)
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
setProcessing(false)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (loading) {
|
|
295
|
+
return (
|
|
296
|
+
<div className="p-6 space-y-4">
|
|
297
|
+
<div className="h-8 w-64 bg-muted rounded animate-pulse" />
|
|
298
|
+
<div className="h-64 bg-muted rounded animate-pulse" />
|
|
299
|
+
</div>
|
|
300
|
+
)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (!analysis) {
|
|
304
|
+
return (
|
|
305
|
+
<div className="p-6">
|
|
306
|
+
<div className="flex items-center gap-2 text-amber-600 mb-4">
|
|
307
|
+
<AlertTriangle className="h-5 w-5" />
|
|
308
|
+
<p>Failed to analyze content for redaction. Please try again.</p>
|
|
309
|
+
</div>
|
|
310
|
+
<Button onClick={() => navigate(`/cortex/support/${id}`)}>Back to Ticket</Button>
|
|
311
|
+
</div>
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div className="flex flex-col h-full">
|
|
317
|
+
{/* Header */}
|
|
318
|
+
<div className="px-6 py-4 border-b border-border shrink-0">
|
|
319
|
+
<div className="flex items-center justify-between">
|
|
320
|
+
<div className="flex items-center gap-3">
|
|
321
|
+
<Button variant="ghost" size="sm" onClick={() => navigate(`/cortex/support/${id}`)}>
|
|
322
|
+
<ArrowLeft className="h-4 w-4" />
|
|
323
|
+
</Button>
|
|
324
|
+
<div>
|
|
325
|
+
<h1 className="text-lg font-semibold flex items-center gap-2">
|
|
326
|
+
<BookOpen className="h-5 w-5 text-purple-600" />
|
|
327
|
+
KB Article Redaction Review
|
|
328
|
+
</h1>
|
|
329
|
+
<p className="text-xs text-muted-foreground">Review AI suggestions before publishing</p>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
<div className="flex items-center gap-2">
|
|
333
|
+
<Button
|
|
334
|
+
variant={viewMode === 'review' ? 'default' : 'outline'}
|
|
335
|
+
size="sm"
|
|
336
|
+
onClick={() => setViewMode('review')}
|
|
337
|
+
>
|
|
338
|
+
<Eye className="h-4 w-4 mr-1" />
|
|
339
|
+
Review
|
|
340
|
+
</Button>
|
|
341
|
+
<Button
|
|
342
|
+
variant={viewMode === 'final' ? 'default' : 'outline'}
|
|
343
|
+
size="sm"
|
|
344
|
+
onClick={() => setViewMode('final')}
|
|
345
|
+
>
|
|
346
|
+
<CheckCircle className="h-4 w-4 mr-1" />
|
|
347
|
+
Final
|
|
348
|
+
</Button>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
{/* Content */}
|
|
354
|
+
<div className="flex flex-1 min-h-0">
|
|
355
|
+
{viewMode === 'review' ? (
|
|
356
|
+
<>
|
|
357
|
+
{/* Left: Content with Redactions */}
|
|
358
|
+
<div className="flex-1 min-w-0 border-r border-border">
|
|
359
|
+
<ScrollArea className="h-full p-6">
|
|
360
|
+
<div className="space-y-6">
|
|
361
|
+
{/* Title Input */}
|
|
362
|
+
<div className="space-y-2">
|
|
363
|
+
<Label>Article Title</Label>
|
|
364
|
+
<Input
|
|
365
|
+
value={articleTitle}
|
|
366
|
+
onChange={e => setArticleTitle(e.target.value)}
|
|
367
|
+
placeholder="KB Article Title"
|
|
368
|
+
/>
|
|
369
|
+
</div>
|
|
370
|
+
|
|
371
|
+
{/* Content Preview */}
|
|
372
|
+
<div className="space-y-2">
|
|
373
|
+
<Label>Content Preview</Label>
|
|
374
|
+
<div className="border rounded-lg p-4 bg-white whitespace-pre-wrap text-sm leading-relaxed">
|
|
375
|
+
{parseContentWithRedactions(
|
|
376
|
+
analysis.original_content,
|
|
377
|
+
analysis.suggestions.filter(s => acceptedSuggestions.has(s.id))
|
|
378
|
+
).map((part, i) => {
|
|
379
|
+
if (typeof part === 'string') {
|
|
380
|
+
return <span key={i}>{part}</span>
|
|
381
|
+
}
|
|
382
|
+
return (
|
|
383
|
+
<mark
|
|
384
|
+
key={i}
|
|
385
|
+
className={`px-1 rounded ${
|
|
386
|
+
part.sensitivity_level === 'high'
|
|
387
|
+
? 'bg-red-200'
|
|
388
|
+
: part.sensitivity_level === 'medium'
|
|
389
|
+
? 'bg-yellow-200'
|
|
390
|
+
: 'bg-blue-200'
|
|
391
|
+
}`}
|
|
392
|
+
title={part.reasoning}
|
|
393
|
+
>
|
|
394
|
+
{part.redacted_text}
|
|
395
|
+
</mark>
|
|
396
|
+
)
|
|
397
|
+
})}
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
401
|
+
{/* Custom Redaction */}
|
|
402
|
+
<div className="space-y-3 pt-4 border-t">
|
|
403
|
+
<Label>Add Custom Redaction</Label>
|
|
404
|
+
<div className="flex gap-2">
|
|
405
|
+
<Input
|
|
406
|
+
value={newRedaction.text}
|
|
407
|
+
onChange={e => setNewRedaction({ ...newRedaction, text: e.target.value })}
|
|
408
|
+
placeholder="Text to redact"
|
|
409
|
+
className="flex-1"
|
|
410
|
+
/>
|
|
411
|
+
<select
|
|
412
|
+
value={newRedaction.level}
|
|
413
|
+
onChange={e => setNewRedaction({ ...newRedaction, level: e.target.value as 'high' | 'medium' | 'low' })}
|
|
414
|
+
className="border rounded px-2 text-sm"
|
|
415
|
+
>
|
|
416
|
+
<option value="high">High</option>
|
|
417
|
+
<option value="medium">Medium</option>
|
|
418
|
+
<option value="low">Low</option>
|
|
419
|
+
</select>
|
|
420
|
+
</div>
|
|
421
|
+
<Input
|
|
422
|
+
value={newRedaction.reason}
|
|
423
|
+
onChange={e => setNewRedaction({ ...newRedaction, reason: e.target.value })}
|
|
424
|
+
placeholder="Reason for redaction"
|
|
425
|
+
/>
|
|
426
|
+
<Button onClick={addCustomRedaction} size="sm">
|
|
427
|
+
Add Redaction
|
|
428
|
+
</Button>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</ScrollArea>
|
|
432
|
+
</div>
|
|
433
|
+
|
|
434
|
+
{/* Right: Suggestions List */}
|
|
435
|
+
<div className="w-96 shrink-0 flex flex-col">
|
|
436
|
+
<div className="px-4 py-3 border-b border-border bg-muted/30">
|
|
437
|
+
<p className="text-sm font-medium">Redaction Suggestions</p>
|
|
438
|
+
<p className="text-xs text-muted-foreground">
|
|
439
|
+
{acceptedSuggestions.size} accepted · {rejectedSuggestions.size} rejected
|
|
440
|
+
</p>
|
|
441
|
+
</div>
|
|
442
|
+
<ScrollArea className="flex-1 p-4">
|
|
443
|
+
<div className="space-y-3">
|
|
444
|
+
{analysis.suggestions.map(suggestion => {
|
|
445
|
+
const isAccepted = acceptedSuggestions.has(suggestion.id)
|
|
446
|
+
const isRejected = rejectedSuggestions.has(suggestion.id)
|
|
447
|
+
const Icon = SENSITIVITY_ICONS[suggestion.sensitivity_level]
|
|
448
|
+
|
|
449
|
+
return (
|
|
450
|
+
<div
|
|
451
|
+
key={suggestion.id}
|
|
452
|
+
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
|
|
453
|
+
isAccepted
|
|
454
|
+
? 'border-green-300 bg-green-50'
|
|
455
|
+
: isRejected
|
|
456
|
+
? 'border-gray-300 bg-gray-50 opacity-50'
|
|
457
|
+
: 'border-amber-300 bg-amber-50'
|
|
458
|
+
}`}
|
|
459
|
+
onClick={() => toggleSuggestion(suggestion.id)}
|
|
460
|
+
>
|
|
461
|
+
<div className="flex items-start gap-2">
|
|
462
|
+
<Checkbox checked={isAccepted} className="mt-0.5" />
|
|
463
|
+
<div className="flex-1 min-w-0">
|
|
464
|
+
<div className="flex items-center gap-2 mb-1">
|
|
465
|
+
<Badge
|
|
466
|
+
variant="outline"
|
|
467
|
+
className={`text-xs ${SENSITIVITY_COLORS[suggestion.sensitivity_level]}`}
|
|
468
|
+
>
|
|
469
|
+
<Icon className="h-3 w-3 mr-1" />
|
|
470
|
+
{suggestion.sensitivity_level}
|
|
471
|
+
</Badge>
|
|
472
|
+
<span className="text-xs text-muted-foreground uppercase">{suggestion.category}</span>
|
|
473
|
+
</div>
|
|
474
|
+
<p className="text-sm font-mono truncate">"{suggestion.original_text}"</p>
|
|
475
|
+
<p className="text-xs text-muted-foreground mt-1">→ "{suggestion.redacted_text}"</p>
|
|
476
|
+
<p className="text-xs text-muted-foreground mt-2 italic">{suggestion.reasoning}</p>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
)
|
|
481
|
+
})}
|
|
482
|
+
|
|
483
|
+
{customRedactions.map(redaction => (
|
|
484
|
+
<div
|
|
485
|
+
key={redaction.id}
|
|
486
|
+
className={`border rounded-lg p-3 border-purple-300 bg-purple-50 ${
|
|
487
|
+
rejectedSuggestions.has(redaction.id) ? 'opacity-50' : ''
|
|
488
|
+
}`}
|
|
489
|
+
onClick={() => {
|
|
490
|
+
const newRejected = new Set(rejectedSuggestions)
|
|
491
|
+
if (newRejected.has(redaction.id)) {
|
|
492
|
+
newRejected.delete(redaction.id)
|
|
493
|
+
} else {
|
|
494
|
+
newRejected.add(redaction.id)
|
|
495
|
+
}
|
|
496
|
+
setRejectedSuggestions(newRejected)
|
|
497
|
+
}}
|
|
498
|
+
>
|
|
499
|
+
<div className="flex items-start gap-2">
|
|
500
|
+
<Checkbox checked={!rejectedSuggestions.has(redaction.id)} className="mt-0.5" />
|
|
501
|
+
<div className="flex-1 min-w-0">
|
|
502
|
+
<Badge variant="outline" className="text-xs">
|
|
503
|
+
Custom
|
|
504
|
+
</Badge>
|
|
505
|
+
<p className="text-sm font-mono mt-1 truncate">"{redaction.original_text}"</p>
|
|
506
|
+
<p className="text-xs text-muted-foreground mt-1">{redaction.reasoning}</p>
|
|
507
|
+
</div>
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
))}
|
|
511
|
+
</div>
|
|
512
|
+
</ScrollArea>
|
|
513
|
+
|
|
514
|
+
{/* Action Bar */}
|
|
515
|
+
<div className="border-t p-4 space-y-2">
|
|
516
|
+
<div className="flex items-center justify-between text-sm">
|
|
517
|
+
<span className="text-muted-foreground">AI Confidence</span>
|
|
518
|
+
<span className="font-medium">{Math.round(analysis.confidence_score * 100)}%</span>
|
|
519
|
+
</div>
|
|
520
|
+
<Button
|
|
521
|
+
className="w-full"
|
|
522
|
+
onClick={() => setViewMode('final')}
|
|
523
|
+
disabled={!articleTitle}
|
|
524
|
+
>
|
|
525
|
+
<CheckCircle className="h-4 w-4 mr-1" />
|
|
526
|
+
Preview Final
|
|
527
|
+
</Button>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
</>
|
|
531
|
+
) : (
|
|
532
|
+
// Final Preview Mode
|
|
533
|
+
<div className="flex-1 min-w-0">
|
|
534
|
+
<ScrollArea className="h-full p-6">
|
|
535
|
+
<div className="max-w-2xl mx-auto space-y-6">
|
|
536
|
+
<div className="space-y-2">
|
|
537
|
+
<h2 className="text-2xl font-bold">{articleTitle}</h2>
|
|
538
|
+
<p className="text-sm text-muted-foreground">
|
|
539
|
+
Generated from support ticket · {acceptedSuggestions.size} redactions applied
|
|
540
|
+
</p>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
<div className="prose prose-sm max-w-none whitespace-pre-wrap border rounded-lg p-6 bg-white">
|
|
544
|
+
{finalContent}
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<div className="flex gap-2 pt-4">
|
|
548
|
+
<Button variant="outline" onClick={() => setViewMode('review')}>
|
|
549
|
+
Back to Review
|
|
550
|
+
</Button>
|
|
551
|
+
<Button onClick={publishKB} disabled={processing || !articleTitle}>
|
|
552
|
+
{processing ? 'Publishing...' : 'Publish KB Article'}
|
|
553
|
+
</Button>
|
|
554
|
+
</div>
|
|
555
|
+
</div>
|
|
556
|
+
</ScrollArea>
|
|
557
|
+
</div>
|
|
558
|
+
)}
|
|
559
|
+
</div>
|
|
560
|
+
</div>
|
|
561
|
+
)
|
|
562
|
+
}
|