From 68dd8d354423e3b6034470ea6c353da2fa0ab1fa Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Sat, 7 Feb 2026 16:43:38 -0600 Subject: [PATCH] feat: AI Audit Log admin page with filters, feedback training, and export --- admin/src/app/ai-audit-log/page.tsx | 463 ++++++++++++++++++++++++++++ admin/src/components/Sidebar.tsx | 3 +- admin/src/lib/api.ts | 23 ++ 3 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 admin/src/app/ai-audit-log/page.tsx diff --git a/admin/src/app/ai-audit-log/page.tsx b/admin/src/app/ai-audit-log/page.tsx new file mode 100644 index 0000000..09a7190 --- /dev/null +++ b/admin/src/app/ai-audit-log/page.tsx @@ -0,0 +1,463 @@ +'use client'; + +import AdminShell from '@/components/AdminShell'; +import { api } from '@/lib/api'; +import { formatDateTime } from '@/lib/utils'; +import { useEffect, useState, useCallback } from 'react'; +import { + ScrollText, Search, ThumbsUp, ThumbsDown, Download, + ChevronLeft, ChevronRight, Filter, MessageSquare, FileText, + CheckCircle, XCircle, AlertTriangle, Eye, +} from 'lucide-react'; + +function ScoreBar({ label, value }: { label: string; value: number }) { + const pct = Math.round(value * 100); + const color = pct > 70 ? 'bg-red-500' : pct > 40 ? 'bg-yellow-500' : 'bg-green-500'; + return ( +
+ {label} +
+
+
+ {pct}% +
+ ); +} + +function DecisionBadge({ decision }: { decision: string }) { + const styles: Record = { + pass: 'bg-green-50 text-green-700 border-green-200', + flag: 'bg-red-50 text-red-700 border-red-200', + nsfw: 'bg-amber-50 text-amber-700 border-amber-200', + }; + const icons: Record = { + pass: , + flag: , + nsfw: , + }; + return ( + + {icons[decision]} {decision.toUpperCase()} + + ); +} + +function FeedbackBadge({ correct }: { correct: boolean | null }) { + if (correct === null || correct === undefined) { + return Not reviewed; + } + return correct ? ( + + Correct + + ) : ( + + Incorrect + + ); +} + +export default function AIAuditLogPage() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(0); + const limit = 25; + + // Filters + const [decisionFilter, setDecisionFilter] = useState(''); + const [contentTypeFilter, setContentTypeFilter] = useState(''); + const [feedbackFilter, setFeedbackFilter] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [searchInput, setSearchInput] = useState(''); + + // Feedback modal + const [feedbackId, setFeedbackId] = useState(null); + const [feedbackCorrect, setFeedbackCorrect] = useState(null); + const [feedbackReason, setFeedbackReason] = useState(''); + const [submitting, setSubmitting] = useState(false); + + // Expanded row + const [expandedId, setExpandedId] = useState(null); + + const fetchLog = useCallback(() => { + setLoading(true); + api.getAIModerationLog({ + limit, + offset: page * limit, + decision: decisionFilter || undefined, + content_type: contentTypeFilter || undefined, + search: searchQuery || undefined, + feedback: feedbackFilter || undefined, + }) + .then((data) => { + setItems(data.items || []); + setTotal(data.total || 0); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [page, decisionFilter, contentTypeFilter, feedbackFilter, searchQuery]); + + useEffect(() => { fetchLog(); }, [fetchLog]); + + const handleSearch = () => { + setPage(0); + setSearchQuery(searchInput); + }; + + const handleFeedbackSubmit = async () => { + if (!feedbackId || feedbackCorrect === null || !feedbackReason.trim()) return; + setSubmitting(true); + try { + await api.submitAIModerationFeedback(feedbackId, feedbackCorrect, feedbackReason); + setFeedbackId(null); + setFeedbackCorrect(null); + setFeedbackReason(''); + fetchLog(); + } catch (e: any) { + alert(`Failed: ${e.message}`); + } + setSubmitting(false); + }; + + const handleExport = async () => { + try { + const data = await api.exportAITrainingData(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `ai-training-data-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + } catch (e: any) { + alert(`Export failed: ${e.message}`); + } + }; + + const totalPages = Math.ceil(total / limit); + + const feedbackPresets = [ + 'AI correctly identified harmful content', + 'AI correctly passed safe content', + 'False positive — content was actually fine', + 'False negative — content should have been flagged', + 'AI flagged satire/humor incorrectly', + 'Threshold too sensitive for this type of content', + 'AI missed context — cultural/religious reference', + ]; + + return ( + + {/* Header */} +
+
+

+ + AI Moderation Audit Log +

+

+ {total} decisions logged · Review AI decisions and provide training feedback +

+
+ +
+ + {/* Filters */} +
+
+ + + {/* Search */} +
+ setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + /> + +
+ + {/* Decision filter */} + + + {/* Content type filter */} + + + {/* Feedback filter */} + + + {(decisionFilter || contentTypeFilter || feedbackFilter || searchQuery) && ( + + )} +
+
+ + {/* Table */} + {loading ? ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+ ))} +
+ ) : items.length === 0 ? ( +
+ +

No audit log entries found

+

AI moderation decisions will appear here as content is created.

+
+ ) : ( + <> +
+ {items.map((item) => ( +
+ {/* Main row */} +
setExpandedId(expandedId === item.id ? null : item.id)} + > +
+
+ {/* Top badges */} +
+ + + {item.content_type === 'post' ? : } + {item.content_type} + + {item.flag_reason && ( + + {item.flag_reason} + + )} + + {formatDateTime(item.created_at)} + +
+ + {/* Content snippet */} +

+ {item.content_snippet || No content} +

+ + {/* Author */} +

+ By @{item.author_handle || '—'} + {item.author_display_name && ` (${item.author_display_name})`} +

+
+ + {/* Right side: scores + feedback status */} +
+
+ + + +
+
+ +
+
+
+
+ + {/* Expanded detail */} + {expandedId === item.id && ( +
+
+
+

Content ID

+

{item.content_id}

+
+
+

AI Provider

+

{item.ai_provider || 'openai'}

+
+ {item.or_decision && ( +
+

OpenRouter Decision

+

{item.or_decision}

+
+ )} + {item.feedback_reason && ( +
+

Admin Feedback

+

{item.feedback_reason}

+ {item.feedback_at && ( +

Reviewed {formatDateTime(item.feedback_at)}

+ )} +
+ )} +
+ + {/* Feedback form */} + {item.feedback_correct === null || item.feedback_correct === undefined ? ( + feedbackId === item.id ? ( +
+

Train the AI — Was this decision correct?

+ + {/* Correct / Incorrect toggle */} +
+ + +
+ + {/* Preset reasons */} +
+ {feedbackPresets + .filter(p => { + if (feedbackCorrect === true) return p.startsWith('AI correctly'); + if (feedbackCorrect === false) return !p.startsWith('AI correctly'); + return true; + }) + .map((preset) => ( + + ))} +
+ + {/* Custom reason */} +