import { useState, useEffect, useCallback } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useAppPath } from '@core/hooks/useAppPath' import { apiFetch } from '@core/lib/api' import { Button } from '@core/components/ui/button' import { Badge } from '@core/components/ui/badge' import { ScrollArea } from '@core/components/ui/scroll-area' import { Textarea } from '@core/components/ui/textarea' import { Input } from '@core/components/ui/input' import { Label } from '@core/components/ui/label' import { Checkbox } from '@core/components/ui/checkbox' import { ArrowLeft, AlertCircle, EyeOff, Eye, CheckCircle, BookOpen, AlertTriangle } from 'lucide-react' interface RedactionSuggestion { id: string start_index: number end_index: number original_text: string redacted_text: string sensitivity_level: 'high' | 'medium' | 'low' reasoning: string category: 'pii' | 'confidential' | 'account_specific' | 'internal_reference' } interface RedactionAnalysis { original_content: string redacted_content: string suggestions: RedactionSuggestion[] confidence_score: number processing_metadata: { model_used: string temperature: number tokens_consumed: number } } interface Ticket { id: string title: string description?: string data?: { // Case analysis fields (populated by custom_case_analysis) ca_reported_issue?: string ca_true_problem?: string ca_final_solution?: string ca_diagnostic_steps?: string[] ca_solution_steps?: string[] // Legacy fields ai_metadata?: { problem_statement?: string solution_path?: string } // KB tracking kb_proposed_kb_id?: string } } const SENSITIVITY_COLORS = { high: 'bg-red-100 text-red-700 border-red-200', medium: 'bg-yellow-100 text-yellow-700 border-yellow-200', low: 'bg-blue-100 text-blue-700 border-blue-200', } const SENSITIVITY_ICONS = { high: AlertCircle, medium: AlertTriangle, low: EyeOff, } function parseContentWithRedactions(content: string, suggestions: RedactionSuggestion[]): (string | RedactionSuggestion)[] { if (!suggestions.length) return [content] const parts: (string | RedactionSuggestion)[] = [] let lastIndex = 0 // Sort by start index const sorted = [...suggestions].sort((a, b) => a.start_index - b.start_index) for (const suggestion of sorted) { if (suggestion.start_index > lastIndex) { parts.push(content.slice(lastIndex, suggestion.start_index)) } parts.push(suggestion) lastIndex = suggestion.end_index } if (lastIndex < content.length) { parts.push(content.slice(lastIndex)) } return parts } export default function RedactionReview() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const appPath = useAppPath() const [ticket, setTicket] = useState(null) const [analysis, setAnalysis] = useState(null) const [loading, setLoading] = useState(true) const [processing, setProcessing] = useState(false) const [acceptedSuggestions, setAcceptedSuggestions] = useState>(new Set()) const [rejectedSuggestions, setRejectedSuggestions] = useState>(new Set()) const [customRedactions, setCustomRedactions] = useState([]) const [newRedaction, setNewRedaction] = useState({ text: '', reason: '', level: 'medium' as const }) const [finalContent, setFinalContent] = useState('') const [articleTitle, setArticleTitle] = useState('') const [viewMode, setViewMode] = useState<'review' | 'final'>('review') // Load ticket and trigger redaction analysis useEffect(() => { if (!id) return const loadAndAnalyze = async () => { setLoading(true) try { // Load ticket const tRes = await apiFetch(`/api/admin-data?action=get&entity=items&id=${id}`).then(r => r.json()) const ticketData = tRes?.data ?? tRes ?? null setTicket(ticketData) // Generate article title from ticket if (ticketData?.title) { setArticleTitle(`KB: ${ticketData.title}`) } // Trigger redaction analysis via KB Redaction Agent const analysisRes = await apiFetch('/.netlify/functions/custom_support-redaction?action=analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ticket_id: id, content: buildContentForAnalysis(ticketData), }), }).then(r => r.json()) if (analysisRes?.analysis) { setAnalysis(analysisRes.analysis) // Initially accept all high-sensitivity suggestions const autoAccept = new Set( analysisRes.analysis.suggestions .filter((s: RedactionSuggestion) => s.sensitivity_level === 'high') .map((s: RedactionSuggestion) => s.id) ) setAcceptedSuggestions(autoAccept) updateFinalContent(analysisRes.analysis, autoAccept, new Set(), []) } } catch (err) { console.error('Failed to analyze:', err) } finally { setLoading(false) } } loadAndAnalyze() }, [id]) // Build content from ticket for redaction analysis. // Prefers case analysis fields (ca_*) populated by custom_case_analysis, // falling back to legacy ai_metadata fields. function buildContentForAnalysis(ticket: Ticket): string { const d = ticket.data || {} const parts: string[] = [] if (ticket.title) parts.push(`Title: ${ticket.title}`) if (d.ca_reported_issue) { parts.push(`Reported Issue: ${d.ca_reported_issue}`) } else if (ticket.description) { parts.push(`Description: ${ticket.description}`) } if (d.ca_true_problem) { parts.push(`Root Cause: ${d.ca_true_problem}`) } else if (d.ai_metadata?.problem_statement) { parts.push(`Problem: ${d.ai_metadata.problem_statement}`) } if (d.ca_diagnostic_steps?.length) { parts.push(`Diagnostic Steps:\n${d.ca_diagnostic_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`) } if (d.ca_solution_steps?.length) { parts.push(`Solution Steps:\n${d.ca_solution_steps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`) } if (d.ca_final_solution) { parts.push(`Final Solution: ${d.ca_final_solution}`) } else if (d.ai_metadata?.solution_path) { parts.push(`Solution: ${d.ai_metadata.solution_path}`) } return parts.join('\n\n') } // Update final content based on accepted/rejected suggestions const updateFinalContent = useCallback(( analysis: RedactionAnalysis | null, accepted: Set, rejected: Set, custom: RedactionSuggestion[] ) => { if (!analysis) return let content = analysis.original_content // Get all accepted redactions (both AI and custom), sorted by position (reverse) const allAccepted = [ ...analysis.suggestions.filter(s => accepted.has(s.id) && !rejected.has(s.id)), ...custom.filter(c => !rejected.has(c.id)), ].sort((a, b) => b.start_index - a.start_index) // Apply redactions from end to start to preserve indices for (const redaction of allAccepted) { content = content.slice(0, redaction.start_index) + redaction.redacted_text + content.slice(redaction.end_index) } setFinalContent(content) }, []) // Toggle suggestion acceptance const toggleSuggestion = (suggestionId: string) => { const newAccepted = new Set(acceptedSuggestions) const newRejected = new Set(rejectedSuggestions) if (newAccepted.has(suggestionId)) { newAccepted.delete(suggestionId) newRejected.add(suggestionId) } else { newAccepted.add(suggestionId) newRejected.delete(suggestionId) } setAcceptedSuggestions(newAccepted) setRejectedSuggestions(newRejected) updateFinalContent(analysis, newAccepted, newRejected, customRedactions) } // Add custom redaction const addCustomRedaction = () => { if (!newRedaction.text || !analysis) return const startIndex = analysis.original_content.indexOf(newRedaction.text) if (startIndex === -1) return const customRedaction: RedactionSuggestion = { id: `custom-${Date.now()}`, start_index: startIndex, end_index: startIndex + newRedaction.text.length, original_text: newRedaction.text, redacted_text: '[REDACTED]', sensitivity_level: newRedaction.level, reasoning: newRedaction.reason || 'Manually added', category: 'account_specific', } const newCustom = [...customRedactions, customRedaction] setCustomRedactions(newCustom) setAcceptedSuggestions(new Set([...acceptedSuggestions, customRedaction.id])) setNewRedaction({ text: '', reason: '', level: 'medium' }) updateFinalContent(analysis, acceptedSuggestions, rejectedSuggestions, newCustom) } // Publish KB article const publishKB = async () => { if (!id || !finalContent || !articleTitle) return setProcessing(true) try { // Create KB article const kbRes = await apiFetch('/api/admin-data?action=create&entity=items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type_slug: 'kb_article', title: articleTitle, status: 'published', description: finalContent, data: { kb_type: 'article', priority: 'medium', security_level: 'internal', source_info: { source_type: 'redaction_review', source_ticket_id: id, redaction_review: { ai_confidence: analysis?.confidence_score, accepted_redactions: acceptedSuggestions.size, rejected_redactions: rejectedSuggestions.size, custom_redactions: customRedactions.length, }, }, }, }), }).then(r => r.json()) if (kbRes?.id || kbRes?.data?.id) { const kbId = kbRes.id || kbRes.data.id // Update ticket with KB reference await apiFetch(`/api/admin-data?action=update&entity=items&id=${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: { ...ticket?.data, kb_proposed_kb_id: kbId, }, }), }) navigate(appPath(`/kb/${kbId}`)) } } finally { setProcessing(false) } } if (loading) { return (
) } if (!analysis) { return (

Failed to analyze content for redaction. Please try again.

) } return (
{/* Header */}

KB Article Redaction Review

Review AI suggestions before publishing

{/* Content */}
{viewMode === 'review' ? ( <> {/* Left: Content with Redactions */}
{/* Title Input */}
setArticleTitle(e.target.value)} placeholder="KB Article Title" />
{/* Content Preview */}
{parseContentWithRedactions( analysis.original_content, analysis.suggestions.filter(s => acceptedSuggestions.has(s.id)) ).map((part, i) => { if (typeof part === 'string') { return {part} } return ( {part.redacted_text} ) })}
{/* Custom Redaction */}
setNewRedaction({ ...newRedaction, text: e.target.value })} placeholder="Text to redact" className="flex-1" />
setNewRedaction({ ...newRedaction, reason: e.target.value })} placeholder="Reason for redaction" />
{/* Right: Suggestions List */}

Redaction Suggestions

{acceptedSuggestions.size} accepted · {rejectedSuggestions.size} rejected

{analysis.suggestions.map(suggestion => { const isAccepted = acceptedSuggestions.has(suggestion.id) const isRejected = rejectedSuggestions.has(suggestion.id) const Icon = SENSITIVITY_ICONS[suggestion.sensitivity_level] return (
toggleSuggestion(suggestion.id)} >
{suggestion.sensitivity_level} {suggestion.category}

"{suggestion.original_text}"

→ "{suggestion.redacted_text}"

{suggestion.reasoning}

) })} {customRedactions.map(redaction => (
{ const newRejected = new Set(rejectedSuggestions) if (newRejected.has(redaction.id)) { newRejected.delete(redaction.id) } else { newRejected.add(redaction.id) } setRejectedSuggestions(newRejected) }} >
Custom

"{redaction.original_text}"

{redaction.reasoning}

))}
{/* Action Bar */}
AI Confidence {Math.round(analysis.confidence_score * 100)}%
) : ( // Final Preview Mode

{articleTitle}

Generated from support ticket · {acceptedSuggestions.size} redactions applied

{finalContent}
)}
) }