- Persist structured verdict (flags/severity/confidence/evidence) on moderation_actions so the public web can show WHY a message was moderated. - Add a persistent Qdrant archive collection (gmw_message_archive); embed every captured message at capture time (fire-and-forget, best-effort). - Public semantic search over the archive (backend oRPC + FE toggle on the messages view). Both features are read-only/public and fully automatic. Migration: 0015_add_moderation_explainability.sql
30 KiB
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 onmoderation_actions, surface them through the existing public moderation oRPC + the existing publicmoderationdashboard 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 (reuseembedText) and upsert. Add a publicmessages.semanticSearchoRPC + a read-only search UI on the publicmessagesview. 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):
AnalysisResultshape (src/modules/ai-moderation/ai-analysis-worker.ts:55):messageId, status, flags[], categories[], severity, confidence, recommendedAction, score, analysis, correctedFlags?. The sharedAnalysisResult(src/shared/moderation-types.ts:142) ALSO hasevidence?: string[]andpolicyVersion?: string. THESE ARE ALREADY COMPUTED but only logged, never persisted tomoderation_actions.moderation_actionsschema is DEFINED TWICE with a divergence:src/shared/database/schema.ts:638→pgModerationActionsTable(authoritative, hasreset_nicknameinaction_typeenum).src/shared/database/schema/messages.ts:25→ anotherpgModerationActionsTable(NOreset_nickname; gateway-local copy).- The gateway's
ModerationActionsDb(src/modules/message-capture/moderationActionsDb.ts) imports fromschema.ts(the authoritative one). Themessages.tscopy appears UNUSED for DB ops — but WE MUST ADD NEW COLUMNS TO BOTH to avoid type drift, OR confirm themessages.tscopy is dead and delete it. Decision: add columns toschema.ts(authoritative) AND themessages.tscopy to keep$inferInsert/$inferSelectin sync (the gatewayModerationActiontype flows from shared). Verify with grep thatmessages.tspgModerationActionsTableis 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 byrunMigrations()→migratePostgres. New tables/columns must be added withdrizzle-kit generateto produce a numbered.sql+ journal entry, OR (simpler, matches0013_rename_*.sqlmanual style) write a raw idempotent.sqlunderdrizzle/migrations/AND register it in_journal.json. Preferred here: usepnpm drizzle-kit generateso the journal stays consistent. The legacysrc/shared/database/migrations/001_drop_unused_ai_columns.sqlis a PRE-drizzle manual script — do NOT follow that pattern. - Historical lesson (MUST respect): a prior migration (
0004_drop_unused_ai_columns.sql= old001_drop) DELETEDai_evidence,ai_policy_version,ai_moderation_rawfrommessageswith the note "written but never read". → Our newmoderation_actionscolumns MUST be read (serializer + FE render). No write-only columns. - Embedding client:
embedText(text)/embedTexts(texts[])insrc/modules/ai-moderation/embeddingClient.ts. ReturnsnullifAI_LLM_EMBEDDING_MODELnot configured. Reusesconfig.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. HasensureQdrantCollection(vectorSize),upsertQdrantPoint(cacheKey, vector, payload),searchQdrant(vector, limit, scoreThreshold). These are hardcoded to the cache collection nameconfig.QDRANT_COLLECTION ?? "gmw_text_moderation". #3 needs a second collection → generalize the client to accept a collection name param (addensureQdrantCollectionV2(name, size)/upsertQdrantPointV2(name, id, vector, payload)/searchQdrantV2(name, vector, limit, scoreThreshold)OR refactorcollectionName()to take an arg). Keep the cache path unchanged. - Capture hook:
captureMessage()(src/modules/message-capture/messageCapture.ts:201) callsmessageStore.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.tsxrendersActionRowper action. TheModerationActionFE type is inservices/frontend/src/lib/types/moderation.ts(NO new fields yet). The backendmoderationService.listActionsSQL is inservices/backend/src/modules/moderation/moderation.repository.ts:68(raw SQL, selects fixed columns, joinsmessages). - oRPC wiring:
services/backend/src/orpc/router.ts→moderationRouter(stats, actions) andmessagesRouter(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(authoritativepgModerationActionsTable, ~line 638) - Modify:
services/discord-gateway/src/shared/database/schema/messages.ts(pgModerationActionsTablecopy, ~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:
// ── 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):
-- 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
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 secondcreateModerationActioncall ~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:
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:
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
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(listActionsraw SQL ~line 68) — add new columns to SELECT + map. - Modify:
services/frontend/src/lib/types/moderation.ts(ModerationActioninterface) — 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:
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:
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):
{a.severity && (
<Badge tone={aiTone(a.severity === "none" ? "clean" : a.severity)}>
{a.severity}
</Badge>
)}
{a.flags?.length ? (
<div className="mt-1 flex flex-wrap gap-1">
{a.flags.map((f) => <Badge key={f} tone="amber">{f}</Badge>)}
</div>
) : null}
{a.evidence?.length ? (
<div className="mt-1 text-xs text-ink-faint border-l-2 border-hairline pl-2">
“{a.evidence[0]}”
</div>
) : null}
{a.confidence != null && (
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
conf {(a.confidence * 100).toFixed(0)}%
</div>
)}
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
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:
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<boolean> {
// same body as ensureQdrantCollection but uses `name` instead of collectionName()
}
export async function upsertQdrantPointV2(
name: string, pointId: number, vector: number[], payload: QdrantVerdictPayload,
): Promise<boolean> { /* PUT /collections/{name}/points with wait:true */ }
export async function searchQdrantV2(
name: string, vector: number[], limit: number, scoreThreshold: number,
): Promise<QdrantSearchHit[]> { /* 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
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
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:
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
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— addsemanticSearch(query, limit, guildId?). - Modify:
services/backend/src/modules/messages/messages.service.ts— exposesemanticSearch. - Modify:
services/backend/src/orpc/router.ts— addmessages.semanticSearchprocedure. - Create (or reuse): an embedding call from the backend. The backend does NOT import the gateway's
embeddingClient. Decision: add a minimal backend embed helperservices/backend/src/modules/messages/embed.tsthat calls the same OpenAI-compatible endpoint viaconfig(reuseconfig.AI_LLM_BASE_URL/AI_LLM_API_KEY/AI_LLM_EMBEDDING_MODELif present on the backend; if not configured, return a clear "search unavailable" error). Mirrorencoding_format: "float". - Modify:
services/backend/src/modules/messages/messages.schema.ts— addsemanticSearchQueryzod schema (limit, guildId?, query).
Step 1: Backend embed helper (embed.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<number[] | null> {
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
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:<messageId>
// 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:
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
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 newuseMessagesSemanticSearchhook →messages.semanticSearchoRPC, renders results as message cards (reuseGlassPanel/Badge/existing message row components). - Modify:
services/frontend/src/lib/types/message.ts— addSemanticSearchResult+SemanticSearchResponsetypes. - Modify:
services/frontend/src/lib/api/client.ts(orserver.ts) — addsemanticSearchfetcher/routers export if using oRPC client; if the FE uses raw fetch through the proxy, add aPOST /api/messages/semantic-searchor an oRPC client call consistent with existingmessages.*calls (follow the EXISTING pattern insrc/lib/api/— inspect howmessages.listis called and replicate).
Step 1: Add FE types
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
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
cd services/discord-gateway && pnpm typecheck && pnpm build && pnpm lintcd services/backend && pnpm typecheck && pnpm build && pnpm lintcd services/frontend && pnpm typecheck && pnpm build && pnpm lint- Commit any formatting fixes (biome
--unsafeif import order), authorasepharyana, NO Co-Authored-By. git push origin main→ watchgh run watchon "Build & Deploy (Nix)".- After deploy: verify
systemctl show gmw-discord-gateway.service --property=ActiveEnterTimestamp,SubStatereflects new timestamp; same for backend + frontend. - Smoke:
curl -s http://127.0.0.1:4001/trpc/messages.semanticSearch?input=<urlencoded json>OR via the public webimphnen.asepharyana.my.idmessages 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). - Moderation explainability: trigger/observe a flagged message → confirm
moderation_actions.flagsis populated (SQLSELECT 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
pgModerationActionsTabledouble-definition must stay in sync (Task 1 patches both). Ifmessages.tscopy is provably dead, a follow-up can delete it — but NOT in this plan (avoid scope creep / risk). AutoDeleteResultverdict availability (Task 2): requires confirming theAnalysisResultis reachable at thecreateModerationActioncall sites. If not, we add an optional field toAutoDeleteResultat 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
QdrantVerdictPayloadfor archive is slightly awkward (carriesflags/expires_atsemantics). Cleaner: introduceQdrantArchivePayloadwithmessage_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_actionshas 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.semanticSearchreturns relevant results for a known phrase.- All three services: typecheck + build + lint green; CI "Build & Deploy (Nix)" green; systemd timestamps updated.