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({
|
const workerPool = new Piscina({
|
||||||
filename: fileURLToPath(getAnalysisWorkerUrl()),
|
filename: fileURLToPath(getAnalysisWorkerUrl()),
|
||||||
execArgv: process.execArgv,
|
execArgv: process.execArgv,
|
||||||
|
maxThreads: config.PISCINA_MAX_THREADS ?? availableParallelism(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface AnalysisWorkerResponse {
|
interface AnalysisWorkerResponse {
|
||||||
@@ -275,8 +276,8 @@ export function pickBatchWithinBudget(
|
|||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const content = msg.edited_content ?? msg.content;
|
const content = msg.edited_content ?? msg.content;
|
||||||
// Rough token estimate: ~3 chars per token + metadata overhead
|
// Accurate token count via tiktoken (+ overhead for JSON structure)
|
||||||
const msgTokens = Math.ceil(content.length / 3) + tokensPerMessage;
|
const msgTokens = estimateTokens(content) + tokensPerMessage;
|
||||||
|
|
||||||
if (usedTokens + msgTokens <= maxTokens) {
|
if (usedTokens + msgTokens <= maxTokens) {
|
||||||
batch.push(msg);
|
batch.push(msg);
|
||||||
|
|||||||
@@ -26,6 +26,55 @@ interface BadwordCacheEntry {
|
|||||||
const badwordCache = new Map<string, BadwordCacheEntry>();
|
const badwordCache = new Map<string, BadwordCacheEntry>();
|
||||||
const inFlightBadwordLookups = new Map<string, Promise<string[]>>();
|
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 {
|
export interface ModerationTextEvidence {
|
||||||
raw: string;
|
raw: string;
|
||||||
normalized: string;
|
normalized: string;
|
||||||
@@ -123,6 +172,11 @@ export async function detectIndonesianBadwords(
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tier 0: Local rule-based pre-filter (fastest — no API call) ──
|
||||||
|
if (isDefinitivelySafe(text)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// De-duplicate concurrent lookups
|
// De-duplicate concurrent lookups
|
||||||
const inFlight = inFlightBadwordLookups.get(cacheKey);
|
const inFlight = inFlightBadwordLookups.get(cacheKey);
|
||||||
if (inFlight) {
|
if (inFlight) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import { retryWithBackoff } from "@bete/shared/utils";
|
import { retryWithBackoff } from "@bete/shared/utils";
|
||||||
|
import { LRUCache } from "lru-cache";
|
||||||
import type { ChatCompletion } from "openai/resources/chat/completions";
|
import type { ChatCompletion } from "openai/resources/chat/completions";
|
||||||
import { AbortError } from "p-retry";
|
import { AbortError } from "p-retry";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -28,11 +29,14 @@ import {
|
|||||||
buildStickerVisionPrompt,
|
buildStickerVisionPrompt,
|
||||||
} from "./stickerPrompt.js";
|
} from "./stickerPrompt.js";
|
||||||
import {
|
import {
|
||||||
|
computeImagePhash,
|
||||||
getCachedMediaAnalysis,
|
getCachedMediaAnalysis,
|
||||||
|
getCachedMediaByPhash,
|
||||||
makeCustomEmojiCacheKey,
|
makeCustomEmojiCacheKey,
|
||||||
makeImageCacheKey,
|
makeImageCacheKey,
|
||||||
makeStickerCacheKey,
|
makeStickerCacheKey,
|
||||||
upsertCachedMediaAnalysis,
|
upsertCachedMediaAnalysis,
|
||||||
|
upsertCachedMediaByPhash,
|
||||||
} from "./textCacheStore.js";
|
} from "./textCacheStore.js";
|
||||||
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
||||||
|
|
||||||
|
|||||||
@@ -184,9 +184,15 @@ CRITICAL:
|
|||||||
// Composer: assembles all sections with XML delimiters
|
// 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 {
|
export interface BuildSystemPromptOptions {
|
||||||
contextText: string;
|
contextText: string;
|
||||||
includeMediaInstructions: boolean;
|
/** Prompt mode — determines which sections are included. */
|
||||||
|
mode: PromptMode;
|
||||||
|
/** @deprecated Use `mode` instead. */
|
||||||
|
includeMediaInstructions?: boolean;
|
||||||
correction?: { error: string; preview: string };
|
correction?: { error: string; preview: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user