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 (
+
+ );
+}
+
+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 */}
+
+ ) : (
+
+ )
+ ) : (
+
+
+ Feedback already submitted
+
+ )}
+
+ )}
+
+ ))}
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+ Showing {page * limit + 1}–{Math.min((page + 1) * limit, total)} of {total}
+
+
+
+
+ Page {page + 1} of {totalPages}
+
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx
index a029d05..4ec08e0 100644
--- a/admin/src/components/Sidebar.tsx
+++ b/admin/src/components/Sidebar.tsx
@@ -6,7 +6,7 @@ import { useAuth } from '@/lib/auth';
import { cn } from '@/lib/utils';
import {
LayoutDashboard, Users, FileText, Shield, Scale, Flag,
- Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, HardDrive, AtSign, Brain,
+ Settings, Activity, LogOut, ChevronLeft, ChevronRight, Sliders, FolderTree, HardDrive, AtSign, Brain, ScrollText,
} from 'lucide-react';
import { useState } from 'react';
@@ -21,6 +21,7 @@ const navItems = [
{ href: '/categories', label: 'Categories', icon: FolderTree },
{ href: '/usernames', label: 'Usernames', icon: AtSign },
{ href: '/ai-moderation', label: 'AI Moderation', icon: Brain },
+ { href: '/ai-audit-log', label: 'AI Audit Log', icon: ScrollText },
{ href: '/storage', label: 'Storage', icon: HardDrive },
{ href: '/system', label: 'System Health', icon: Activity },
{ href: '/settings', label: 'Settings', icon: Settings },
diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts
index 80f726b..4d92cc0 100644
--- a/admin/src/lib/api.ts
+++ b/admin/src/lib/api.ts
@@ -352,6 +352,29 @@ class ApiClient {
body: JSON.stringify(data),
});
}
+
+ // AI Moderation Audit Log
+ async getAIModerationLog(params: { limit?: number; offset?: number; decision?: string; content_type?: string; search?: string; feedback?: string } = {}) {
+ const qs = new URLSearchParams();
+ if (params.limit) qs.set('limit', String(params.limit));
+ if (params.offset) qs.set('offset', String(params.offset));
+ if (params.decision) qs.set('decision', params.decision);
+ if (params.content_type) qs.set('content_type', params.content_type);
+ if (params.search) qs.set('search', params.search);
+ if (params.feedback) qs.set('feedback', params.feedback);
+ return this.request(`/api/v1/admin/ai/moderation-log?${qs}`);
+ }
+
+ async submitAIModerationFeedback(id: string, correct: boolean, reason: string) {
+ return this.request(`/api/v1/admin/ai/moderation-log/${id}/feedback`, {
+ method: 'POST',
+ body: JSON.stringify({ correct, reason }),
+ });
+ }
+
+ async exportAITrainingData() {
+ return this.request('/api/v1/admin/ai/training-data');
+ }
}
export const api = new ApiClient();