diff --git a/.hermes/plans/2026-08-18_gmw-explainability-semantic-search.md b/.hermes/plans/2026-08-18_gmw-explainability-semantic-search.md
new file mode 100644
index 0000000..d2e4444
--- /dev/null
+++ b/.hermes/plans/2026-08-18_gmw-explainability-semantic-search.md
@@ -0,0 +1,500 @@
+# GMW — Moderation Explainability (#1) + Semantic Search (#3) Implementation Plan
+
+> **For Hermes:** Use subagent-driven-development to implement task-by-task.
+> Hard constraint from user (2026-08-18): web is PUBLIC, read-only, for USERS not admins. Moderation MUST stay FULLY AUTOMATIC. Rules stay in CODE (no per-channel config UI).
+
+**Goal:** Make GMW transparent (users see why a message was moderated) and searchable (users can semantic-search the message corpus), via two fully-automatic, code-driven, read-only-public features.
+
+**Architecture:**
+- **#1 Explainability:** Persist the structured moderation verdict that already exists in `AnalysisResult` (`flags[]`, `categories[]`, `severity`, `confidence`, `evidence[]`) into new columns on `moderation_actions`, surface them through the existing public moderation oRPC + the existing public `moderation` dashboard view. No new behavior — only new *data* + new *read* paths.
+- **#3 Semantic Search:** Add a SECOND persistent Qdrant collection (`gmw_message_archive`) keyed by message id (NOT the TTL cache). Embed each captured text message at capture time (reuse `embedText`) and upsert. Add a public `messages.semanticSearch` oRPC + a read-only search UI on the public `messages` view. Best-effort / non-blocking — embed failures never affect moderation or capture.
+
+**Tech Stack:** TypeScript (discord-gateway + backend + frontend monorepo), Drizzle ORM + Postgres (PgBouncer on imrnes), Qdrant (100.121.180.82:6333), Next.js 16 App Router + shadcn/ui, oRPC over `/trpc`. pnpm. Deploy via GitHub Actions Nix build + `systemctl restart`.
+
+**Critical existing facts (verified in repo):**
+- `AnalysisResult` shape (`src/modules/ai-moderation/ai-analysis-worker.ts:55`): `messageId, status, flags[], categories[], severity, confidence, recommendedAction, score, analysis, correctedFlags?`. The shared `AnalysisResult` (`src/shared/moderation-types.ts:142`) ALSO has `evidence?: string[]` and `policyVersion?: string`. **THESE ARE ALREADY COMPUTED but only logged, never persisted to `moderation_actions`.**
+- `moderation_actions` schema is DEFINED TWICE with a divergence:
+ - `src/shared/database/schema.ts:638` → `pgModerationActionsTable` (authoritative, has `reset_nickname` in `action_type` enum).
+ - `src/shared/database/schema/messages.ts:25` → another `pgModerationActionsTable` (NO `reset_nickname`; gateway-local copy).
+ - The gateway's `ModerationActionsDb` (`src/modules/message-capture/moderationActionsDb.ts`) imports from `schema.ts` (the authoritative one). The `messages.ts` copy appears UNUSED for DB ops — but WE MUST ADD NEW COLUMNS TO BOTH to avoid type drift, OR confirm the `messages.ts` copy is dead and delete it. **Decision: add columns to `schema.ts` (authoritative) AND the `messages.ts` copy to keep `$inferInsert`/`$inferSelect` in sync (the gateway `ModerationAction` type flows from shared).** Verify with grep that `messages.ts` `pgModerationActionsTable` is not used by any `.insert()`/`.select()` at runtime before relying on it; if only re-exported, we still patch it for type-safety.
+- Migration mechanism: Drizzle-managed via `drizzle/migrations/*.sql` (journal `_journal.json`) applied by `runMigrations()` → `migratePostgres`. **New tables/columns must be added with `drizzle-kit generate` to produce a numbered `.sql` + journal entry**, OR (simpler, matches `0013_rename_*.sql` manual style) write a raw idempotent `.sql` under `drizzle/migrations/` AND register it in `_journal.json`. **Preferred here: use `pnpm drizzle-kit generate` so the journal stays consistent.** The legacy `src/shared/database/migrations/001_drop_unused_ai_columns.sql` is a PRE-drizzle manual script — do NOT follow that pattern.
+- **Historical lesson (MUST respect):** a prior migration (`0004_drop_unused_ai_columns.sql` = old `001_drop`) DELETED `ai_evidence`, `ai_policy_version`, `ai_moderation_raw` from `messages` with the note "written but never read". → Our new `moderation_actions` columns MUST be read (serializer + FE render). No write-only columns.
+- Embedding client: `embedText(text)` / `embedTexts(texts[])` in `src/modules/ai-moderation/embeddingClient.ts`. Returns `null` if `AI_LLM_EMBEDDING_MODEL` not configured. Reuses `config.AI_LLM_BASE_URL` + `config.AI_LLM_API_KEY`. OpenAI SDK v6 → `encoding_format: "float"` REQUIRED (Nvidia rejects base64).
+- Qdrant client: `src/modules/ai-moderation/qdrantClient.ts`. Has `ensureQdrantCollection(vectorSize)`, `upsertQdrantPoint(cacheKey, vector, payload)`, `searchQdrant(vector, limit, scoreThreshold)`. These are hardcoded to the cache collection name `config.QDRANT_COLLECTION ?? "gmw_text_moderation"`. **#3 needs a second collection** → generalize the client to accept a collection name param (add `ensureQdrantCollectionV2(name, size)` / `upsertQdrantPointV2(name, id, vector, payload)` / `searchQdrantV2(name, vector, limit, scoreThreshold)` OR refactor `collectionName()` to take an arg). Keep the cache path unchanged.
+- Capture hook: `captureMessage()` (`src/modules/message-capture/messageCapture.ts:201`) calls `messageStore.upsertMessageForCapture(messageRecord)` then (if not backlog) `queueMessageAnalysis`. **#3 embed must happen here**, async + fire-and-forget, after successful insert.
+- Public moderation view: `services/frontend/src/app/(dashboard)/moderation/view.tsx` renders `ActionRow` per action. The `ModerationAction` FE type is in `services/frontend/src/lib/types/moderation.ts` (NO new fields yet). The backend `moderationService.listActions` SQL is in `services/backend/src/modules/moderation/moderation.repository.ts:68` (raw SQL, selects fixed columns, joins `messages`).
+- oRPC wiring: `services/backend/src/orpc/router.ts` → `moderationRouter` (stats, actions) and `messagesRouter` (list, byChannel, getById, review, attachments). New procedures added here.
+
+---
+
+## TASK 1 — Schema: add explainability columns to `moderation_actions`
+
+**Objective:** Persist structured verdict on moderation actions so it can be surfaced (read) later.
+
+**Files:**
+- Modify: `services/discord-gateway/src/shared/database/schema.ts` (authoritative `pgModerationActionsTable`, ~line 638)
+- Modify: `services/discord-gateway/src/shared/database/schema/messages.ts` (`pgModerationActionsTable` copy, ~line 25) to keep type in sync
+- Create: `services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql`
+- Update: `services/discord-gateway/drizzle/migrations/meta/_journal.json` (add new entry)
+
+**Step 1: Add columns to both schema definitions**
+Add after `executed_at` in BOTH `pgModerationActionsTable` definitions:
+```ts
+ // ── 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
+```
+Note: `flags`/`categories`/`evidence` stored as JSON-stringified TEXT (consistent with how `messages.ai_moderation_flags`/`ai_categories` are stored as TEXT elsewhere — confirm storage format in `updateMessageAIAnalysis`). Keep nullable.
+
+**Step 2: Generate/author the migration SQL**
+`0015_add_moderation_explainability.sql` (idempotent):
+```sql
+-- Add structured explainability columns to moderation_actions (read-only surfaced to public web).
+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;
+```
+Register in `_journal.json`: append an entry with `idx: 15`, a new unique `tag` (hash), `version`, `when` = Date.now(), `tag` short, `breakpoints: false`. Use `pnpm drizzle-kit generate` if possible to get a correct tag; otherwise hand-edit the journal carefully (copy an existing entry's shape).
+
+**Step 3: Type-check gateway**
+Run: `cd services/discord-gateway && pnpm typecheck`
+Expected: PASS (no new compile errors).
+
+**Step 4: Commit**
+```bash
+git add services/discord-gateway/src/shared/database/schema.ts \
+ services/discord-gateway/src/shared/database/schema/messages.ts \
+ services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql \
+ services/discord-gateway/drizzle/migrations/meta/_journal.json
+git commit -m "feat(db): add explainability columns to moderation_actions"
+```
+
+---
+
+## TASK 2 — Persist verdict at the auto-delete + command-handler call sites
+
+**Objective:** Populate the new columns from the already-computed `AnalysisResult` when a moderation action is logged. Fully automatic, no new behavior.
+
+**Files:**
+- Modify: `services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts` (`logAutoDeleteAttempt` ~line 166, and the second `createModerationAction` call ~line 234 for nickname/mute paths)
+- Modify: `services/discord-gateway/src/modules/command-handler/moderation.handler.ts` (`createModerationAction` ~line 95)
+- Helper (create): `services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts` — shared mapper so all 3 call sites stay DRY.
+
+**Step 1: Create the mapper helper**
+`verdictToActionFields.ts`:
+```ts
+import type { AnalysisResult } from "@/modules/ai-moderation/ai-analysis-worker";
+import type { ModerationActionInsert } from "@/shared/index"; // or inline shape
+
+/**
+ * Map a computed AI verdict into the explainability columns of a moderation
+ * action. Null-safe: missing fields stay null (e.g. manual admin actions have
+ * no AnalysisResult). This is read-only structured data — it does NOT change
+ * any enforcement decision.
+ */
+export function verdictToActionFields(result?: {
+ flags?: string[];
+ categories?: string[];
+ severity?: string;
+ confidence?: number;
+ score?: number;
+ evidence?: string[];
+ policyVersion?: string;
+}): {
+ flags: string | null;
+ categories: string | null;
+ severity: string | null;
+ confidence: number | null;
+ score: number | null;
+ evidence: string | null;
+ policy_version: string | null;
+} {
+ if (!result) {
+ return { flags: null, categories: null, severity: null, confidence: null,
+ score: null, evidence: null, policy_version: null };
+ }
+ const j = (v: unknown) => (v == null ? null : JSON.stringify(v));
+ return {
+ flags: j(result.flags),
+ categories: j(result.categories),
+ severity: result.severity ?? null,
+ confidence: result.confidence ?? null,
+ score: result.score ?? null,
+ evidence: j(result.evidence),
+ policy_version: result.policyVersion ?? null,
+ };
+}
+```
+
+**Step 2: Wire `logAutoDeleteAttempt`**
+Find the `createModerationAction({...})` in `logAutoDeleteAttempt` and spread the verdict fields:
+```ts
+await messageStore.createModerationAction({
+ message_id: message.id,
+ user_id: message.user_id,
+ guild_id: message.guild_id,
+ action_type: "delete_message",
+ reason: result.reason,
+ ...verdictToActionFields(result.analysisResult), // <-- pass the AnalysisResult through
+ executed_by: "auto-delete-manager",
+ status: ...,
+});
+```
+**IMPORTANT:** `result` here is `AutoDeleteResult` — verify it carries the `AnalysisResult` (or the verdict). If `AutoDeleteResult` does NOT carry the full `AnalysisResult`, trace where `attemptAutoDeleteFlaggedMessage` is called from and pass the `AnalysisResult` down (it is available in the analysis worker that triggered the delete). Confirm by reading `AutoDeleteResult` type + its producer. If the verdict is only available at the orchestrator level, add an optional `verdict?: AnalysisResult` field to `AutoDeleteResult` and populate it at the call site.
+
+**Step 3: Wire the second call site in `autoDeleteManager.ts`** (the mute/nickname path ~line 234) similarly, if it has an `AnalysisResult` available; otherwise leave fields null (manual-style action).
+
+**Step 4: Wire `moderation.handler.ts`** command path (~line 95) — pass `verdictToActionFields(verdict)` if the command handler has the `AnalysisResult` for the target message; otherwise nulls. Confirm what the handler receives.
+
+**Step 5: Type-check + lint**
+Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
+Expected: PASS.
+
+**Step 6: Commit**
+```bash
+git add services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts \
+ services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts \
+ services/discord-gateway/src/modules/command-handler/moderation.handler.ts
+git commit -m "feat(mods): persist structured verdict into moderation_actions"
+```
+
+---
+
+## TASK 3 — Backend: surface explainability in `moderation.actions`
+
+**Objective:** Read the new columns in the public oRPC so the frontend can render them. (Read path — satisfies the "never write-only" rule.)
+
+**Files:**
+- Modify: `services/backend/src/modules/moderation/moderation.repository.ts` (`listActions` raw SQL ~line 68) — add new columns to SELECT + map.
+- Modify: `services/frontend/src/lib/types/moderation.ts` (`ModerationAction` interface) — add new fields.
+- Modify: `services/frontend/src/app/(dashboard)/moderation/view.tsx` (`ActionRow`) — render flags/categories badges + severity + confidence + evidence snippet.
+
+**Step 1: Extend backend SELECT**
+In `listActions`, add to the SELECT list: `a.flags, a.categories, a.severity, a.confidence, a.score, a.evidence, a.policy_version`. In the `.map(...)` add:
+```ts
+flags: r.flags ? safeJsonArray(String(r.flags)) : null,
+categories: r.categories ? safeJsonArray(String(r.categories)) : null,
+severity: r.severity ? String(r.severity) : null,
+confidence: r.confidence != null ? Number(r.confidence) : null,
+score: r.score != null ? Number(r.score) : null,
+evidence: r.evidence ? safeJsonArray(String(r.evidence)) : null,
+policy_version: r.policy_version ? String(r.policy_version) : null,
+```
+where `safeJsonArray(s)` = `JSON.parse(s)` wrapped in try/catch returning `[]` on failure (define a tiny local helper in the repository file).
+
+**Step 2: Extend FE type**
+In `services/frontend/src/lib/types/moderation.ts` `ModerationAction`:
+```ts
+ 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;
+```
+
+**Step 3: Render in `ActionRow`**
+After the existing reason block, add (using existing `Badge` + `aiTone` from `@/lib/ai-status`):
+```tsx
+{a.severity && (
+
+ {a.severity}
+
+)}
+{a.flags?.length ? (
+
+ {a.flags.map((f) => {f})}
+
+) : null}
+{a.evidence?.length ? (
+
+ “{a.evidence[0]}”
+
+) : null}
+{a.confidence != null && (
+
+ conf {(a.confidence * 100).toFixed(0)}%
+
+)}
+```
+Keep `ActionRow` read-only. No admin controls.
+
+**Step 4: Type-check both services**
+Run: `cd services/backend && pnpm typecheck && pnpm lint` and `cd services/frontend && pnpm typecheck && pnpm lint`
+Expected: PASS.
+
+**Step 5: Commit**
+```bash
+git add services/backend/src/modules/moderation/moderation.repository.ts \
+ services/frontend/src/lib/types/moderation.ts \
+ services/frontend/src/app/\(dashboard\)/moderation/view.tsx
+git commit -m "feat(web): surface moderation explainability (flags/severity/evidence)"
+```
+
+---
+
+## TASK 4 — Qdrant client: support a second persistent collection
+
+**Objective:** Generalize the Qdrant client so #3 can use a dedicated archive collection without disturbing the automod cache.
+
+**Files:**
+- Modify: `services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts`
+
+**Step 1: Add collection-aware variants**
+Refactor `collectionName()` to accept an optional name, and add V2 functions that take an explicit collection:
+```ts
+function collectionName(fallback = config.QDRANT_COLLECTION ?? "gmw_text_moderation"): string {
+ return fallback;
+}
+export const ARCHIVE_COLLECTION = config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
+
+export async function ensureQdrantCollectionV2(name: string, vectorSize: number): Promise {
+ // same body as ensureQdrantCollection but uses `name` instead of collectionName()
+}
+export async function upsertQdrantPointV2(
+ name: string, pointId: number, vector: number[], payload: QdrantVerdictPayload,
+): Promise { /* PUT /collections/{name}/points with wait:true */ }
+export async function searchQdrantV2(
+ name: string, vector: number[], limit: number, scoreThreshold: number,
+): Promise { /* POST /collections/{name}/points/search */ }
+```
+Keep all existing `ensureQdrantCollection` / `upsertQdrantPoint` / `searchQdrant` UNCHANGED (cache path). V2 functions mirror them with the `name` param. Reuse `request()` and the existing payload/score types.
+
+**Step 2: Type-check**
+Run: `cd services/discord-gateway && pnpm typecheck`
+Expected: PASS.
+
+**Step 3: Commit**
+```bash
+git add services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts
+git commit -m "feat(qdrant): add collection-aware V2 upsert/search for archive"
+```
+
+---
+
+## TASK 5 — Capture-time embed + archive upsert
+
+**Objective:** Make every (non-backlog, text) captured message searchable in the persistent archive. Non-blocking / best-effort.
+
+**Files:**
+- Modify: `services/discord-gateway/src/modules/message-capture/messageCapture.ts` (`captureMessage` ~line 201)
+- Create: `services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts` — wraps embed + upsert with fire-and-forget + rate-limit guard.
+
+**Step 1: Create `archiveEmbedder.ts`**
+```ts
+import { createChildLogger } from "@/shared/logger/index";
+import { embedText } from "@/modules/ai-moderation/embeddingClient";
+import { ARCHIVE_COLLECTION, ensureQdrantCollectionV2, upsertQdrantPointV2, qdrantPointId } from "@/modules/ai-moderation/qdrantClient";
+import { config } from "@/shared/config/config";
+
+const log = createChildLogger("archive-embedder");
+
+/**
+ * Fire-and-forget: embed a captured message and upsert into the persistent
+ * archive collection. Failures are swallowed — searching is a nice-to-have,
+ * never a precondition for capture or moderation.
+ */
+export function archiveMessageEmbedded(message: {
+ id: string; content: string; username: string; channel_id: string; guild_id: string; created_at: number;
+}): void {
+ if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip
+ if (!message.content || message.content.trim().length < 3) return;
+ void (async () => {
+ try {
+ const vector = await embedText(message.content);
+ if (!vector) return;
+ const ok = await ensureQdrantCollectionV2(ARCHIVE_COLLECTION, vector.length);
+ if (!ok) return;
+ await upsertQdrantPointV2(ARCHIVE_COLLECTION, qdrantPointId(`archive:${message.id}`), vector, {
+ text: message.content.slice(0, 4000),
+ flags: "", // not a verdict payload; keep shape compatible
+ analyzed_at: Date.now(),
+ expires_at: Date.now() + 1000 * 60 * 60 * 24 * 365 * 5, // 5y persistent
+ content_hash: undefined,
+ });
+ } catch (err) {
+ log.debug({ messageId: message.id, error: err instanceof Error ? err.message : String(err) }, "archive embed skipped");
+ }
+ })();
+}
+```
+Payload type reuse: `QdrantVerdictPayload` has `text`, `flags`, `analyzed_at`, `expires_at`, `content_hash?`. For the archive we only need `text` + timestamps; set `flags: ""` (empty, ignored by search filter which keys on `expires_at`). **Acceptable:** the search path filters `expires_at >= now` — 5y window satisfies that.
+
+**Step 2: Call from `captureMessage`**
+In `captureMessage`, after `const inserted = await messageStore.upsertMessageForCapture(messageRecord); if (!inserted) return;` and BEFORE the backlog branch, add:
+```ts
+if (!isBacklog && messageRecord.content) {
+ archiveMessageEmbedded(messageRecord);
+}
+```
+(`messageRecord` is the `MessageRecord` from `buildMessageRecord`; confirm it carries `content`, `channel_id`, `guild_id`, `created_at`. It does — see `messagesCrud`/`types`.)
+
+**Step 3: Type-check + lint**
+Run: `cd services/discord-gateway && pnpm typecheck && pnpm lint`
+Expected: PASS.
+
+**Step 4: Commit**
+```bash
+git add services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts \
+ services/discord-gateway/src/modules/message-capture/messageCapture.ts
+git commit -m "feat(archive): embed captured messages into persistent Qdrant archive"
+```
+
+---
+
+## TASK 6 — Backend: `messages.semanticSearch` oRPC
+
+**Objective:** Public, read-only semantic search over the message archive.
+
+**Files:**
+- Modify: `services/backend/src/modules/messages/messages.repository.ts` — add `semanticSearch(query, limit, guildId?)`.
+- Modify: `services/backend/src/modules/messages/messages.service.ts` — expose `semanticSearch`.
+- Modify: `services/backend/src/orpc/router.ts` — add `messages.semanticSearch` procedure.
+- Create (or reuse): an embedding call from the backend. The backend does NOT import the gateway's `embeddingClient`. **Decision:** add a minimal backend embed helper `services/backend/src/modules/messages/embed.ts` that calls the same OpenAI-compatible endpoint via `config` (reuse `config.AI_LLM_BASE_URL`/`AI_LLM_API_KEY`/`AI_LLM_EMBEDDING_MODEL` if present on the backend; if not configured, return a clear "search unavailable" error). Mirror `encoding_format: "float"`.
+- Modify: `services/backend/src/modules/messages/messages.schema.ts` — add `semanticSearchQuery` zod schema (limit, guildId?, query).
+
+**Step 1: Backend embed helper** (`embed.ts`)
+```ts
+import OpenAI from "openai";
+import { config } from "@/shared/config/index";
+import { createChildLogger } from "@/shared/logger/index";
+const log = createChildLogger("messages-embed");
+let client: OpenAI | null = null;
+function getClient() {
+ if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
+ if (!client) client = new OpenAI({ apiKey: config.AI_LLM_API_KEY, baseURL: config.AI_LLM_BASE_URL, maxRetries: 0, timeout: 30_000 });
+ return client;
+}
+export async function embedQuery(text: string): Promise {
+ const c = getClient(); if (!c) return null;
+ try {
+ const r = await c.embeddings.create({ model: config.AI_LLM_EMBEDDING_MODEL as string, input: text, encoding_format: "float" });
+ return r.data[0].embedding;
+ } catch (e) { log.warn({ error: e instanceof Error ? e.message : String(e) }, "query embed failed"); return null; }
+}
+```
+
+**Step 2: Repository `semanticSearch`**
+```ts
+async semanticSearch(queryVector: number[], limit: number, guildId?: string) {
+ // Search archive collection, then join messages for text + channel.
+ const hits = await searchQdrantV2(ARCHIVE_COLLECTION, queryVector, limit, 0.6);
+ const ids = hits.map(h => h.cacheKey.replace("qdrant:", "")); // point id → we stored archive:
+ // decode: qdrantPointId is a uint64; we need the original message id.
+ // SIMPLER: store message_id inside the payload too. → update archiveEmbedder payload to include `message_id`.
+ ...
+}
+```
+**REFINEMENT (important):** `qdrantPointId` is a hash, not reversible. So the archive payload MUST carry `message_id` (and `channel_id`, `guild_id`, `username`, `created_at`) so the backend can return full results without a reverse lookup. **Update `archiveEmbedder.ts` payload** to include those fields, and relax `QdrantVerdictPayload` (or create `QdrantArchivePayload`) to allow them. Then `semanticSearch` returns the payloads directly (already contain text + metadata) — no DB join needed, and it works even for deleted messages (archive keeps the text). Apply `guildId` filter client-side on the returned payloads.
+
+**Step 3: Service + router**
+`messages.service.ts`: `async semanticSearch(query: string, limit: number, guildId?: string)` → embed → `repository.semanticSearch`.
+`orpc/router.ts` under `messagesRouter`:
+```ts
+semanticSearch: os
+ .input(z.object({ query: z.string().min(1), limit: z.coerce.number().int().positive().max(50).default(10), guildId: z.string().optional() }))
+ .handler(async ({ input }) => {
+ const results = await messagesService.semanticSearch(input.query, input.limit, input.guildId);
+ return { results, nextCursor: null };
+ }),
+```
+
+**Step 4: Type-check + lint (backend)**
+Run: `cd services/backend && pnpm typecheck && pnpm lint`
+Expected: PASS.
+
+**Step 5: Commit**
+```bash
+git add services/backend/src/modules/messages/embed.ts \
+ services/backend/src/modules/messages/messages.repository.ts \
+ services/backend/src/modules/messages/messages.service.ts \
+ services/backend/src/modules/messages/messages.schema.ts \
+ services/backend/src/orpc/router.ts
+git commit -m "feat(api): public semantic message search over archive"
+```
+
+---
+
+## TASK 7 — Frontend: semantic search UI on `messages` view
+
+**Objective:** Public, read-only search box + results on the existing messages dashboard.
+
+**Files:**
+- Modify: `services/frontend/src/app/(dashboard)/messages/page.tsx` + `view.tsx` — add a search input (debounced) that calls a new `useMessagesSemanticSearch` hook → `messages.semanticSearch` oRPC, renders results as message cards (reuse `GlassPanel`/`Badge`/existing message row components).
+- Modify: `services/frontend/src/lib/types/message.ts` — add `SemanticSearchResult` + `SemanticSearchResponse` types.
+- Modify: `services/frontend/src/lib/api/client.ts` (or `server.ts`) — add `semanticSearch` fetcher/routers export if using oRPC client; if the FE uses raw fetch through the proxy, add a `POST /api/messages/semantic-search` or an oRPC client call consistent with existing `messages.*` calls (follow the EXISTING pattern in `src/lib/api/` — inspect how `messages.list` is called and replicate).
+
+**Step 1: Add FE types**
+```ts
+export interface SemanticSearchResult {
+ message_id: string;
+ content: string;
+ username: string;
+ channel_id: string;
+ guild_id: string;
+ created_at: number;
+ score: number;
+}
+export interface SemanticSearchResponse { results: SemanticSearchResult[]; nextCursor: string | null; }
+```
+
+**Step 2: Add hook + wire view**
+Follow the existing `use-moderation.ts` SWR pattern. Add `useMessagesSemanticSearch(query, guildId?)` returning `{ data, isLoading, error }`. In `messages/view.tsx`, add a search `Input` (from `@/components/primitives`) at the top, debounce ~300ms, and render results below the live list when a query is present. Reuse the message-row rendering already in that view (do not invent a new component).
+
+**Step 3: Type-check + lint (frontend)**
+Run: `cd services/frontend && pnpm typecheck && pnpm lint`
+Expected: PASS.
+
+**Step 4: Commit**
+```bash
+git add services/frontend/src/app/\(dashboard\)/messages/ \
+ services/frontend/src/lib/types/message.ts \
+ services/frontend/src/lib/api/ \
+ services/frontend/src/hooks/
+git commit -m "feat(web): public semantic message search UI"
+```
+
+---
+
+## TASK 8 — Build all services + deploy + verify
+
+1. `cd services/discord-gateway && pnpm typecheck && pnpm build && pnpm lint`
+2. `cd services/backend && pnpm typecheck && pnpm build && pnpm lint`
+3. `cd services/frontend && pnpm typecheck && pnpm build && pnpm lint`
+4. Commit any formatting fixes (biome `--unsafe` if import order), author `asepharyana`, NO Co-Authored-By.
+5. `git push origin main` → watch `gh run watch` on "Build & Deploy (Nix)".
+6. After deploy: verify `systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp,SubState` reflects new timestamp; same for backend + frontend.
+7. Smoke: `curl -s http://127.0.0.1:4001/trpc/messages.semanticSearch?input=` OR via the public web `imphnen.asepharyana.my.id` messages page → type a query → expect results (after some messages have been embedded; embeddings only run on NEW captures post-deploy, so seed a few test messages or backfill).
+8. Moderation explainability: trigger/observe a flagged message → confirm `moderation_actions.flags` is populated (SQL `SELECT flags, severity FROM moderation_actions ORDER BY created_at DESC LIMIT 5;`) and the public moderation view shows badges.
+
+---
+
+## RISKS / TRADEOFFS / OPEN QUESTIONS
+- **Embedding cost:** #3 embeds EVERY captured message → more embedding API calls. Mitigated: only text ≥3 chars, fire-and-forget, skip if model unconfigured. If cost is a concern, batch embed (reuse `embedTexts`) per capture burst — but start simple (per-message) and observe.
+- **Backfill:** post-deploy, the archive is empty until new messages arrive. Optional follow-up: a one-off backfill script over existing `messages` (out of scope for this plan unless user asks).
+- **Schema duplication:** the `pgModerationActionsTable` double-definition must stay in sync (Task 1 patches both). If `messages.ts` copy is provably dead, a follow-up can delete it — but NOT in this plan (avoid scope creep / risk).
+- **`AutoDeleteResult` verdict availability (Task 2):** requires confirming the `AnalysisResult` is reachable at the `createModerationAction` call sites. If not, we add an optional field to `AutoDeleteResult` at the orchestrator call site. This is the highest-risk integration point — verify before assuming.
+- **Public exposure:** semantic search returns message text + usernames. This is INTENDED (web is public for users). No auth added. If a guild wants private, that is a future config (out of scope).
+- **Qdrant payload type:** reusing `QdrantVerdictPayload` for archive is slightly awkward (carries `flags`/`expires_at` semantics). Cleaner: introduce `QdrantArchivePayload` with `message_id`, `channel_id`, `guild_id`, `username`, `content`, `created_at`, `expires_at`. **Prefer the dedicated payload type in Task 4/5** to avoid confusion.
+
+## VERIFICATION CHECKLIST
+- [ ] `moderation_actions` has 7 new columns (DB + both schema defs).
+- [ ] A real auto-delete populates `flags`/`severity`/`evidence` (verified via SQL).
+- [ ] Public moderation view renders badges + evidence (manual browser check on imphnen.asepharyana.my.id/moderation).
+- [ ] New message capture upserts a point into `gmw_message_archive` (verify via Qdrant `/collections/gmw_message_archive/points/count`).
+- [ ] `messages.semanticSearch` returns relevant results for a known phrase.
+- [ ] All three services: typecheck + build + lint green; CI "Build & Deploy (Nix)" green; systemd timestamps updated.
diff --git a/services/backend/src/modules/messages/embed.ts b/services/backend/src/modules/messages/embed.ts
new file mode 100644
index 0000000..b922bc1
--- /dev/null
+++ b/services/backend/src/modules/messages/embed.ts
@@ -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 {
+ 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;
+ }
+}
diff --git a/services/backend/src/modules/messages/messages.schema.ts b/services/backend/src/modules/messages/messages.schema.ts
index f26416b..1419e3d 100644
--- a/services/backend/src/modules/messages/messages.schema.ts
+++ b/services/backend/src/modules/messages/messages.schema.ts
@@ -41,3 +41,11 @@ export const messageUpdateSchema = z.object({
export type MessageQuery = z.infer;
export type MessageCreate = z.infer;
export type MessageUpdate = z.infer;
+
+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;
diff --git a/services/backend/src/modules/messages/messages.service.ts b/services/backend/src/modules/messages/messages.service.ts
index 8318bab..4dfb57d 100644
--- a/services/backend/src/modules/messages/messages.service.ts
+++ b/services/backend/src/modules/messages/messages.service.ts
@@ -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[]; 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();
diff --git a/services/backend/src/modules/messages/qdrant.ts b/services/backend/src/modules/messages/qdrant.ts
new file mode 100644
index 0000000..f4a6db9
--- /dev/null
+++ b/services/backend/src/modules/messages/qdrant.ts
@@ -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 {
+ const h: Record = { "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 {
+ 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 {
+ 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 [];
+ }
+}
diff --git a/services/backend/src/modules/moderation/moderation.repository.ts b/services/backend/src/modules/moderation/moderation.repository.ts
index d73c56e..e692160 100644
--- a/services/backend/src/modules/moderation/moderation.repository.ts
+++ b/services/backend/src/modules/moderation/moderation.repository.ts
@@ -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,
}));
diff --git a/services/backend/src/orpc/router.ts b/services/backend/src/orpc/router.ts
index 0b00d89..8e91ded 100644
--- a/services/backend/src/orpc/router.ts
+++ b/services/backend/src/orpc/router.ts
@@ -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 ───────────────────────────────────────────────────
diff --git a/services/backend/src/shared/config/index.ts b/services/backend/src/shared/config/index.ts
index 2739a24..7a3cb3d 100644
--- a/services/backend/src/shared/config/index.ts
+++ b/services/backend/src/shared/config/index.ts
@@ -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()
diff --git a/services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql b/services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql
new file mode 100644
index 0000000..eaf61b3
--- /dev/null
+++ b/services/discord-gateway/drizzle/migrations/0015_add_moderation_explainability.sql
@@ -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;
diff --git a/services/discord-gateway/drizzle/migrations/meta/_journal.json b/services/discord-gateway/drizzle/migrations/meta/_journal.json
index 7f8a41e..3cc57c4 100644
--- a/services/discord-gateway/drizzle/migrations/meta/_journal.json
+++ b/services/discord-gateway/drizzle/migrations/meta/_journal.json
@@ -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
}
]
}
\ No newline at end of file
diff --git a/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts b/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts
index 6418ec0..cad3509 100644
--- a/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts
+++ b/services/discord-gateway/src/modules/ai-moderation/autoDeleteManager.ts
@@ -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",
diff --git a/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts b/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts
index f8c13a7..2343b87 100644
--- a/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts
+++ b/services/discord-gateway/src/modules/ai-moderation/qdrantClient.ts
@@ -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 {
const h: Record = {
"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 {
+ 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 {
+ 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,
+ "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 {
+ 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,
+ "Qdrant archive search failed — semantic search skipped",
+ );
+ return [];
+ }
+}
diff --git a/services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts b/services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts
new file mode 100644
index 0000000..d43df48
--- /dev/null
+++ b/services/discord-gateway/src/modules/ai-moderation/verdictToActionFields.ts
@@ -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
+ };
+}
diff --git a/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts b/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts
new file mode 100644
index 0000000..1d2540a
--- /dev/null
+++ b/services/discord-gateway/src/modules/message-capture/archiveEmbedder.ts
@@ -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",
+ );
+ }
+ })();
+}
diff --git a/services/discord-gateway/src/modules/message-capture/messageCapture.ts b/services/discord-gateway/src/modules/message-capture/messageCapture.ts
index f789d4f..8feeca8 100644
--- a/services/discord-gateway/src/modules/message-capture/messageCapture.ts
+++ b/services/discord-gateway/src/modules/message-capture/messageCapture.ts
@@ -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 {
+ 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);
diff --git a/services/discord-gateway/src/shared/config/index.ts b/services/discord-gateway/src/shared/config/index.ts
index c5f5baf..a32c557 100644
--- a/services/discord-gateway/src/shared/config/index.ts
+++ b/services/discord-gateway/src/shared/config/index.ts
@@ -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
diff --git a/services/discord-gateway/src/shared/database/schema.ts b/services/discord-gateway/src/shared/database/schema.ts
index 414519f..33e2b28 100644
--- a/services/discord-gateway/src/shared/database/schema.ts
+++ b/services/discord-gateway/src/shared/database/schema.ts
@@ -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(
diff --git a/services/discord-gateway/src/shared/database/schema/messages.ts b/services/discord-gateway/src/shared/database/schema/messages.ts
index b4b6415..39f22f1 100644
--- a/services/discord-gateway/src/shared/database/schema/messages.ts
+++ b/services/discord-gateway/src/shared/database/schema/messages.ts
@@ -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(
diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx
index fa8b850..dfa1a10 100644
--- a/services/frontend/src/app/(dashboard)/messages/view.tsx
+++ b/services/frontend/src/app/(dashboard)/messages/view.tsx
@@ -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(null);
const [selected, setSelected] = useState(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)}
/>
+