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)} /> +
+ {semanticSearching && ( + + + {semantic.data?.length ?? 0} matches + + } + /> + {semantic.isLoading ? ( + + ) : semantic.data && semantic.data.length > 0 ? ( +
+ {semantic.data.map((r, i) => ( +
+
+
+ + {(r.score * 100).toFixed(0)}% + + + {formatRelativeTime(r.created_at)} + +
+
+ {r.content} +
+
+
+ ))} +
+ ) : ( + } + title="No semantic matches" + description="Try different wording — semantic search finds meaning, not exact text." + /> + )} +
+ )} + ); + // 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 (
“{a.reason}”
)} + {severityTone && a.severity && ( +
+ {a.severity} + {a.confidence != null && ( + + conf {(a.confidence * 100).toFixed(0)}% + + )} +
+ )} + {a.flags?.length ? ( +
+ {a.flags.slice(0, 6).map((f) => ( + + {f} + + ))} +
+ ) : null} + {a.evidence?.length ? ( +
+ “{a.evidence[0]}” +
+ ) : null} {a.executed_by && (
by {a.executed_by} diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 5f29fe5..1a21407 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -28,6 +28,7 @@ export { useMessagesStream, useMessagesWsSync, useReview, + useSemanticSearch, useTextChannels, } from "./use-messages"; export { diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts index 8569dbf..2cced71 100644 --- a/services/frontend/src/hooks/use-messages.ts +++ b/services/frontend/src/hooks/use-messages.ts @@ -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( + 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) { diff --git a/services/frontend/src/lib/api/messages.ts b/services/frontend/src/lib/api/messages.ts index 454f0c1..0eb52fb 100644 --- a/services/frontend/src/lib/api/messages.ts +++ b/services/frontend/src/lib/api/messages.ts @@ -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; + }>, }; diff --git a/services/frontend/src/lib/types/message.ts b/services/frontend/src/lib/types/message.ts index aa88338..5e6846a 100644 --- a/services/frontend/src/lib/types/message.ts +++ b/services/frontend/src/lib/types/message.ts @@ -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; +} diff --git a/services/frontend/src/lib/types/moderation.ts b/services/frontend/src/lib/types/moderation.ts index 3c4d050..3946dfb 100644 --- a/services/frontend/src/lib/types/moderation.ts +++ b/services/frontend/src/lib/types/moderation.ts @@ -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 {