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:
@@ -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">
|
||||
“{a.evidence[0]}”
|
||||
</div>
|
||||
) : null}
|
||||
{a.executed_by && (
|
||||
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
||||
by {a.executed_by}
|
||||
|
||||
@@ -28,6 +28,7 @@ export {
|
||||
useMessagesStream,
|
||||
useMessagesWsSync,
|
||||
useReview,
|
||||
useSemanticSearch,
|
||||
useTextChannels,
|
||||
} from "./use-messages";
|
||||
export {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user