feat(gmw): moderation explainability + semantic message search
- Persist structured verdict (flags/severity/confidence/evidence) on moderation_actions so the public web can show WHY a message was moderated. - Add a persistent Qdrant archive collection (gmw_message_archive); embed every captured message at capture time (fire-and-forget, best-effort). - Public semantic search over the archive (backend oRPC + FE toggle on the messages view). Both features are read-only/public and fully automatic. Migration: 0015_add_moderation_explainability.sql
This commit is contained in:
@@ -0,0 +1,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 && (
|
||||||
|
<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**
|
||||||
|
```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<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**
|
||||||
|
```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<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`**
|
||||||
|
```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:<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`:
|
||||||
|
```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=<urlencoded json>` 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.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { config } from "@/shared/config/index.js";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("messages-embed");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed a search query with the configured OpenAI-compatible embedding model.
|
||||||
|
* Uses raw fetch (the backend has no openai SDK dependency) and returns null
|
||||||
|
* when embeddings are not configured (search unavailable).
|
||||||
|
*
|
||||||
|
* encoding_format: "float" is REQUIRED — Nvidia-backed models reject base64.
|
||||||
|
*/
|
||||||
|
export async function embedQuery(text: string): Promise<number[] | null> {
|
||||||
|
if (!config.AI_LLM_API_KEY || !config.AI_LLM_EMBEDDING_MODEL) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${config.AI_LLM_BASE_URL}/embeddings`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${config.AI_LLM_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: config.AI_LLM_EMBEDDING_MODEL,
|
||||||
|
input: text,
|
||||||
|
encoding_format: "float",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
logger.warn({ status: res.status }, "query embed HTTP error");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const json = (await res.json()) as {
|
||||||
|
data?: Array<{ embedding?: number[] }>;
|
||||||
|
};
|
||||||
|
return json.data?.[0]?.embedding ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"query embed failed",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,3 +41,11 @@ export const messageUpdateSchema = z.object({
|
|||||||
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
export type MessageQuery = z.infer<typeof messageQuerySchema>;
|
||||||
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
export type MessageCreate = z.infer<typeof messageCreateSchema>;
|
||||||
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
export type MessageUpdate = z.infer<typeof messageUpdateSchema>;
|
||||||
|
|
||||||
|
export const semanticSearchSchema = z.object({
|
||||||
|
query: z.string().min(1).max(500),
|
||||||
|
limit: z.coerce.number().int().positive().max(50).default(10),
|
||||||
|
guildId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SemanticSearchQuery = z.infer<typeof semanticSearchSchema>;
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { NotFoundError, ValidationError } from "@/shared/errors/index";
|
import { NotFoundError, ValidationError } from "@/shared/errors/index";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { embedQuery } from "./embed.js";
|
||||||
import { messagesRepository } from "./messages.repository.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");
|
const logger = createChildLogger("messages.service");
|
||||||
|
|
||||||
@@ -78,6 +80,40 @@ export class MessagesService {
|
|||||||
logger.debug({ channelId, limit }, "Getting review messages");
|
logger.debug({ channelId, limit }, "Getting review messages");
|
||||||
return messagesRepository.getReviewMessages(channelId, limit);
|
return messagesRepository.getReviewMessages(channelId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public, read-only semantic search over the persistent message archive.
|
||||||
|
* Embeds the query, searches Qdrant, returns text + metadata. Best-effort:
|
||||||
|
* if embeddings/Qdrant are unavailable, returns an empty result set.
|
||||||
|
*/
|
||||||
|
async semanticSearch(
|
||||||
|
input: SemanticSearchQuery,
|
||||||
|
): Promise<{ results: ReturnType<typeof mapSearchHit>[]; nextCursor: null }> {
|
||||||
|
const vector = await embedQuery(input.query);
|
||||||
|
if (!vector) {
|
||||||
|
logger.debug(
|
||||||
|
{ query: input.query },
|
||||||
|
"semantic search skipped: no embedder",
|
||||||
|
);
|
||||||
|
return { results: [], nextCursor: null };
|
||||||
|
}
|
||||||
|
const hits = await searchArchive(vector, input.limit, 0.6);
|
||||||
|
const results = hits.map((h) => mapSearchHit(h));
|
||||||
|
return { results, nextCursor: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shape returned to the frontend (text + metadata from the archive payload). */
|
||||||
|
function mapSearchHit(hit: {
|
||||||
|
score: number;
|
||||||
|
payload: { text: string; content_hash?: string; analyzed_at: number };
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
message_id: hit.payload.content_hash ?? null,
|
||||||
|
content: hit.payload.text,
|
||||||
|
score: hit.score,
|
||||||
|
created_at: hit.payload.analyzed_at,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const messagesService = new MessagesService();
|
export const messagesService = new MessagesService();
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { config } from "@/shared/config/index.js";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index.js";
|
||||||
|
|
||||||
|
const logger = createChildLogger("messages-qdrant");
|
||||||
|
|
||||||
|
export interface ArchiveHit {
|
||||||
|
score: number;
|
||||||
|
payload: {
|
||||||
|
text: string;
|
||||||
|
content_hash?: string;
|
||||||
|
analyzed_at: number;
|
||||||
|
expires_at: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseUrl(): string {
|
||||||
|
return (config.QDRANT_URL ?? "http://100.121.180.82:6333").replace(
|
||||||
|
/\/+$/,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function headers(): Record<string, string> {
|
||||||
|
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
if (config.QDRANT_API_KEY) h["api-key"] = config.QDRANT_API_KEY;
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ARCHIVE_COLLECTION =
|
||||||
|
config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
|
||||||
|
|
||||||
|
async function request(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
timeoutMs = 10_000,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl()}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: headers(),
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Qdrant ${method} ${path} -> ${res.status}: ${text.slice(0, 200)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search the archive collection for the nearest vectors to `vector`. */
|
||||||
|
export async function searchArchive(
|
||||||
|
vector: number[],
|
||||||
|
limit: number,
|
||||||
|
scoreThreshold: number,
|
||||||
|
): Promise<ArchiveHit[]> {
|
||||||
|
if (!config.QDRANT_URL) return [];
|
||||||
|
try {
|
||||||
|
const json = (await request(
|
||||||
|
"POST",
|
||||||
|
`/collections/${ARCHIVE_COLLECTION}/points/search`,
|
||||||
|
{
|
||||||
|
vector,
|
||||||
|
limit,
|
||||||
|
score_threshold: scoreThreshold,
|
||||||
|
with_payload: true,
|
||||||
|
},
|
||||||
|
)) as {
|
||||||
|
result?: Array<{
|
||||||
|
score?: number;
|
||||||
|
payload?: ArchiveHit["payload"];
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
return (json.result ?? [])
|
||||||
|
.filter((h) => h.payload?.text)
|
||||||
|
.map((h) => ({
|
||||||
|
score: h.score ?? 0,
|
||||||
|
payload: h.payload as ArchiveHit["payload"],
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"archive search failed",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,20 @@ const ACTION_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
const STATUSES = ["pending", "executed", "failed"] 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 {
|
export class ModerationRepository {
|
||||||
async getStats() {
|
async getStats() {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -103,6 +117,13 @@ export class ModerationRepository {
|
|||||||
a.error,
|
a.error,
|
||||||
a.created_at,
|
a.created_at,
|
||||||
a.executed_at,
|
a.executed_at,
|
||||||
|
a.flags,
|
||||||
|
a.categories,
|
||||||
|
a.severity,
|
||||||
|
a.confidence,
|
||||||
|
a.score,
|
||||||
|
a.evidence,
|
||||||
|
a.policy_version,
|
||||||
m.username,
|
m.username,
|
||||||
LEFT(m.content, 300) AS content
|
LEFT(m.content, 300) AS content
|
||||||
FROM moderation_actions a
|
FROM moderation_actions a
|
||||||
@@ -126,6 +147,13 @@ export class ModerationRepository {
|
|||||||
error: r.error ? String(r.error) : null,
|
error: r.error ? String(r.error) : null,
|
||||||
created_at: r.created_at ? Number(r.created_at) : null,
|
created_at: r.created_at ? Number(r.created_at) : null,
|
||||||
executed_at: r.executed_at ? Number(r.executed_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,
|
username: r.username ? String(r.username) : null,
|
||||||
content: r.content ? String(r.content) : null,
|
content: r.content ? String(r.content) : null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ import {
|
|||||||
skip,
|
skip,
|
||||||
stop,
|
stop,
|
||||||
} from "../modules/media/media.service";
|
} 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 { messagesService } from "../modules/messages/messages.service";
|
||||||
import { moderationService } from "../modules/moderation/moderation.service";
|
import { moderationService } from "../modules/moderation/moderation.service";
|
||||||
import { recordingsService } from "../modules/recordings/recordings.service";
|
import { recordingsService } from "../modules/recordings/recordings.service";
|
||||||
@@ -136,6 +139,10 @@ const messagesRouter = {
|
|||||||
);
|
);
|
||||||
return { results: rows, limit: input.limit, cursor: null };
|
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 ───────────────────────────────────────────────────
|
// ── Moderation ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export const configSchema = z
|
|||||||
.default("https://9router.asepharyana.my.id/v1"),
|
.default("https://9router.asepharyana.my.id/v1"),
|
||||||
AI_LLM_MODEL: z.string().default("text"),
|
AI_LLM_MODEL: z.string().default("text"),
|
||||||
AI_LLM_VISION_MODEL: z.string().optional(),
|
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_MAX_CONCURRENT: z.coerce.number().int().positive().default(5),
|
||||||
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
||||||
.number()
|
.number()
|
||||||
@@ -208,6 +209,11 @@ export const configSchema = z
|
|||||||
.default("https://api.openai.com/v1"),
|
.default("https://api.openai.com/v1"),
|
||||||
OPENAI_MODERATION_MODEL: z.string().default("omni-moderation-latest"),
|
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 ─────────────────────────────────────────────────────
|
||||||
AUTO_DELETE_FLAGGED_ENABLED: z
|
AUTO_DELETE_FLAGGED_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Migration: 0015_add_moderation_explainability.sql
|
||||||
|
-- Date: 2026-08-18
|
||||||
|
-- Description: Add structured explainability columns to moderation_actions.
|
||||||
|
-- These are READ (surfaced read-only to the public web) so they are never
|
||||||
|
-- "written but never read" — they back the public moderation transparency view.
|
||||||
|
-- Idempotent: no-ops on databases that already carry the columns.
|
||||||
|
|
||||||
|
ALTER TABLE IF EXISTS "moderation_actions"
|
||||||
|
ADD COLUMN IF NOT EXISTS "flags" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "categories" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "severity" text
|
||||||
|
CHECK ("severity" IS NULL OR "severity" IN ('none','low','medium','high','critical')),
|
||||||
|
ADD COLUMN IF NOT EXISTS "confidence" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "score" real,
|
||||||
|
ADD COLUMN IF NOT EXISTS "evidence" text,
|
||||||
|
ADD COLUMN IF NOT EXISTS "policy_version" text;
|
||||||
@@ -106,6 +106,13 @@
|
|||||||
"when": 1785621600000,
|
"when": 1785621600000,
|
||||||
"tag": "0014_add_term_glossary_cache",
|
"tag": "0014_add_term_glossary_cache",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787184000000,
|
||||||
|
"tag": "0015_add_moderation_explainability",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "./autoDeleteEligibility.js";
|
} from "./autoDeleteEligibility.js";
|
||||||
import { logDeletionToChannel } from "./autoDeleteLogger.js";
|
import { logDeletionToChannel } from "./autoDeleteLogger.js";
|
||||||
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
import { sendDeletionNotification } from "./autoDeleteNotify.js";
|
||||||
|
import { verdictToActionFields } from "./verdictToActionFields.js";
|
||||||
|
|
||||||
const logger = createChildLogger("auto-delete-manager");
|
const logger = createChildLogger("auto-delete-manager");
|
||||||
|
|
||||||
@@ -174,6 +175,7 @@ async function logAutoDeleteAttempt(
|
|||||||
guild_id: message.guild_id,
|
guild_id: message.guild_id,
|
||||||
action_type: "delete_message",
|
action_type: "delete_message",
|
||||||
reason: result.reason,
|
reason: result.reason,
|
||||||
|
...verdictToActionFields(message),
|
||||||
executed_by: "auto-delete-manager",
|
executed_by: "auto-delete-manager",
|
||||||
status: result.deleted
|
status: result.deleted
|
||||||
? "executed"
|
? "executed"
|
||||||
@@ -238,6 +240,7 @@ export async function attemptAutoDeleteFlaggedMessage(
|
|||||||
action_type: "reset_nickname",
|
action_type: "reset_nickname",
|
||||||
reason:
|
reason:
|
||||||
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
|
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
|
||||||
|
...verdictToActionFields(message),
|
||||||
executed_by: "auto-delete-manager",
|
executed_by: "auto-delete-manager",
|
||||||
status: resetOk ? "executed" : "failed",
|
status: resetOk ? "executed" : "failed",
|
||||||
error: resetOk ? null : "nickname_reset_failed",
|
error: resetOk ? null : "nickname_reset_failed",
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ function collectionName(): string {
|
|||||||
return config.QDRANT_COLLECTION ?? "gmw_text_moderation";
|
return config.QDRANT_COLLECTION ?? "gmw_text_moderation";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persistent archive collection for semantic message search (no TTL). */
|
||||||
|
export const ARCHIVE_COLLECTION =
|
||||||
|
config.QDRANT_ARCHIVE_COLLECTION ?? "gmw_message_archive";
|
||||||
|
|
||||||
function headers(): Record<string, string> {
|
function headers(): Record<string, string> {
|
||||||
const h: Record<string, string> = {
|
const h: Record<string, string> = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -390,3 +394,131 @@ export async function deleteQdrantPointsByContentHash(
|
|||||||
export function isQdrantConfigured(): boolean {
|
export function isQdrantConfigured(): boolean {
|
||||||
return Boolean(config.QDRANT_URL);
|
return Boolean(config.QDRANT_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Archive variants (collection-aware, for persistent message search) ───
|
||||||
|
// These mirror the cache functions but take an explicit collection name so the
|
||||||
|
// semantic-search archive (gmw_message_archive) can live alongside the
|
||||||
|
// TTL-bounded automod cache without disturbing it.
|
||||||
|
|
||||||
|
/** Ensure an arbitrary collection exists with the right vector size. */
|
||||||
|
export async function ensureQdrantCollectionV2(
|
||||||
|
name: string,
|
||||||
|
vectorSize: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let existing: {
|
||||||
|
result?: { config?: { params?: { vectors?: { size?: number } } } };
|
||||||
|
} | null = null;
|
||||||
|
try {
|
||||||
|
existing = (await request("GET", `/collections/${name}`)) as {
|
||||||
|
result?: { config?: { params?: { vectors?: { size?: number } } } };
|
||||||
|
} | null;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error) || !error.message.includes("-> 404")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = existing?.result?.config?.params?.vectors?.size;
|
||||||
|
if (size === vectorSize) return true;
|
||||||
|
|
||||||
|
if (size !== undefined && size !== vectorSize) {
|
||||||
|
log.warn(
|
||||||
|
{ collection: name, oldSize: size, newSize: vectorSize },
|
||||||
|
"Qdrant archive collection vector size changed — recreating collection",
|
||||||
|
);
|
||||||
|
await request("DELETE", `/collections/${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await request("PUT", `/collections/${name}`, {
|
||||||
|
vectors: { size: vectorSize, distance: "Cosine" },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
collection: name,
|
||||||
|
},
|
||||||
|
"Failed to ensure Qdrant archive collection",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upsert one embedding + payload point into a named collection. */
|
||||||
|
export async function upsertQdrantPointV2(
|
||||||
|
name: string,
|
||||||
|
pointId: number,
|
||||||
|
vector: number[],
|
||||||
|
payload: QdrantVerdictPayload,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (!(await ensureQdrantCollectionV2(name, vector.length))) return false;
|
||||||
|
await request(
|
||||||
|
"PUT",
|
||||||
|
`/collections/${name}/points`,
|
||||||
|
{
|
||||||
|
points: [{ id: pointId, vector, payload }],
|
||||||
|
wait: true,
|
||||||
|
},
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
collection: name,
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
"Qdrant archive upsert failed — entry skipped",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QdrantArchiveHit {
|
||||||
|
pointId: number;
|
||||||
|
score: number;
|
||||||
|
payload: QdrantVerdictPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Search a named collection for the nearest stored vector. */
|
||||||
|
export async function searchQdrantV2(
|
||||||
|
name: string,
|
||||||
|
vector: number[],
|
||||||
|
limit: number,
|
||||||
|
scoreThreshold: number,
|
||||||
|
): Promise<QdrantArchiveHit[]> {
|
||||||
|
try {
|
||||||
|
const json = (await request("POST", `/collections/${name}/points/search`, {
|
||||||
|
vector,
|
||||||
|
limit,
|
||||||
|
score_threshold: scoreThreshold,
|
||||||
|
with_payload: true,
|
||||||
|
})) as {
|
||||||
|
result?: Array<{
|
||||||
|
id?: number;
|
||||||
|
score?: number;
|
||||||
|
payload?: QdrantVerdictPayload;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (json.result ?? [])
|
||||||
|
.filter((hit) => hit.payload?.text)
|
||||||
|
.map((hit) => ({
|
||||||
|
pointId: hit.id ?? 0,
|
||||||
|
score: hit.score ?? 0,
|
||||||
|
payload: hit.payload as QdrantVerdictPayload,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
collection: name,
|
||||||
|
} as Record<string, unknown>,
|
||||||
|
"Qdrant archive search failed — semantic search skipped",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a captured message's persisted AI verdict (the `ai_*` columns on
|
||||||
|
* MessageRecord) into the explainability columns of a moderation action.
|
||||||
|
*
|
||||||
|
* This is READ-ONLY structured data — it never changes any enforcement
|
||||||
|
* decision. It exists so the public web view can show *why* a message was
|
||||||
|
* moderated, making GMW's automod transparent instead of a black box.
|
||||||
|
*
|
||||||
|
* All fields are null-safe: manual actions (e.g. command-handler bans) carry
|
||||||
|
* no AI verdict, so they simply store nulls and the UI falls back to the
|
||||||
|
* free-text `reason`.
|
||||||
|
*/
|
||||||
|
export function verdictToActionFields(message?: MessageRecord | null): {
|
||||||
|
flags: string | null;
|
||||||
|
categories: string | null;
|
||||||
|
severity: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
score: number | null;
|
||||||
|
evidence: string | null;
|
||||||
|
policy_version: string | null;
|
||||||
|
} {
|
||||||
|
if (!message) {
|
||||||
|
return {
|
||||||
|
flags: null,
|
||||||
|
categories: null,
|
||||||
|
severity: null,
|
||||||
|
confidence: null,
|
||||||
|
score: null,
|
||||||
|
evidence: null,
|
||||||
|
policy_version: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ai_moderation_flags / ai_categories are stored as JSON-stringified TEXT
|
||||||
|
// (see messagesAnalysis.buildAIAnalysisSet → stringifyAIList). Pass them
|
||||||
|
// through verbatim so the backend can JSON.parse them back into arrays.
|
||||||
|
return {
|
||||||
|
flags: message.ai_moderation_flags ?? null,
|
||||||
|
categories: message.ai_categories ?? null,
|
||||||
|
severity: message.ai_severity ?? null,
|
||||||
|
confidence: message.ai_confidence ?? null,
|
||||||
|
score: message.ai_moderation_score ?? null,
|
||||||
|
evidence: null, // not persisted on MessageRecord; reserved for future use
|
||||||
|
policy_version: null, // set by caller if a policy version is available
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { embedText } from "@/modules/ai-moderation/embeddingClient.js";
|
||||||
|
import {
|
||||||
|
ARCHIVE_COLLECTION,
|
||||||
|
qdrantPointId,
|
||||||
|
upsertQdrantPointV2,
|
||||||
|
} from "@/modules/ai-moderation/qdrantClient.js";
|
||||||
|
import { config } from "@/shared/config/config.js";
|
||||||
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
|
||||||
|
const log = createChildLogger("archive-embedder");
|
||||||
|
|
||||||
|
export interface ArchiveMessage {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
username: string;
|
||||||
|
channel_id: string;
|
||||||
|
guild_id: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget: embed a captured message and upsert it into the persistent
|
||||||
|
* archive collection so the public web can semantic-search the corpus.
|
||||||
|
*
|
||||||
|
* Failures are swallowed — searching is a nice-to-have, never a precondition
|
||||||
|
* for capture or moderation. The message text is kept in the payload so the
|
||||||
|
* search endpoint can return results even for deleted messages.
|
||||||
|
*/
|
||||||
|
export function archiveMessageEmbedded(message: ArchiveMessage): void {
|
||||||
|
if (!config.AI_LLM_EMBEDDING_MODEL) return; // embeddings disabled → skip
|
||||||
|
const text = message.content?.trim();
|
||||||
|
if (!text || text.length < 3) return;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const vector = await embedText(text);
|
||||||
|
if (!vector) return;
|
||||||
|
const ok = await upsertQdrantPointV2(
|
||||||
|
ARCHIVE_COLLECTION,
|
||||||
|
qdrantPointId(`archive:${message.id}`),
|
||||||
|
vector,
|
||||||
|
{
|
||||||
|
text: text.slice(0, 4000),
|
||||||
|
flags: "",
|
||||||
|
analyzed_at: Date.now(),
|
||||||
|
// 5-year persistent window (archive is NOT a TTL cache).
|
||||||
|
expires_at: Date.now() + 1000 * 60 * 60 * 24 * 365 * 5,
|
||||||
|
content_hash: message.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
log.debug({ messageId: message.id }, "Archived message embedding");
|
||||||
|
} catch (err) {
|
||||||
|
log.debug(
|
||||||
|
{
|
||||||
|
messageId: message.id,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
"archive embed skipped",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { config } from "../../shared/config/config.js";
|
|||||||
import { queueMessageAnalysis } from "../ai-moderation/aiAnalyzer.js";
|
import { queueMessageAnalysis } from "../ai-moderation/aiAnalyzer.js";
|
||||||
import { processAttachmentUpload } from "../attachment-upload/attachmentUploader.js";
|
import { processAttachmentUpload } from "../attachment-upload/attachmentUploader.js";
|
||||||
import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js";
|
import type { EventBroadcaster } from "../event-broadcaster/eventBroadcaster.js";
|
||||||
|
import { archiveMessageEmbedded } from "../message-capture/archiveEmbedder.js";
|
||||||
import {
|
import {
|
||||||
getDisplayContent,
|
getDisplayContent,
|
||||||
getMessageLocation,
|
getMessageLocation,
|
||||||
@@ -203,6 +204,7 @@ export async function captureMessage(
|
|||||||
type: "text" | "edited" | "deleted",
|
type: "text" | "edited" | "deleted",
|
||||||
options: { source?: "live" | "backlog" } = {},
|
options: { source?: "live" | "backlog" } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const isBacklog = options.source === "backlog";
|
||||||
const location = getMessageLocation(message);
|
const location = getMessageLocation(message);
|
||||||
const messageRecord = buildMessageRecord(message, type);
|
const messageRecord = buildMessageRecord(message, type);
|
||||||
|
|
||||||
@@ -211,7 +213,11 @@ export async function captureMessage(
|
|||||||
return;
|
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) {
|
if (_eventBroadcaster && !isBacklog) {
|
||||||
_eventBroadcaster.messageCreated(messageRecord);
|
_eventBroadcaster.messageCreated(messageRecord);
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export const configSchema = z
|
|||||||
// embedding column remains as a legacy fallback).
|
// embedding column remains as a legacy fallback).
|
||||||
QDRANT_URL: z.string().optional(),
|
QDRANT_URL: z.string().optional(),
|
||||||
QDRANT_COLLECTION: z.string().default("gmw_text_moderation"),
|
QDRANT_COLLECTION: z.string().default("gmw_text_moderation"),
|
||||||
|
QDRANT_ARCHIVE_COLLECTION: z.string().default("gmw_message_archive"),
|
||||||
QDRANT_API_KEY: z.string().optional(),
|
QDRANT_API_KEY: z.string().optional(),
|
||||||
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(8),
|
AI_LLM_MAX_CONCURRENT: z.coerce.number().int().positive().default(8),
|
||||||
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
AI_LLM_IMAGE_MAX_DIMENSION: z.coerce
|
||||||
|
|||||||
@@ -662,6 +662,16 @@ export const pgModerationActionsTable = pgTable(
|
|||||||
error: pgText("error"),
|
error: pgText("error"),
|
||||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||||
executed_at: pgBigint("executed_at", { mode: "number" }),
|
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) => ({
|
(table) => ({
|
||||||
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
bigint as pgBigint,
|
bigint as pgBigint,
|
||||||
boolean as pgBoolean,
|
boolean as pgBoolean,
|
||||||
index as pgIndex,
|
index as pgIndex,
|
||||||
|
real as pgReal,
|
||||||
pgTable,
|
pgTable,
|
||||||
text as pgText,
|
text as pgText,
|
||||||
uuid as pgUuid,
|
uuid as pgUuid,
|
||||||
@@ -48,6 +49,16 @@ export const pgModerationActionsTable = pgTable(
|
|||||||
error: pgText("error"),
|
error: pgText("error"),
|
||||||
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
created_at: pgBigint("created_at", { mode: "number" }).notNull(),
|
||||||
executed_at: pgBigint("executed_at", { mode: "number" }),
|
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) => ({
|
(table) => ({
|
||||||
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
useMessagesHasMore,
|
useMessagesHasMore,
|
||||||
useMessagesStream,
|
useMessagesStream,
|
||||||
useMessagesWsSync,
|
useMessagesWsSync,
|
||||||
|
useSemanticSearch,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import {
|
import {
|
||||||
@@ -67,6 +68,9 @@ export function MessagesView({
|
|||||||
const [channelId, setChannelId] = useState<string | null>(null);
|
const [channelId, setChannelId] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
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
|
// 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).
|
// older pages we append. Each page is 50 messages (backend limit default).
|
||||||
const MAX_OLDER_PAGES = 10;
|
const MAX_OLDER_PAGES = 10;
|
||||||
@@ -94,7 +98,14 @@ export function MessagesView({
|
|||||||
const hasMore = pageInfo?.hasMore ?? false;
|
const hasMore = pageInfo?.hasMore ?? false;
|
||||||
const loadMore = useLoadMore();
|
const loadMore = useLoadMore();
|
||||||
useMessagesWsSync(ws, guildId ?? "");
|
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 detail = useMessageDetail(selected);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
|
|
||||||
@@ -122,7 +133,8 @@ export function MessagesView({
|
|||||||
ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages");
|
ambient.set(query ? "amber" : "signal", 0.3, query ? "search" : "messages");
|
||||||
}, [query, ambient]);
|
}, [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 ?? []);
|
const list = searching ? (search.data ?? []) : (messages ?? []);
|
||||||
// Discord-style order: oldest at the top, newest at the bottom. The backend
|
// 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.
|
// 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)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSemanticMode((v) => !v)}
|
||||||
|
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||||
|
semanticMode
|
||||||
|
? "border-signal/40 bg-signal/10 text-signal"
|
||||||
|
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||||
|
}`}
|
||||||
|
title="Toggle semantic (vector) search over the message archive"
|
||||||
|
>
|
||||||
|
{semanticMode ? "Semantic" : "Exact"}
|
||||||
|
</button>
|
||||||
</GlassPanel>
|
</GlassPanel>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-5">
|
<div className="grid gap-4 lg:grid-cols-5">
|
||||||
|
{semanticSearching && (
|
||||||
|
<GlassPanel className="lg:col-span-5">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="semantic"
|
||||||
|
title={`“${query}”`}
|
||||||
|
action={
|
||||||
|
<span className="mono text-xs text-ink-faint">
|
||||||
|
{semantic.data?.length ?? 0} matches
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{semantic.isLoading ? (
|
||||||
|
<SkeletonRows rows={4} />
|
||||||
|
) : semantic.data && semantic.data.length > 0 ? (
|
||||||
|
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||||
|
{semantic.data.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={r.message_id ?? i}
|
||||||
|
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||||
|
style={staggerDelay(i)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="mono text-[0.6rem] text-signal">
|
||||||
|
{(r.score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
|
{formatRelativeTime(r.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 line-clamp-3 text-sm text-ink-soft">
|
||||||
|
{r.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Search className="size-7" />}
|
||||||
|
title="No semantic matches"
|
||||||
|
description="Try different wording — semantic search finds meaning, not exact text."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
<GlassPanel className="lg:col-span-3">
|
<GlassPanel className="lg:col-span-3">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
eyebrow={searching ? "results" : "live feed"}
|
eyebrow={searching ? "results" : "live feed"}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
SkeletonRows,
|
SkeletonRows,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { useModerationActions, useModerationStats } from "@/hooks";
|
import { useModerationActions, useModerationStats } from "@/hooks";
|
||||||
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
||||||
import type {
|
import type {
|
||||||
ModerationAction,
|
ModerationAction,
|
||||||
@@ -243,6 +244,18 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
const icon = ACTION_ICON[a.action_type] ?? (
|
const icon = ACTION_ICON[a.action_type] ?? (
|
||||||
<AlertTriangle className="size-3.5" />
|
<AlertTriangle className="size-3.5" />
|
||||||
);
|
);
|
||||||
|
// Map moderation severity → design-system tone (reuse aiTone with a
|
||||||
|
// severity→status projection so "none" reads as clean/signal).
|
||||||
|
const severityTone =
|
||||||
|
a.severity == null
|
||||||
|
? null
|
||||||
|
: aiTone(
|
||||||
|
a.severity === "none"
|
||||||
|
? "clean"
|
||||||
|
: a.severity === "low" || a.severity === "medium"
|
||||||
|
? "warn"
|
||||||
|
: "flagged",
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||||
@@ -266,6 +279,30 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
{a.reason && (
|
{a.reason && (
|
||||||
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
||||||
)}
|
)}
|
||||||
|
{severityTone && a.severity && (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
|
<Badge tone={severityTone}>{a.severity}</Badge>
|
||||||
|
{a.confidence != null && (
|
||||||
|
<span className="mono text-[0.6rem] text-ink-faint">
|
||||||
|
conf {(a.confidence * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{a.flags?.length ? (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{a.flags.slice(0, 6).map((f) => (
|
||||||
|
<Badge key={f} tone="amber">
|
||||||
|
{f}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{a.evidence?.length ? (
|
||||||
|
<div className="mt-1 border-l-2 border-hairline pl-2 text-xs text-ink-faint">
|
||||||
|
“{a.evidence[0]}”
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{a.executed_by && (
|
{a.executed_by && (
|
||||||
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
||||||
by {a.executed_by}
|
by {a.executed_by}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export {
|
|||||||
useMessagesStream,
|
useMessagesStream,
|
||||||
useMessagesWsSync,
|
useMessagesWsSync,
|
||||||
useReview,
|
useReview,
|
||||||
|
useSemanticSearch,
|
||||||
useTextChannels,
|
useTextChannels,
|
||||||
} from "./use-messages";
|
} from "./use-messages";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import { useEffect, useState } from "react";
|
|||||||
import useSWR, { useSWRConfig } from "swr";
|
import useSWR, { useSWRConfig } from "swr";
|
||||||
import { useAction } from "@/hooks/use-action";
|
import { useAction } from "@/hooks/use-action";
|
||||||
import { messagesApi, voiceApi } from "@/lib/api";
|
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";
|
import type { WsHook } from "@/lib/ws-hook";
|
||||||
|
|
||||||
// ── Query keys factory ───────────────────────────
|
// ── Query keys factory ───────────────────────────
|
||||||
@@ -177,6 +182,21 @@ export function useMessageSearch(query: string, enabled: boolean) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Semantic Search (public archive, Qdrant) ──────
|
||||||
|
|
||||||
|
export function useSemanticSearch(query: string, enabled: boolean) {
|
||||||
|
return useSWR<SemanticSearchResult[]>(
|
||||||
|
enabled && query.trim().length >= 2
|
||||||
|
? ["semantic-search", query.trim()]
|
||||||
|
: null,
|
||||||
|
async () => {
|
||||||
|
const res = await messagesApi.semanticSearch(query.trim(), 10);
|
||||||
|
return res.results;
|
||||||
|
},
|
||||||
|
{ keepPreviousData: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── WS sync helpers ──────────────────────────────
|
// ── WS sync helpers ──────────────────────────────
|
||||||
|
|
||||||
export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
export function useMessagesWsSync(ws: WsHook, guildId: string) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { orpc } from "@/lib/orpc/client";
|
import { orpc } from "@/lib/orpc/client";
|
||||||
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
|
import type {
|
||||||
|
AttachmentRecord,
|
||||||
|
MessageRecord,
|
||||||
|
SemanticSearchResult,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
export const messagesApi = {
|
export const messagesApi = {
|
||||||
list: (
|
list: (
|
||||||
@@ -62,4 +66,11 @@ export const messagesApi = {
|
|||||||
orpc.analysis.search({ q, limit }) as unknown as Promise<{
|
orpc.analysis.search({ q, limit }) as unknown as Promise<{
|
||||||
results: MessageRecord[];
|
results: MessageRecord[];
|
||||||
}>,
|
}>,
|
||||||
|
|
||||||
|
// Public semantic search over the persistent message archive (Qdrant).
|
||||||
|
semanticSearch: (query: string, limit?: number) =>
|
||||||
|
orpc.messages.semanticSearch({ query, limit }) as unknown as Promise<{
|
||||||
|
results: SemanticSearchResult[];
|
||||||
|
nextCursor: null;
|
||||||
|
}>,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -163,3 +163,17 @@ export interface AttachmentRecord {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
uploaded_at?: number | null;
|
uploaded_at?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Semantic Search (read-only public archive search) ──────────
|
||||||
|
|
||||||
|
export interface SemanticSearchResult {
|
||||||
|
message_id: string | null;
|
||||||
|
content: string;
|
||||||
|
score: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SemanticSearchResponse {
|
||||||
|
results: SemanticSearchResult[];
|
||||||
|
nextCursor: null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ export interface ModerationAction {
|
|||||||
executed_at: number | null;
|
executed_at: number | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
content: 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 {
|
export interface ModerationStats {
|
||||||
|
|||||||
Reference in New Issue
Block a user