perf(discord-gateway): start AI moderation analysis immediately, running attachment uploads in parallel instead of blocking
This commit is contained in:
@@ -239,6 +239,7 @@ function getAnalysisWorkerUrl(): URL {
|
||||
const workerPool = new Piscina({
|
||||
filename: fileURLToPath(getAnalysisWorkerUrl()),
|
||||
execArgv: process.execArgv,
|
||||
maxThreads: config.PISCINA_MAX_THREADS ?? availableParallelism(),
|
||||
});
|
||||
|
||||
interface AnalysisWorkerResponse {
|
||||
@@ -275,8 +276,8 @@ export function pickBatchWithinBudget(
|
||||
|
||||
for (const msg of messages) {
|
||||
const content = msg.edited_content ?? msg.content;
|
||||
// Rough token estimate: ~3 chars per token + metadata overhead
|
||||
const msgTokens = Math.ceil(content.length / 3) + tokensPerMessage;
|
||||
// Accurate token count via tiktoken (+ overhead for JSON structure)
|
||||
const msgTokens = estimateTokens(content) + tokensPerMessage;
|
||||
|
||||
if (usedTokens + msgTokens <= maxTokens) {
|
||||
batch.push(msg);
|
||||
|
||||
@@ -26,6 +26,55 @@ interface BadwordCacheEntry {
|
||||
const badwordCache = new Map<string, BadwordCacheEntry>();
|
||||
const inFlightBadwordLookups = new Map<string, Promise<string[]>>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local rule-based pre-filter — short-circuits definitively safe messages
|
||||
// without calling the LLM badword detection API.
|
||||
// Conservative: only flags messages that are 100% certainly clean.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAFE_PATTERNS: Array<{ test: (text: string) => boolean; reason: string }> = [
|
||||
{
|
||||
// Pure laughter patterns
|
||||
test: (t) => /^(wkwk+|w+kw+k+|wkwkw+|haha+|hehe+|hihi+|huhu+|xixi+|wakak+|awkwa+)$/i.test(t),
|
||||
reason: "laughter pattern",
|
||||
},
|
||||
{
|
||||
// Single-word affirmatives (common in Indonesian Discord)
|
||||
test: (t) => /^(ok|oke|okay|sip|siap|aman|mantap|gas|gass|gaskeun|santuy|gaskan|lah|wih|wah|eh|nah|loh|hmm|hm|heh)$/i.test(t),
|
||||
reason: "single-word affirmative",
|
||||
},
|
||||
{
|
||||
// Greetings / common short expressions
|
||||
test: (t) => /^(hai|halo|hello|hi|oi|woy|woi|pagi|siang|sore|malam|mlm|p|w|L|F|gws|thx|thks|makasih|ty|thanks|yw|sama-sama|ok sip|ok bang|siap bang)$/i.test(t),
|
||||
reason: "greeting/common expression",
|
||||
},
|
||||
{
|
||||
// Very short messages (1-2 characters) — reactions, single letters
|
||||
test: (t) => t.length <= 2,
|
||||
reason: "very short message (1-2 chars)",
|
||||
},
|
||||
{
|
||||
// Pure numeric or pure punctuation
|
||||
test: (t) => /^[\d\s.,!?;:'"()\-_]+$/.test(t),
|
||||
reason: "numeric/punctuation only",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks whether a text message is definitively safe and does not need
|
||||
* LLM-based badword detection. This is a conservative local pre-filter
|
||||
* — it only returns true for patterns that CANNOT be violations.
|
||||
*/
|
||||
export function isDefinitivelySafe(text: string): boolean {
|
||||
// Normalize: strip Discord custom emoji, trim whitespace
|
||||
const { text: normalized } = normalizeDiscordCustomEmoji(text);
|
||||
const trimmed = normalized.trim();
|
||||
|
||||
if (trimmed.length === 0) return true;
|
||||
|
||||
return SAFE_PATTERNS.some((p) => p.test(trimmed));
|
||||
}
|
||||
|
||||
export interface ModerationTextEvidence {
|
||||
raw: string;
|
||||
normalized: string;
|
||||
@@ -123,6 +172,11 @@ export async function detectIndonesianBadwords(
|
||||
return cached;
|
||||
}
|
||||
|
||||
// ── Tier 0: Local rule-based pre-filter (fastest — no API call) ──
|
||||
if (isDefinitivelySafe(text)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// De-duplicate concurrent lookups
|
||||
const inFlight = inFlightBadwordLookups.get(cacheKey);
|
||||
if (inFlight) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createChildLogger } from "@bete/shared/logger";
|
||||
import { retryWithBackoff } from "@bete/shared/utils";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import type { ChatCompletion } from "openai/resources/chat/completions";
|
||||
import { AbortError } from "p-retry";
|
||||
import { z } from "zod";
|
||||
@@ -28,11 +29,14 @@ import {
|
||||
buildStickerVisionPrompt,
|
||||
} from "./stickerPrompt.js";
|
||||
import {
|
||||
computeImagePhash,
|
||||
getCachedMediaAnalysis,
|
||||
getCachedMediaByPhash,
|
||||
makeCustomEmojiCacheKey,
|
||||
makeImageCacheKey,
|
||||
makeStickerCacheKey,
|
||||
upsertCachedMediaAnalysis,
|
||||
upsertCachedMediaByPhash,
|
||||
} from "./textCacheStore.js";
|
||||
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
||||
|
||||
|
||||
@@ -184,9 +184,15 @@ CRITICAL:
|
||||
// Composer: assembles all sections with XML delimiters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Prompt mode — determines which few-shot example section is included. */
|
||||
export type PromptMode = "text" | "media" | "mixed";
|
||||
|
||||
export interface BuildSystemPromptOptions {
|
||||
contextText: string;
|
||||
includeMediaInstructions: boolean;
|
||||
/** Prompt mode — determines which sections are included. */
|
||||
mode: PromptMode;
|
||||
/** @deprecated Use `mode` instead. */
|
||||
includeMediaInstructions?: boolean;
|
||||
correction?: { error: string; preview: string };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user