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
@@ -0,0 +1,43 @@
import { config } from "@/shared/config/index.js";
import { createChildLogger } from "@/shared/logger/index.js";
const logger = createChildLogger("messages-embed");
/**
* Embed a search query with the configured OpenAI-compatible embedding model.
* Uses raw fetch (the backend has no openai SDK dependency) and returns null
* when embeddings are not configured (search unavailable).
*
* encoding_format: "float" is REQUIRED — Nvidia-backed models reject base64.
*/
export async function embedQuery(text: string): Promise<number[] | null> {
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
try {
const res = await fetch(`${config.AI_LLM_BASE_URL}/embeddings`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.AI_LLM_API_KEY}`,
},
body: JSON.stringify({
model: config.AI_LLM_EMBEDDING_MODEL,
input: text,
encoding_format: "float",
}),
});
if (!res.ok) {
logger.warn({ status: res.status }, "query embed HTTP error");
return null;
}
const json = (await res.json()) as {
data?: Array<{ embedding?: number[] }>;
};
return json.data?.[0]?.embedding ?? null;
} catch (error) {
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
"query embed failed",
);
return null;
}
}
@@ -41,3 +41,11 @@ export const messageUpdateSchema = z.object({
export type MessageQuery = z.infer<typeof messageQuerySchema>;
export type MessageCreate = z.infer<typeof messageCreateSchema>;
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
export const semanticSearchSchema = z.object({
query: z.string().min(1).max(500),
limit: z.coerce.number().int().positive().max(50).default(10),
guildId: z.string().optional(),
});
export type SemanticSearchQuery = z.infer<typeof semanticSearchSchema>;
@@ -1,7 +1,9 @@
import { NotFoundError, ValidationError } from "@/shared/errors/index";
import { createChildLogger } from "@/shared/logger/index";
import { embedQuery } from "./embed.js";
import { messagesRepository } from "./messages.repository.js";
import type { MessageQuery } from "./messages.schema.js";
import type { MessageQuery, SemanticSearchQuery } from "./messages.schema.js";
import { searchArchive } from "./qdrant.js";
const logger = createChildLogger("messages.service");
@@ -78,6 +80,40 @@ export class MessagesService {
logger.debug({ channelId, limit }, "Getting review messages");
return messagesRepository.getReviewMessages(channelId, limit);
}
/**
* Public, read-only semantic search over the persistent message archive.
* Embeds the query, searches Qdrant, returns text + metadata. Best-effort:
* if embeddings/Qdrant are unavailable, returns an empty result set.
*/
async semanticSearch(
input: SemanticSearchQuery,
): Promise<{ results: ReturnType<typeof mapSearchHit>[]; nextCursor: null }> {
const vector = await embedQuery(input.query);
if (!vector) {
logger.debug(
{ query: input.query },
"semantic search skipped: no embedder",
);
return { results: [], nextCursor: null };
}
const hits = await searchArchive(vector, input.limit, 0.6);
const results = hits.map((h) => mapSearchHit(h));
return { results, nextCursor: null };
}
}
/** Shape returned to the frontend (text + metadata from the archive payload). */
function mapSearchHit(hit: {
score: number;
payload: { text: string; content_hash?: string; analyzed_at: number };
}) {
return {
message_id: hit.payload.content_hash ?? null,
content: hit.payload.text,
score: hit.score,
created_at: hit.payload.analyzed_at,
};
}
export const messagesService = new MessagesService();
@@ -0,0 +1,95 @@
import { config } from "@/shared/config/index.js";
import { createChildLogger } from "@/shared/logger/index.js";
const logger = createChildLogger("messages-qdrant");
export interface ArchiveHit {
score: number;
payload: {
text: string;
content_hash?: string;
analyzed_at: number;
expires_at: number;
};
}
function baseUrl(): string {
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
/\/+$/,
"",
);
}
function headers(): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json" };
if (config.QDRANT_API_KEY) h["api-key"] = config.QDRANT_API_KEY;
return h;
}
export const ARCHIVE_COLLECTION =
config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
async function request(
method: string,
path: string,
body?: unknown,
timeoutMs = 10_000,
): Promise<unknown> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${baseUrl()}${path}`, {
method,
headers: headers(),
body: body === undefined ? undefined : JSON.stringify(body),
signal: controller.signal,
});
const text = await res.text();
if (!res.ok) {
throw new Error(
`Qdrant ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`,
);
}
return text ? JSON.parse(text) : null;
} finally {
clearTimeout(timer);
}
}
/** Search the archive collection for the nearest vectors to `vector`. */
export async function searchArchive(
vector: number[],
limit: number,
scoreThreshold: number,
): Promise<ArchiveHit[]> {
if (!config.QDRANT_URL) return [];
try {
const json = (await request(
"POST",
`/collections/${ARCHIVE_COLLECTION}/points/search`,
{
vector,
limit,
score_threshold: scoreThreshold,
with_payload: true,
},
)) as {
result?: Array<{
score?: number;
payload?: ArchiveHit["payload"];
}>;
};
return (json.result ?? [])
.filter((h) => h.payload?.text)
.map((h) => ({
score: h.score ?? 0,
payload: h.payload as ArchiveHit["payload"],
}));
} catch (error) {
logger.warn(
{ error: error instanceof Error ? error.message : String(error) },
"archive search failed",
);
return [];
}
}
@@ -17,6 +17,20 @@ const ACTION_TYPES = [
] as const;
const STATUSES = ["pending", "executed", "failed"] as const;
/** Parse a JSON-stringified array column (e.g. flags/categories/evidence).
* Returns null on empty/malformed input so the FE can treat it as "no data". */
function parseJsonArray(value: unknown): string[] | null {
if (value == null) return null;
const str = typeof value === "string" ? value : String(value);
if (str.length === 0) return null;
try {
const parsed = JSON.parse(str);
return Array.isArray(parsed) ? (parsed as string[]) : null;
} catch {
return null;
}
}
export class ModerationRepository {
async getStats() {
const db = getDatabase();
@@ -103,6 +117,13 @@ export class ModerationRepository {
a.error,
a.created_at,
a.executed_at,
a.flags,
a.categories,
a.severity,
a.confidence,
a.score,
a.evidence,
a.policy_version,
m.username,
LEFT(m.content, 300) AS content
FROM moderation_actions a
@@ -126,6 +147,13 @@ export class ModerationRepository {
error: r.error ? String(r.error) : null,
created_at: r.created_at ? Number(r.created_at) : null,
executed_at: r.executed_at ? Number(r.executed_at) : null,
flags: parseJsonArray(r.flags),
categories: parseJsonArray(r.categories),
severity: r.severity ? String(r.severity) : null,
confidence: r.confidence != null ? Number(r.confidence) : null,
score: r.score != null ? Number(r.score) : null,
evidence: parseJsonArray(r.evidence),
policy_version: r.policy_version ? String(r.policy_version) : null,
username: r.username ? String(r.username) : null,
content: r.content ? String(r.content) : null,
}));
+8 -1
View File
@@ -16,7 +16,10 @@ import {
skip,
stop,
} from "../modules/media/media.service";
import { messageQuerySchema } from "../modules/messages/messages.schema";
import {
messageQuerySchema,
semanticSearchSchema,
} from "../modules/messages/messages.schema";
import { messagesService } from "../modules/messages/messages.service";
import { moderationService } from "../modules/moderation/moderation.service";
import { recordingsService } from "../modules/recordings/recordings.service";
@@ -136,6 +139,10 @@ const messagesRouter = {
);
return { results: rows, limit: input.limit, cursor: null };
}),
// Public, read-only semantic search over the message archive.
semanticSearch: os
.input(semanticSearchSchema)
.handler(({ input }) => messagesService.semanticSearch(input)),
};
// ── Moderation ───────────────────────────────────────────────────
@@ -134,6 +134,7 @@ export const configSchema = z
.default("https://9router.asepharyana.my.id/v1"),
AI_LLM_MODEL: z.string().default("text"),
AI_LLM_VISION_MODEL: z.string().optional(),
AI_LLM_EMBEDDING_MODEL: z.string().optional(),
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
.number()
@@ -208,6 +209,11 @@ export const configSchema = z
.default("https://api.openai.com/v1"),
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
// ── Qdrant (message archive for semantic search) ──────────────────
QDRANT_URL: z.string().optional(),
QDRANT_API_KEY: z.string().optional(),
QDRANT_ARCHIVE_COLLECTION: z.string().default("gmw_message_archive"),
// ── Auto Delete ─────────────────────────────────────────────────────
AUTO_DELETE_FLAGGED_ENABLED: z
.string()
@@ -0,0 +1,16 @@
-- Migration: 0015_add_moderation_explainability.sql
-- Date: 2026-08-18
-- Description: Add structured explainability columns to moderation_actions.
-- These are READ (surfaced read-only to the public web) so they are never
-- "written but never read" — they back the public moderation transparency view.
-- Idempotent: no-ops on databases that already carry the columns.
ALTER TABLE IF EXISTS "moderation_actions"
ADD COLUMN IF NOT EXISTS "flags" text,
ADD COLUMN IF NOT EXISTS "categories" text,
ADD COLUMN IF NOT EXISTS "severity" text
CHECK ("severity" IS NULL OR "severity" IN ('none','low','medium','high','critical')),
ADD COLUMN IF NOT EXISTS "confidence" real,
ADD COLUMN IF NOT EXISTS "score" real,
ADD COLUMN IF NOT EXISTS "evidence" text,
ADD COLUMN IF NOT EXISTS "policy_version" text;
@@ -106,6 +106,13 @@
"when": 1785621600000,
"tag": "0014_add_term_glossary_cache",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1787184000000,
"tag": "0015_add_moderation_explainability",
"breakpoints": true
}
]
}
@@ -10,6 +10,7 @@ import {
} from "./autoDeleteEligibility.js";
import { logDeletionToChannel } from "./autoDeleteLogger.js";
import { sendDeletionNotification } from "./autoDeleteNotify.js";
import { verdictToActionFields } from "./verdictToActionFields.js";
const logger = createChildLogger("auto-delete-manager");
@@ -174,6 +175,7 @@ async function logAutoDeleteAttempt(
guild_id: message.guild_id,
action_type: "delete_message",
reason: result.reason,
...verdictToActionFields(message),
executed_by: "auto-delete-manager",
status: result.deleted
? "executed"
@@ -238,6 +240,7 @@ export async function attemptAutoDeleteFlaggedMessage(
action_type: "reset_nickname",
reason:
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
...verdictToActionFields(message),
executed_by: "auto-delete-manager",
status: resetOk ? "executed" : "failed",
error: resetOk ? null : "nickname_reset_failed",
@@ -50,6 +50,10 @@ function collectionName(): string {
return config.QDRANT_COLLECTION ?? "gmw_text_moderation";
}
/** Persistent archive collection for semantic message search (no TTL). */
export const ARCHIVE_COLLECTION =
config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
function headers(): Record<string, string> {
const h: Record<string, string> = {
"Content-Type": "application/json",
@@ -390,3 +394,131 @@ export async function deleteQdrantPointsByContentHash(
export function isQdrantConfigured(): boolean {
return Boolean(config.QDRANT_URL);
}
// ─── Archive variants (collection-aware, for persistent message search) ───
// These mirror the cache functions but take an explicit collection name so the
// semantic-search archive (gmw_message_archive) can live alongside the
// TTL-bounded automod cache without disturbing it.
/** Ensure an arbitrary collection exists with the right vector size. */
export async function ensureQdrantCollectionV2(
name: string,
vectorSize: number,
): Promise<boolean> {
try {
let existing: {
result?: { config?: { params?: { vectors?: { size?: number } } } };
} | null = null;
try {
existing = (await request("GET", `/collections/${name}`)) as {
result?: { config?: { params?: { vectors?: { size?: number } } } };
} | null;
} catch (error) {
if (!(error instanceof Error) || !error.message.includes("-> 404")) {
throw error;
}
}
const size = existing?.result?.config?.params?.vectors?.size;
if (size === vectorSize) return true;
if (size !== undefined && size !== vectorSize) {
log.warn(
{ collection: name, oldSize: size, newSize: vectorSize },
"Qdrant archive collection vector size changed — recreating collection",
);
await request("DELETE", `/collections/${name}`);
}
await request("PUT", `/collections/${name}`, {
vectors: { size: vectorSize, distance: "Cosine" },
});
return true;
} catch (error) {
log.error(
{
error: error instanceof Error ? error.message : String(error),
collection: name,
},
"Failed to ensure Qdrant archive collection",
);
return false;
}
}
/** Upsert one embedding + payload point into a named collection. */
export async function upsertQdrantPointV2(
name: string,
pointId: number,
vector: number[],
payload: QdrantVerdictPayload,
): Promise<boolean> {
try {
if (!(await ensureQdrantCollectionV2(name, vector.length))) return false;
await request(
"PUT",
`/collections/${name}/points`,
{
points: [{ id: pointId, vector, payload }],
wait: true,
},
30_000,
);
return true;
} catch (error) {
log.warn(
{
error: error instanceof Error ? error.message : String(error),
collection: name,
} as Record<string, unknown>,
"Qdrant archive upsert failed — entry skipped",
);
return false;
}
}
export interface QdrantArchiveHit {
pointId: number;
score: number;
payload: QdrantVerdictPayload;
}
/** Search a named collection for the nearest stored vector. */
export async function searchQdrantV2(
name: string,
vector: number[],
limit: number,
scoreThreshold: number,
): Promise<QdrantArchiveHit[]> {
try {
const json = (await request("POST", `/collections/${name}/points/search`, {
vector,
limit,
score_threshold: scoreThreshold,
with_payload: true,
})) as {
result?: Array<{
id?: number;
score?: number;
payload?: QdrantVerdictPayload;
}>;
};
return (json.result ?? [])
.filter((hit) => hit.payload?.text)
.map((hit) => ({
pointId: hit.id ?? 0,
score: hit.score ?? 0,
payload: hit.payload as QdrantVerdictPayload,
}));
} catch (error) {
log.warn(
{
error: error instanceof Error ? error.message : String(error),
collection: name,
} as Record<string, unknown>,
"Qdrant archive search failed — semantic search skipped",
);
return [];
}
}
@@ -0,0 +1,48 @@
import type { MessageRecord } from "../message-capture/types.js";
/**
* Map a captured message's persisted AI verdict (the `ai_*` columns on
* MessageRecord) into the explainability columns of a moderation action.
*
* This is READ-ONLY structured data — it never changes any enforcement
* decision. It exists so the public web view can show *why* a message was
* moderated, making GMW's automod transparent instead of a black box.
*
* All fields are null-safe: manual actions (e.g. command-handler bans) carry
* no AI verdict, so they simply store nulls and the UI falls back to the
* free-text `reason`.
*/
export function verdictToActionFields(message?: MessageRecord | null): {
flags: string | null;
categories: string | null;
severity: string | null;
confidence: number | null;
score: number | null;
evidence: string | null;
policy_version: string | null;
} {
if (!message) {
return {
flags: null,
categories: null,
severity: null,
confidence: null,
score: null,
evidence: null,
policy_version: null,
};
}
// ai_moderation_flags / ai_categories are stored as JSON-stringified TEXT
// (see messagesAnalysis.buildAIAnalysisSet → stringifyAIList). Pass them
// through verbatim so the backend can JSON.parse them back into arrays.
return {
flags: message.ai_moderation_flags ?? null,
categories: message.ai_categories ?? null,
severity: message.ai_severity ?? null,
confidence: message.ai_confidence ?? null,
score: message.ai_moderation_score ?? null,
evidence: null, // not persisted on MessageRecord; reserved for future use
policy_version: null, // set by caller if a policy version is available
};
}
@@ -0,0 +1,63 @@
import { embedText } from "@/modules/ai-moderation/embeddingClient.js";
import {
ARCHIVE_COLLECTION,
qdrantPointId,
upsertQdrantPointV2,
} from "@/modules/ai-moderation/qdrantClient.js";
import { config } from "@/shared/config/config.js";
import { createChildLogger } from "@/shared/logger/index";
const log = createChildLogger("archive-embedder");
export interface ArchiveMessage {
id: string;
content: string;
username: string;
channel_id: string;
guild_id: string;
created_at: number;
}
/**
* Fire-and-forget: embed a captured message and upsert it into the persistent
* archive collection so the public web can semantic-search the corpus.
*
* Failures are swallowed — searching is a nice-to-have, never a precondition
* for capture or moderation. The message text is kept in the payload so the
* search endpoint can return results even for deleted messages.
*/
export function archiveMessageEmbedded(message: ArchiveMessage): void {
if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip
const text = message.content?.trim();
if (!text || text.length < 3) return;
void (async () => {
try {
const vector = await embedText(text);
if (!vector) return;
const ok = await upsertQdrantPointV2(
ARCHIVE_COLLECTION,
qdrantPointId(`archive:${message.id}`),
vector,
{
text: text.slice(0, 4000),
flags: "",
analyzed_at: Date.now(),
// 5-year persistent window (archive is NOT a TTL cache).
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 365 * 5,
content_hash: message.id,
},
);
if (!ok) return;
log.debug({ messageId: message.id }, "Archived message embedding");
} catch (err) {
log.debug(
{
messageId: message.id,
error: err instanceof Error ? err.message : String(err),
},
"archive embed skipped",
);
}
})();
}
@@ -4,6 +4,7 @@ import { config } from "../../shared/config/config.js";
import { queueMessageAnalysis } from "../ai-moderation/aiAnalyzer.js";
import { processAttachmentUpload } from "../attachment-upload/attachmentUploader.js";
import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js";
import { archiveMessageEmbedded } from "../message-capture/archiveEmbedder.js";
import {
getDisplayContent,
getMessageLocation,
@@ -203,6 +204,7 @@ export async function captureMessage(
type: "text" | "edited" | "deleted",
options: { source?: "live" | "backlog" } = {},
): Promise<void> {
const isBacklog = options.source === "backlog";
const location = getMessageLocation(message);
const messageRecord = buildMessageRecord(message, type);
@@ -211,7 +213,11 @@ export async function captureMessage(
return;
}
const isBacklog = options.source === "backlog";
// Fire-and-forget: make the captured message searchable in the persistent
// archive (public semantic search). Never blocks capture/moderation.
if (!isBacklog && messageRecord.content) {
archiveMessageEmbedded(messageRecord);
}
if (_eventBroadcaster && !isBacklog) {
_eventBroadcaster.messageCreated(messageRecord);
@@ -178,6 +178,7 @@ export const configSchema = z
// embedding column remains as a legacy fallback).
QDRANT_URL: z.string().optional(),
QDRANT_COLLECTION: z.string().default("gmw_text_moderation"),
QDRANT_ARCHIVE_COLLECTION: z.string().default("gmw_message_archive"),
QDRANT_API_KEY: z.string().optional(),
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(8),
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
@@ -662,6 +662,16 @@ export const pgModerationActionsTable = pgTable(
error: pgText("error"),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
executed_at: pgBigint("executed_at", { mode: "number" }),
// ── Explainability (structured verdict; surfaced read-only to public web) ──
flags: pgText("flags"), // JSON array of string flags, e.g. ["sara_agama","vulgar"]
categories: pgText("categories"), // JSON array of category strings
severity: pgText("severity", {
enum: ["none", "low", "medium", "high", "critical"],
}),
confidence: pgReal("confidence"), // 0..1
score: pgReal("score"), // 0..1 raw model score
evidence: pgText("evidence"), // JSON array of short quoted snippets
policy_version: pgText("policy_version"), // rules.ts policy version string
},
(table) => ({
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
@@ -2,6 +2,7 @@ import {
bigint as pgBigint,
boolean as pgBoolean,
index as pgIndex,
real as pgReal,
pgTable,
text as pgText,
uuid as pgUuid,
@@ -48,6 +49,16 @@ export const pgModerationActionsTable = pgTable(
error: pgText("error"),
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
executed_at: pgBigint("executed_at", { mode: "number" }),
// ── Explainability (structured verdict; surfaced read-only to public web) ──
flags: pgText("flags"), // JSON array of string flags, e.g. ["sara_agama","vulgar"]
categories: pgText("categories"), // JSON array of category strings
severity: pgText("severity", {
enum: ["none", "low", "medium", "high", "critical"],
}),
confidence: pgReal("confidence"), // 0..1
score: pgReal("score"), // 0..1 raw model score
evidence: pgText("evidence"), // JSON array of short quoted snippets
policy_version: pgText("policy_version"), // rules.ts policy version string
},
(table) => ({
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
@@ -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 {