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:
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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">
|
||||
“{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