feat: expand AI moderation with structured analysis, review workflow, and guardrails

- Add structured AI moderation fields (categories, severity, confidence,
  recommended_action, policy_version, evidence) to messages table
- Add moderation_reviews, moderation_actions, and retention_policies tables
- Upgrade LLM response parsing to support structured metadata with backwards
  compatibility for legacy responses
- Implement public AI evaluation review UI with decision controls
  (approve, false positive + reanalyze, escalate)
- Add auto-delete guardrails requiring high confidence, severity, and
  allowed categories; log all attempts to moderation_actions
- Add retention manager scaffolding for messages/attachments/voice
- Add action executor for moderation actions (mute, warn, kick, ban)
- Add review routes: GET/POST/PATCH /api/reviews, GET/POST/PATCH /api/actions
- Preserve auth separation: voice/media/recordings gated, review public

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-30 01:02:51 +07:00
co-authored by Claude Opus 4.6
parent b938420eb3
commit c894e5cd75
15 changed files with 2104 additions and 27 deletions
+14
View File
@@ -1,4 +1,12 @@
export type AIStatus = "pending" | "clean" | "warn" | "flagged" | "error";
export type AISeverity = "none" | "low" | "medium" | "high" | "critical";
export type AIRecommendedAction =
| "none"
| "monitor"
| "warn"
| "review"
| "delete"
| "escalate";
export interface MessageRecord {
id: string;
@@ -20,6 +28,12 @@ export interface MessageRecord {
ai_moderation_score?: number | null;
ai_moderation_raw?: string | null;
ai_analysis?: string | null;
ai_categories?: string | null;
ai_severity?: AISeverity | null;
ai_confidence?: number | null;
ai_recommended_action?: AIRecommendedAction | null;
ai_policy_version?: string | null;
ai_evidence?: string | null;
ai_analyzed_at?: number | null;
ai_error?: string | null;
}
@@ -24,9 +24,25 @@ function getAiIcon(status: string) {
return null;
}
function parseStringList(value?: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
} catch {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
}
export function MessageCard({ message, onReanalyze }: MessageCardProps) {
const displayContent = message.edited_content ?? message.content;
const aiStatus = message.ai_status ?? "pending";
const categories = parseStringList(message.ai_categories ?? message.ai_moderation_flags);
const evidence = parseStringList(message.ai_evidence);
const confidence = message.ai_confidence ?? message.ai_moderation_score ?? null;
const [isReanalyzing, setIsReanalyzing] = useState(false);
const handleReanalyze = async () => {
@@ -62,11 +78,30 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) {
<p className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
{displayContent || "(empty message)"}
</p>
<div className="flex flex-wrap gap-2 text-xs">
{message.ai_severity ? <Badge variant="outline">severity: {message.ai_severity}</Badge> : null}
{message.ai_recommended_action ? <Badge variant="outline">action: {message.ai_recommended_action}</Badge> : null}
{confidence != null ? <Badge variant="outline">confidence: {Math.round(confidence * 100)}%</Badge> : null}
{message.ai_policy_version ? <Badge variant="outline">policy: {message.ai_policy_version}</Badge> : null}
{categories.slice(0, 6).map((category) => (
<Badge key={category} variant="secondary">{category}</Badge>
))}
</div>
{message.ai_analysis ? (
<div className="rounded-xl bg-muted p-3 text-sm text-muted-foreground">
{message.ai_analysis}
</div>
) : null}
{evidence.length > 0 ? (
<div className="rounded-xl border border-border bg-background/50 p-3 text-xs text-muted-foreground">
<div className="mb-1 font-medium text-foreground/80">Evidence</div>
<ul className="list-disc space-y-1 pl-4">
{evidence.slice(0, 4).map((item, index) => (
<li key={`${message.id}-evidence-${index}`}>{item}</li>
))}
</ul>
</div>
) : null}
{message.ai_error ? (
<div className="rounded-xl bg-destructive/10 p-3 text-sm text-destructive">
AI error: {message.ai_error}
+162 -15
View File
@@ -1,6 +1,12 @@
import { useMemo, useState } from "react";
import type { MessageRecord } from "../../types/messages";
import { MessageFeed } from "../messages/MessageFeed";
import { useReview, type ReviewStatus } from "../../hooks/useReview";
import { MessageCard } from "../messages/MessageCard";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Input } from "../ui/input";
import { Select } from "../ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
export interface ReviewPanelProps {
@@ -8,39 +14,180 @@ export interface ReviewPanelProps {
onReanalyze: (id: string) => void;
}
type ReviewFilter = "all" | "warn" | "flagged" | "error";
const statusOptions = [
{ value: "all", label: "All reviewable" },
{ value: "warn", label: "Warn" },
{ value: "flagged", label: "Flagged" },
{ value: "error", label: "Errors" },
];
function parseStringList(value?: string | null): string[] {
if (!value) return [];
try {
const parsed = JSON.parse(value) as unknown;
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === "string") : [];
} catch {
return value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
}
function ReviewDecisionControls({
message,
onReanalyze,
}: {
message: MessageRecord;
onReanalyze: (id: string) => void;
}) {
const { createReview, loading, error } = useReview();
const [notes, setNotes] = useState("");
const [reviewerId, setReviewerId] = useState("public-eval");
const [savedStatus, setSavedStatus] = useState<ReviewStatus | null>(null);
const submitDecision = async (status: ReviewStatus) => {
const review = await createReview({
message_id: message.id,
guild_id: message.guild_id,
channel_id: message.channel_id,
reviewer_id: reviewerId.trim() || "public-eval",
status,
notes: notes.trim() || null,
reviewed_at: Date.now(),
});
setSavedStatus(review.status);
if (status === "rejected") {
onReanalyze(message.id);
}
};
return (
<div className="rounded-xl border border-border bg-muted/30 p-3">
<div className="mb-2 flex flex-wrap items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">AI Eval Decision</span>
{savedStatus ? <Badge variant="success">saved: {savedStatus}</Badge> : null}
</div>
<div className="grid gap-2 md:grid-cols-[160px_1fr]">
<Input
value={reviewerId}
onChange={(event) => setReviewerId(event.target.value)}
placeholder="reviewer label"
/>
<Input
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder="reason / evaluation note"
/>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={loading} onClick={() => submitDecision("approved")}>
Approve AI
</Button>
<Button size="sm" variant="outline" disabled={loading} onClick={() => submitDecision("rejected")}>
False Positive + Reanalyze
</Button>
<Button size="sm" variant="outline" disabled={loading} onClick={() => submitDecision("escalated")}>
Escalate
</Button>
</div>
{error ? <div className="mt-2 text-xs text-destructive">{error}</div> : null}
</div>
);
}
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
const flaggedItems = messages.filter(
const [statusFilter, setStatusFilter] = useState<ReviewFilter>("all");
const [severityFilter, setSeverityFilter] = useState("");
const [categoryFilter, setCategoryFilter] = useState("");
const reviewable = useMemo(
() => messages.filter((message) => message.ai_status === "warn" || message.ai_status === "flagged" || message.ai_status === "error"),
[messages],
);
const categories = useMemo(() => {
const set = new Set<string>();
for (const message of reviewable) {
for (const category of parseStringList(message.ai_categories ?? message.ai_moderation_flags)) {
set.add(category);
}
}
return Array.from(set).sort();
}, [reviewable]);
const filtered = reviewable.filter((message) => {
if (statusFilter !== "all" && message.ai_status !== statusFilter) return false;
if (severityFilter && message.ai_severity !== severityFilter) return false;
if (categoryFilter) {
const messageCategories = parseStringList(message.ai_categories ?? message.ai_moderation_flags);
if (!messageCategories.includes(categoryFilter)) return false;
}
return true;
});
const flaggedItems = filtered.filter(
(message) => message.ai_status === "warn" || message.ai_status === "flagged",
);
const errorItems = messages.filter((message) => message.ai_status === "error");
const errorItems = filtered.filter((message) => message.ai_status === "error");
const renderList = (items: MessageRecord[], emptyText: string) => (
<div className="space-y-3">
{items.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
items.map((message) => (
<div key={message.id} className="space-y-2">
<MessageCard message={message} onReanalyze={onReanalyze} />
<ReviewDecisionControls message={message} onReanalyze={onReanalyze} />
</div>
))
)}
</div>
);
return (
<Card>
<CardHeader>
<CardTitle>Needs Review</CardTitle>
<CardTitle>Moderation Review & AI Eval</CardTitle>
<CardDescription>
{flaggedItems.length} flagged messages, {errorItems.length} analysis errors.
Public AI evaluation queue: {reviewable.length} reviewable messages, {errorItems.length} analysis errors.
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-4 grid gap-2 md:grid-cols-3">
<Select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as ReviewFilter)}
options={statusOptions}
/>
<Select
value={severityFilter}
onChange={(event) => setSeverityFilter(event.target.value)}
placeholder="All severities"
options={["none", "low", "medium", "high", "critical"].map((severity) => ({ value: severity, label: severity }))}
/>
<Select
value={categoryFilter}
onChange={(event) => setCategoryFilter(event.target.value)}
placeholder="All categories"
options={categories.map((category) => ({ value: category, label: category }))}
/>
</div>
<Tabs defaultValue="flags">
<TabsList>
<TabsTrigger value="flags">Flags ({flaggedItems.length})</TabsTrigger>
<TabsTrigger value="errors">Errors ({errorItems.length})</TabsTrigger>
</TabsList>
<TabsContent value="flags">
<MessageFeed
messages={flaggedItems}
onReanalyze={onReanalyze}
emptyText="No warned or flagged messages."
/>
{renderList(flaggedItems, "No warned or flagged messages match the filters.")}
</TabsContent>
<TabsContent value="errors">
<MessageFeed
messages={errorItems}
onReanalyze={onReanalyze}
emptyText="No analysis errors."
/>
{renderList(errorItems, "No analysis errors match the filters.")}
</TabsContent>
</Tabs>
</CardContent>
+221
View File
@@ -0,0 +1,221 @@
import { useCallback, useState } from "react";
export type ReviewStatus = "pending" | "approved" | "rejected" | "escalated";
export interface MessageReview {
id: string;
message_id: string;
guild_id: string;
channel_id: string;
reviewer_id: string | null;
status: ReviewStatus;
notes: string | null;
created_at: number;
reviewed_at: number | null;
}
export type ModerationActionType =
| "delete_message"
| "mute_user"
| "warn_user"
| "kick_user"
| "ban_user";
export interface ModerationAction {
id: string;
message_id: string | null;
user_id: string | null;
guild_id: string;
action_type: ModerationActionType;
reason: string | null;
executed_by: string | null;
status: "pending" | "executed" | "failed";
error: string | null;
created_at: number;
executed_at: number | null;
}
interface ReviewQuery {
guildId?: string;
channelId?: string;
status?: string[];
cursor?: string;
limit: number;
}
interface PageResult<T> {
data: T[];
nextCursor: string | null;
}
export function useReview() {
const [reviews, setReviews] = useState<MessageReview[]>([]);
const [actions, setActions] = useState<ModerationAction[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const listReviews = useCallback(async (query: ReviewQuery) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (query.guildId) params.append("guildId", query.guildId);
if (query.channelId) params.append("channelId", query.channelId);
if (query.status?.length) params.append("status", query.status.join(","));
if (query.cursor) params.append("cursor", query.cursor);
params.append("limit", String(query.limit));
const response = await fetch(`/api/reviews?${params}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = (await response.json()) as PageResult<MessageReview>;
setReviews(result.data);
setNextCursor(result.nextCursor);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
}, []);
const createReview = useCallback(
async (review: Omit<MessageReview, "id" | "created_at">) => {
try {
const response = await fetch("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(review),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const newReview = (await response.json()) as MessageReview;
setReviews((prev) => [newReview, ...prev]);
return newReview;
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
throw err;
}
},
[],
);
const updateReview = useCallback(
async (
id: string,
updates: Partial<Omit<MessageReview, "id" | "created_at">>,
) => {
try {
const response = await fetch(`/api/reviews/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const updated = (await response.json()) as MessageReview;
setReviews((prev) =>
prev.map((r) => (r.id === id ? updated : r)),
);
return updated;
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
throw err;
}
},
[],
);
const listActions = useCallback(
async (query: Omit<ReviewQuery, "channelId">) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (query.guildId) params.append("guildId", query.guildId);
if (query.status?.length) params.append("status", query.status.join(","));
if (query.cursor) params.append("cursor", query.cursor);
params.append("limit", String(query.limit));
const response = await fetch(`/api/actions?${params}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = (await response.json()) as PageResult<ModerationAction>;
setActions(result.data);
setNextCursor(result.nextCursor);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setLoading(false);
}
},
[],
);
const createAction = useCallback(
async (
action: Omit<ModerationAction, "id" | "created_at">,
) => {
try {
const response = await fetch("/api/actions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(action),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const newAction = (await response.json()) as ModerationAction;
setActions((prev) => [newAction, ...prev]);
return newAction;
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
throw err;
}
},
[],
);
const updateAction = useCallback(
async (
id: string,
updates: Partial<Omit<ModerationAction, "id" | "created_at">>,
) => {
try {
const response = await fetch(`/api/actions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updates),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const updated = (await response.json()) as ModerationAction;
setActions((prev) =>
prev.map((a) => (a.id === id ? updated : a)),
);
return updated;
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
throw err;
}
},
[],
);
return {
reviews,
actions,
loading,
error,
nextCursor,
listReviews,
createReview,
updateReview,
listActions,
createAction,
updateAction,
};
}