feat(gmw): moderation explainability + semantic message search

- Persist structured verdict (flags/severity/confidence/evidence) on
  moderation_actions so the public web can show WHY a message was moderated.
- Add a persistent Qdrant archive collection (gmw_message_archive); embed
  every captured message at capture time (fire-and-forget, best-effort).
- Public semantic search over the archive (backend oRPC + FE toggle on the
  messages view). Both features are read-only/public and fully automatic.

Migration: 0015_add_moderation_explainability.sql
This commit is contained in:
asepharyana
2026-08-18 15:11:01 +07:00
parent d68f6b653a
commit 1ae19074ee
25 changed files with 1189 additions and 7 deletions
@@ -34,6 +34,7 @@ import {
useMessagesHasMore,
useMessagesStream,
useMessagesWsSync,
useSemanticSearch,
} from "@/hooks";
import { aiTone } from "@/lib/ai-status";
import {
@@ -67,6 +68,9 @@ export function MessagesView({
const [channelId, setChannelId] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [query, setQuery] = useState("");
// Search mode: "exact" (substring match over captured messages) or
// "semantic" (vector similarity over the persistent Qdrant archive).
const [semanticMode, setSemanticMode] = useState(false);
// Guard against loading the entire history on a long scroll: cap how many
// older pages we append. Each page is 50 messages (backend limit default).
const MAX_OLDER_PAGES = 10;
@@ -94,7 +98,14 @@ export function MessagesView({
const hasMore = pageInfo?.hasMore ?? false;
const loadMore = useLoadMore();
useMessagesWsSync(ws, guildId ?? "");
const search = useMessageSearch(query, query.trim().length >= 2);
const search = useMessageSearch(
query,
query.trim().length >= 2 && !semanticMode,
);
const semantic = useSemanticSearch(
query,
query.trim().length >= 2 && semanticMode,
);
const detail = useMessageDetail(selected);
const ambient = useAmbient();
@@ -122,7 +133,8 @@ export function MessagesView({
ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages");
}, [query, ambient]);
const searching = query.trim().length >= 2;
const searching = query.trim().length >= 2 && !semanticMode;
const semanticSearching = query.trim().length >= 2 && semanticMode;
const list = searching ? (search.data ?? []) : (messages ?? []);
// Discord-style order: oldest at the top, newest at the bottom. The backend
// returns DESC (newest first); reverse so the feed reads top→bottom like DC.
@@ -184,9 +196,68 @@ export function MessagesView({
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<button
type="button"
onClick={() => setSemanticMode((v) => !v)}
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
semanticMode
? "border-signal/40 bg-signal/10 text-signal"
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
}`}
title="Toggle semantic (vector) search over the message archive"
>
{semanticMode ? "Semantic" : "Exact"}
</button>
</GlassPanel>
<div className="grid gap-4 lg:grid-cols-5">
{semanticSearching && (
<GlassPanel className="lg:col-span-5">
<SectionHeader
eyebrow="semantic"
title={`${query}`}
action={
<span className="mono text-xs text-ink-faint">
{semantic.data?.length ?? 0} matches
</span>
}
/>
{semantic.isLoading ? (
<SkeletonRows rows={4} />
) : semantic.data && semantic.data.length > 0 ? (
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
{semantic.data.map((r, i) => (
<div
key={r.message_id ?? i}
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
style={staggerDelay(i)}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="mono text-[0.6rem] text-signal">
{(r.score * 100).toFixed(0)}%
</span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
{formatRelativeTime(r.created_at)}
</span>
</div>
<div className="mt-0.5 line-clamp-3 text-sm text-ink-soft">
{r.content}
</div>
</div>
</div>
))}
</div>
) : (
<EmptyState
icon={<Search className="size-7" />}
title="No semantic matches"
description="Try different wording — semantic search finds meaning, not exact text."
/>
)}
</GlassPanel>
)}
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow={searching ? "results" : "live feed"}
@@ -31,6 +31,7 @@ import {
SkeletonRows,
} from "@/components/shared";
import { useModerationActions, useModerationStats } from "@/hooks";
import { aiTone } from "@/lib/ai-status";
import { formatNumber, formatRelativeTime } from "@/lib/format";
import type {
ModerationAction,
@@ -243,6 +244,18 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
const icon = ACTION_ICON[a.action_type] ?? (
<AlertTriangle className="size-3.5" />
);
// Map moderation severity → design-system tone (reuse aiTone with a
// severity→status projection so "none" reads as clean/signal).
const severityTone =
a.severity == null
? null
: aiTone(
a.severity === "none"
? "clean"
: a.severity === "low" || a.severity === "medium"
? "warn"
: "flagged",
);
return (
<div
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
@@ -266,6 +279,30 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
{a.reason && (
<div className="mt-0.5 text-xs text-ink-soft">{a.reason}</div>
)}
{severityTone && a.severity && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<Badge tone={severityTone}>{a.severity}</Badge>
{a.confidence != null && (
<span className="mono text-[0.6rem] text-ink-faint">
conf {(a.confidence * 100).toFixed(0)}%
</span>
)}
</div>
)}
{a.flags?.length ? (
<div className="mt-1 flex flex-wrap gap-1">
{a.flags.slice(0, 6).map((f) => (
<Badge key={f} tone="amber">
{f}
</Badge>
))}
</div>
) : null}
{a.evidence?.length ? (
<div className="mt-1 border-l-2 border-hairline pl-2 text-xs text-ink-faint">
&ldquo;{a.evidence[0]}&rdquo;
</div>
) : null}
{a.executed_by && (
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
by {a.executed_by}
+1
View File
@@ -28,6 +28,7 @@ export {
useMessagesStream,
useMessagesWsSync,
useReview,
useSemanticSearch,
useTextChannels,
} from "./use-messages";
export {
+21 -1
View File
@@ -2,7 +2,12 @@ import { useEffect, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { useAction } from "@/hooks/use-action";
import { messagesApi, voiceApi } from "@/lib/api";
import type { AttachmentRecord, Channel, MessageRecord } from "@/lib/types";
import type {
AttachmentRecord,
Channel,
MessageRecord,
SemanticSearchResult,
} from "@/lib/types";
import type { WsHook } from "@/lib/ws-hook";
// ── Query keys factory ───────────────────────────
@@ -177,6 +182,21 @@ export function useMessageSearch(query: string, enabled: boolean) {
);
}
// ── Semantic Search (public archive, Qdrant) ──────
export function useSemanticSearch(query: string, enabled: boolean) {
return useSWR<SemanticSearchResult[]>(
enabled && query.trim().length >= 2
? ["semantic-search", query.trim()]
: null,
async () => {
const res = await messagesApi.semanticSearch(query.trim(), 10);
return res.results;
},
{ keepPreviousData: true },
);
}
// ── WS sync helpers ──────────────────────────────
export function useMessagesWsSync(ws: WsHook, guildId: string) {
+12 -1
View File
@@ -1,5 +1,9 @@
import { orpc } from "@/lib/orpc/client";
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
import type {
AttachmentRecord,
MessageRecord,
SemanticSearchResult,
} from "@/lib/types";
export const messagesApi = {
list: (
@@ -62,4 +66,11 @@ export const messagesApi = {
orpc.analysis.search({ q, limit }) as unknown as Promise<{
results: MessageRecord[];
}>,
// Public semantic search over the persistent message archive (Qdrant).
semanticSearch: (query: string, limit?: number) =>
orpc.messages.semanticSearch({ query, limit }) as unknown as Promise<{
results: SemanticSearchResult[];
nextCursor: null;
}>,
};
@@ -163,3 +163,17 @@ export interface AttachmentRecord {
created_at: number;
uploaded_at?: number | null;
}
// ── Semantic Search (read-only public archive search) ──────────
export interface SemanticSearchResult {
message_id: string | null;
content: string;
score: number;
created_at: number;
}
export interface SemanticSearchResponse {
results: SemanticSearchResult[];
nextCursor: null;
}
@@ -21,6 +21,14 @@ export interface ModerationAction {
executed_at: number | null;
username: string | null;
content: string | null;
// ── Explainability (structured verdict, surfaced read-only to public web) ──
flags: string[] | null;
categories: string[] | null;
severity: "none" | "low" | "medium" | "high" | "critical" | null;
confidence: number | null;
score: number | null;
evidence: string[] | null;
policy_version: string | null;
}
export interface ModerationStats {