From 292fcbf238af7943d1b28eda293456b1bd7721c8 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 2 Jun 2026 17:55:42 +0700 Subject: [PATCH] chore: session auto-commit --- .../ai-moderation/indonesianTextNormalizer.ts | 147 +--------- .../src/modules/ai-moderation/llmClient.ts | 276 ++++++++++++++++++ .../ai-moderation/llmModerationClient.ts | 71 ++--- src/moderation/indonesianTextNormalizer.ts | 147 +--------- src/moderation/llmClient.ts | 276 ++++++++++++++++++ src/moderation/llmModerationClient.ts | 2 +- 6 files changed, 580 insertions(+), 339 deletions(-) create mode 100644 services/discord-gateway/src/modules/ai-moderation/llmClient.ts create mode 100644 src/moderation/llmClient.ts diff --git a/services/discord-gateway/src/modules/ai-moderation/indonesianTextNormalizer.ts b/services/discord-gateway/src/modules/ai-moderation/indonesianTextNormalizer.ts index e22eb86..b7e4baf 100644 --- a/services/discord-gateway/src/modules/ai-moderation/indonesianTextNormalizer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/indonesianTextNormalizer.ts @@ -1,37 +1,11 @@ -import OpenAI from "openai"; -import { config } from "../../shared/config/config.js"; import { createChildLogger } from "../../shared/logger/logger.js"; import { getCachedText, upsertCachedText } from "./textCacheStore.js"; +import { llmDetectBadwords } from "./llmClient.js"; const log = createChildLogger("indonesianTextNormalizer"); const CUSTOM_EMOJI_PATTERN = //g; -const VALID_PRIMARY_AI_FLAGS = new Set([ - "spam", - "hate_speech", - "sara", - "hoaks", - "harassment", - "vulgar_language", - "sexual_content", - "sexual_deviation", - "violence", - "self_harm", - "doxxing", - "scam", - "misinformation", - "nsfw_image", - "gore_image", - "illegal_content", - "gambling", - "drugs", - "child_safety", - "financial_scam", - "religious_insult", - "self_promo", -]); - /** * In-memory cache TTL (10 min) — fastest path for repeated identical texts. */ @@ -51,7 +25,6 @@ interface BadwordCacheEntry { const badwordCache = new Map(); const inFlightBadwordLookups = new Map>(); -let primaryModerationClient: OpenAI | null = null; export interface ModerationTextEvidence { raw: string; @@ -121,117 +94,6 @@ function setCachedBadwords(key: string, value: string[]): void { } } -function getPrimaryModerationClient(): OpenAI | null { - if (!config.AI_LLM_API_KEY) { - return null; - } - - if (!primaryModerationClient) { - primaryModerationClient = new OpenAI({ - apiKey: config.AI_LLM_API_KEY, - baseURL: config.AI_LLM_BASE_URL, - maxRetries: 0, - timeout: 15000, - }); - } - - return primaryModerationClient; -} - -function normalizePrimaryAiFlag(value: string): string | null { - const lower = value - .trim() - .toLowerCase() - .replace(/[\s-]+/g, "_"); - if (!lower) return null; - - if (VALID_PRIMARY_AI_FLAGS.has(lower)) { - return lower; - } - - return null; -} - -function extractFlagsFromPrimaryAiContent(content: string): string[] { - const flags = new Set(); - let parsed: unknown; - - try { - parsed = JSON.parse(content); - } catch { - parsed = null; - } - - const addValue = (value: unknown) => { - if (typeof value !== "string") return; - const normalized = normalizePrimaryAiFlag(value); - if (normalized) flags.add(normalized); - }; - - if (Array.isArray(parsed)) { - for (const item of parsed) { - addValue(item); - } - } else if (parsed && typeof parsed === "object") { - const candidate = parsed as Record; - for (const key of ["flags", "categories", "badwords"]) { - const value = candidate[key]; - if (Array.isArray(value)) { - for (const item of value) addValue(item); - } else { - addValue(value); - } - } - } - - if (flags.size > 0) { - return Array.from(flags); - } - - const lowerContent = content.toLowerCase(); - for (const flag of VALID_PRIMARY_AI_FLAGS) { - if (lowerContent.includes(flag)) { - flags.add(flag); - } - } - - return Array.from(flags); -} - -async function callPrimaryAiModeration(text: string): Promise { - const client = getPrimaryModerationClient(); - if (!client) { - return []; - } - - const completion = await client.chat.completions.create({ - model: config.AI_LLM_MODEL, - messages: [ - { - role: "user", - content: - "Deteksi kata kasar / pelanggaran ringan dari teks Indonesia berikut. " + - 'Balas hanya JSON object dengan format {"flags":[...]} dan gunakan hanya flag valid ini: ' + - Array.from(VALID_PRIMARY_AI_FLAGS).join(", ") + - ". Jika tidak ada pelanggaran, flags harus array kosong. Teks: " + - text, - }, - ], - temperature: 0.1, - top_p: 0.9, - max_tokens: 200, - stream: false, - response_format: { type: "json_object" }, - } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming); - - const content = completion.choices[0]?.message?.content?.trim(); - if (!content) { - return []; - } - - return extractFlagsFromPrimaryAiContent(content); -} - // --------------------------------------------------------------------------- // Two-tier cache + Primary AI pipeline // --------------------------------------------------------------------------- @@ -244,7 +106,8 @@ async function callPrimaryAiModeration(text: string): Promise { * 2. **DB cache** (DB_CACHE_TTL_MS, 24 h) — same full-text key, persisted * across restarts. Uses the FULL normalized text (not per-word) because * context matters: "kau" alone is clean, but "awas kau" can be a threat. - * 3. **Primary AI** (AI_LLM endpoint) — only runs when both cache layers miss. + * 3. **Primary AI** (AI_LLM endpoint via llmClient) — only runs when both + * cache layers miss. * * No local hardcoded badword list — all detection goes through AI APIs * to eliminate false positives from substring matching. @@ -275,10 +138,10 @@ export async function detectIndonesianBadwords( return flags; } - // ── Tier 3: Primary AI only ── + // ── Tier 3: Primary AI only (via centralized llmClient) ── let finalHits: string[] = []; try { - finalHits = await callPrimaryAiModeration(text); + finalHits = await llmDetectBadwords(text); } catch (error) { log.warn( { error: error instanceof Error ? error.message : String(error) }, diff --git a/services/discord-gateway/src/modules/ai-moderation/llmClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmClient.ts new file mode 100644 index 0000000..d3573ee --- /dev/null +++ b/services/discord-gateway/src/modules/ai-moderation/llmClient.ts @@ -0,0 +1,276 @@ +/** + * Centralised LLM chat completion helper. + * + * All `openai.chat.completions.create` calls in the moderation subsystem + * go through this module so that model, concurrency, retry, and token + * defaults are maintained in one place. + */ + +import OpenAI from "openai"; +import { config } from "../../shared/config/config.js"; +import { retryWithBackoff } from "../../shared/utils/retry.js"; +import { withLlmConcurrency } from "./concurrencyLimiter.js"; +import { createChildLogger } from "../../shared/logger/logger.js"; + +const log = createChildLogger("llm-client"); + +// --------------------------------------------------------------------------- +// Lazy singleton — created on first use so that config is always resolved. +// --------------------------------------------------------------------------- + +let openaiClient: OpenAI | null = null; + +function getClient(): OpenAI | null { + if (!config.AI_LLM_API_KEY) return null; + if (!openaiClient) { + openaiClient = new OpenAI({ + apiKey: config.AI_LLM_API_KEY, + baseURL: config.AI_LLM_BASE_URL, + maxRetries: 0, + timeout: 15_000, + }); + } + return openaiClient; +} + +// --------------------------------------------------------------------------- +// Shared defaults +// --------------------------------------------------------------------------- + +const DEFAULT_TEMPERATURE = 0.2; +const DEFAULT_TOP_P = 0.95; +const DEFAULT_RETRIES = 2; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface LlmCallOpts { + /** Conversation to send. Either a string (→ single user message) or an array of messages. */ + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[]; + /** Which model to use (defaults to config.AI_LLM_MODEL). */ + model?: string; + /** Max output tokens (defaults to 8192). */ + max_tokens?: number; + /** Temperature (defaults to 0.2). */ + temperature?: number; + /** Top-p (defaults to 0.95). */ + top_p?: number; + /** Force JSON output. When true, wraps schema in json_schema response_format. */ + jsonResponse?: + | { type: "json_object" } + | { + type: "json_schema"; + name: string; + schema: Record; + strict: boolean; + }; + /** Extra retries beyond DEFAULT_RETRIES (default 2). */ + retries?: number; +} + +/** + * Call the LLM with sensible defaults: concurrency cap, retry, model, tokens. + * + * Returns the raw OpenAI ChatCompletion so callers can inspect + * `choices[0].message.content`, `finish_reason`, `usage`, etc. + */ +export async function llmChat( + opts: LlmCallOpts, +): Promise { + const client = getClient(); + if (!client) return null; + + const { + messages, + model = config.AI_LLM_MODEL, + max_tokens = 8192, + temperature = DEFAULT_TEMPERATURE, + top_p = DEFAULT_TOP_P, + jsonResponse, + retries = DEFAULT_RETRIES, + } = opts; + + const responseFormat: + | { type: "json_object" } + | { + type: "json_schema"; + json_schema: { + name: string; + schema: Record; + strict: boolean; + }; + } + | undefined = jsonResponse + ? jsonResponse.type === "json_schema" + ? { + type: "json_schema", + json_schema: { + name: jsonResponse.name, + schema: jsonResponse.schema, + strict: jsonResponse.strict, + }, + } + : jsonResponse + : undefined; + + const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = + { + model, + messages, + temperature, + top_p, + max_tokens, + stream: false, + ...(responseFormat ? { response_format: responseFormat } : {}), + } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming; + + return retryWithBackoff( + async () => { + return withLlmConcurrency(async () => + client.chat.completions.create(params), + ); + }, + { + retries, + minTimeout: 0, + maxTimeout: 0, + factor: 2, + logger: log, + }, + ); +} + +/** + * Convenience for the legacy text-only badword detection call in + * `indonesianTextNormalizer`. Returns parsed flags or []. + */ +export async function llmDetectBadwords(text: string): Promise { + const completion = await llmChat({ + messages: [ + { + role: "user", + content: + "Deteksi kata kasar / pelanggaran ringan dari teks Indonesia berikut. " + + 'Balas hanya JSON object dengan format {"flags":[...]} dan gunakan hanya flag valid ini: ' + + Array.from(VALID_PRIMARY_AI_FLAGS).join(", ") + + ". Jika tidak ada pelanggaran, flags harus array kosong. Teks: " + + text, + }, + ], + max_tokens: 200, + temperature: 0.1, + top_p: 0.9, + jsonResponse: { type: "json_object" }, + retries: 2, + }); + + if (!completion) return []; + const content = completion.choices[0]?.message?.content?.trim(); + if (!content) return []; + return extractFlagsFromContent(content); +} + +/** + * Convenience for vision (image/sticker/emoji) analysis. + * Returns the raw completion content (trimmed) or null. + */ +export async function llmVision( + promptText: string, + imageUrl: { url: string }, +): Promise { + const completion = await llmChat({ + messages: [ + { + role: "user", + content: [ + { type: "text" as const, text: promptText }, + { type: "image_url" as const, image_url: imageUrl }, + ], + }, + ], + model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL, + max_tokens: 500, + temperature: 0.1, + top_p: 0.9, + retries: 2, + }); + + if (!completion) return null; + return completion.choices[0]?.message?.content?.trim() ?? null; +} + +// --------------------------------------------------------------------------- +// Flag extraction (reused from indonesianTextNormalizer) +// --------------------------------------------------------------------------- + +const VALID_PRIMARY_AI_FLAGS = new Set([ + "spam", + "hate_speech", + "sara", + "hoaks", + "harassment", + "vulgar_language", + "sexual_content", + "sexual_deviation", + "violence", + "self_harm", + "doxxing", + "scam", + "misinformation", + "nsfw_image", + "gore_image", + "illegal_content", + "gambling", + "drugs", + "child_safety", + "financial_scam", + "religious_insult", + "self_promo", +]); + +function normalizeFlag(value: string): string | null { + const lower = value.trim().toLowerCase().replace(/[\s-]+/g, "_"); + if (!lower) return null; + if (VALID_PRIMARY_AI_FLAGS.has(lower)) return lower; + return null; +} + +function extractFlagsFromContent(content: string): string[] { + const flags = new Set(); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + parsed = null; + } + + const addValue = (v: unknown) => { + if (typeof v !== "string") return; + const n = normalizeFlag(v); + if (n) flags.add(n); + }; + + if (Array.isArray(parsed)) { + for (const item of parsed) addValue(item); + } else if (parsed && typeof parsed === "object") { + const obj = parsed as Record; + for (const key of ["flags", "categories", "badwords"]) { + const val = obj[key]; + if (Array.isArray(val)) { + for (const item of val) addValue(item); + } else { + addValue(val); + } + } + } + + if (flags.size > 0) return Array.from(flags); + + const lower = content.toLowerCase(); + for (const flag of VALID_PRIMARY_AI_FLAGS) { + if (lower.includes(flag)) flags.add(flag); + } + + return Array.from(flags); +} diff --git a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts index 478ccb0..7b64859 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts @@ -11,8 +11,8 @@ import type { AttachmentRecord, MessageRecord, } from "../message-capture/types.js"; -import { withLlmConcurrency } from "./concurrencyLimiter.js"; import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js"; +import { llmChat, llmVision } from "./llmClient.js"; import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js"; import { logModerationAnalysis, logModerationError } from "./responseLogger.js"; import { @@ -592,39 +592,7 @@ const analyzeSingleMediaImage = async ( : buildGeneralImageVisionPrompt(image.sourceLabel, messageId); try { - const completion = await retryWithBackoff( - async () => { - return withLlmConcurrency(async () => - openai.chat.completions.create({ - model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL, - messages: [ - { - role: "user", - content: [ - { - type: "text", - text: promptText, - }, - { type: "image_url", image_url: image.image_url }, - ], - }, - ], - temperature: 0.1, - top_p: 0.9, - max_tokens: 500, - stream: false, - } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming), - ); - }, - { - retries: 2, - minTimeout: 0, - maxTimeout: 0, - logger: log, - }, - ); - - const content = completion.choices[0]?.message?.content?.trim(); + const content = await llmVision(promptText, image.image_url); if (!content) return null; await upsertCachedMediaAnalysis( @@ -693,26 +661,21 @@ async function callModerationLLM( try { const content = await buildContent(state); - const completion = await withLlmConcurrency(async () => - openai.chat.completions.create({ - model: config.AI_LLM_MODEL, - messages: [{ role: "user", content }], - temperature: 0.2, - top_p: 0.95, - // Sufficient for 20 moderation results (each ~70-150 tokens). - // Previous 4096 caused LLM truncation after ~6 results. - max_tokens: 16384, - response_format: { - type: "json_schema", - json_schema: { - name: "moderation_result", - schema: MODERATION_JSON_SCHEMA, - strict: true, - }, - }, - stream: false, - } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming), - ); + const completion = await llmChat({ + messages: [{ role: "user", content }], + max_tokens: 16384, + jsonResponse: { + type: "json_schema", + name: "moderation_result", + schema: MODERATION_JSON_SCHEMA, + strict: true, + }, + retries: 0, + }); + + if (!completion) { + throw new Error("LLM client unavailable (no API key)"); + } if ( !completion.choices || diff --git a/src/moderation/indonesianTextNormalizer.ts b/src/moderation/indonesianTextNormalizer.ts index 47a40a8..d24498c 100644 --- a/src/moderation/indonesianTextNormalizer.ts +++ b/src/moderation/indonesianTextNormalizer.ts @@ -1,37 +1,11 @@ -import OpenAI from "openai"; -import { config } from "../config.js"; import { createChildLogger } from "../logger.js"; import { getCachedText, upsertCachedText } from "./textCacheStore.js"; +import { llmDetectBadwords } from "./llmClient.js"; const log = createChildLogger("indonesianTextNormalizer"); const CUSTOM_EMOJI_PATTERN = //g; -const VALID_PRIMARY_AI_FLAGS = new Set([ - "spam", - "hate_speech", - "sara", - "hoaks", - "harassment", - "vulgar_language", - "sexual_content", - "sexual_deviation", - "violence", - "self_harm", - "doxxing", - "scam", - "misinformation", - "nsfw_image", - "gore_image", - "illegal_content", - "gambling", - "drugs", - "child_safety", - "financial_scam", - "religious_insult", - "self_promo", -]); - /** * In-memory cache TTL (10 min) — fastest path for repeated identical texts. */ @@ -51,7 +25,6 @@ interface BadwordCacheEntry { const badwordCache = new Map(); const inFlightBadwordLookups = new Map>(); -let primaryModerationClient: OpenAI | null = null; export interface ModerationTextEvidence { raw: string; @@ -121,117 +94,6 @@ function setCachedBadwords(key: string, value: string[]): void { } } -function getPrimaryModerationClient(): OpenAI | null { - if (!config.AI_LLM_API_KEY) { - return null; - } - - if (!primaryModerationClient) { - primaryModerationClient = new OpenAI({ - apiKey: config.AI_LLM_API_KEY, - baseURL: config.AI_LLM_BASE_URL, - maxRetries: 0, - timeout: 15000, - }); - } - - return primaryModerationClient; -} - -function normalizePrimaryAiFlag(value: string): string | null { - const lower = value - .trim() - .toLowerCase() - .replace(/[\s-]+/g, "_"); - if (!lower) return null; - - if (VALID_PRIMARY_AI_FLAGS.has(lower)) { - return lower; - } - - return null; -} - -function extractFlagsFromPrimaryAiContent(content: string): string[] { - const flags = new Set(); - let parsed: unknown; - - try { - parsed = JSON.parse(content); - } catch { - parsed = null; - } - - const addValue = (value: unknown) => { - if (typeof value !== "string") return; - const normalized = normalizePrimaryAiFlag(value); - if (normalized) flags.add(normalized); - }; - - if (Array.isArray(parsed)) { - for (const item of parsed) { - addValue(item); - } - } else if (parsed && typeof parsed === "object") { - const candidate = parsed as Record; - for (const key of ["flags", "categories", "badwords"]) { - const value = candidate[key]; - if (Array.isArray(value)) { - for (const item of value) addValue(item); - } else { - addValue(value); - } - } - } - - if (flags.size > 0) { - return Array.from(flags); - } - - const lowerContent = content.toLowerCase(); - for (const flag of VALID_PRIMARY_AI_FLAGS) { - if (lowerContent.includes(flag)) { - flags.add(flag); - } - } - - return Array.from(flags); -} - -async function callPrimaryAiModeration(text: string): Promise { - const client = getPrimaryModerationClient(); - if (!client) { - return []; - } - - const completion = await client.chat.completions.create({ - model: config.AI_LLM_MODEL, - messages: [ - { - role: "user", - content: - "Deteksi kata kasar / pelanggaran ringan dari teks Indonesia berikut. " + - 'Balas hanya JSON object dengan format {"flags":[...]} dan gunakan hanya flag valid ini: ' + - Array.from(VALID_PRIMARY_AI_FLAGS).join(", ") + - ". Jika tidak ada pelanggaran, flags harus array kosong. Teks: " + - text, - }, - ], - temperature: 0.1, - top_p: 0.9, - max_tokens: 200, - stream: false, - response_format: { type: "json_object" }, - } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming); - - const content = completion.choices[0]?.message?.content?.trim(); - if (!content) { - return []; - } - - return extractFlagsFromPrimaryAiContent(content); -} - // --------------------------------------------------------------------------- // Two-tier cache + Primary AI pipeline // --------------------------------------------------------------------------- @@ -244,7 +106,8 @@ async function callPrimaryAiModeration(text: string): Promise { * 2. **DB cache** (DB_CACHE_TTL_MS, 24 h) — same full-text key, persisted * across restarts. Uses the FULL normalized text (not per-word) because * context matters: "kau" alone is clean, but "awas kau" can be a threat. - * 3. **Primary AI** (AI_LLM endpoint) — only runs when both cache layers miss. + * 3. **Primary AI** (AI_LLM endpoint via llmClient) — only runs when both + * cache layers miss. * * No local hardcoded badword list — all detection goes through AI APIs * to eliminate false positives from substring matching. @@ -275,10 +138,10 @@ export async function detectIndonesianBadwords( return flags; } - // ── Tier 3: Primary AI only ── + // ── Tier 3: Primary AI only (via centralized llmClient) ── let finalHits: string[] = []; try { - finalHits = await callPrimaryAiModeration(text); + finalHits = await llmDetectBadwords(text); } catch (error) { log.warn( { error: error instanceof Error ? error.message : String(error) }, diff --git a/src/moderation/llmClient.ts b/src/moderation/llmClient.ts new file mode 100644 index 0000000..3b520d6 --- /dev/null +++ b/src/moderation/llmClient.ts @@ -0,0 +1,276 @@ +/** + * Centralised LLM chat completion helper. + * + * All `openai.chat.completions.create` calls in the moderation subsystem + * go through this module so that model, concurrency, retry, and token + * defaults are maintained in one place. + */ + +import OpenAI from "openai"; +import { config } from "../config.js"; +import { retryWithBackoff } from "../retry.js"; +import { withLlmConcurrency } from "./concurrencyLimiter.js"; +import { createChildLogger } from "../logger.js"; + +const log = createChildLogger("llm-client"); + +// --------------------------------------------------------------------------- +// Lazy singleton — created on first use so that config is always resolved. +// --------------------------------------------------------------------------- + +let openaiClient: OpenAI | null = null; + +function getClient(): OpenAI | null { + if (!config.AI_LLM_API_KEY) return null; + if (!openaiClient) { + openaiClient = new OpenAI({ + apiKey: config.AI_LLM_API_KEY, + baseURL: config.AI_LLM_BASE_URL, + maxRetries: 0, + timeout: 15_000, + }); + } + return openaiClient; +} + +// --------------------------------------------------------------------------- +// Shared defaults +// --------------------------------------------------------------------------- + +const DEFAULT_TEMPERATURE = 0.2; +const DEFAULT_TOP_P = 0.95; +const DEFAULT_RETRIES = 2; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface LlmCallOpts { + /** Conversation to send. Either a string (→ single user message) or an array of messages. */ + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[]; + /** Which model to use (defaults to config.AI_LLM_MODEL). */ + model?: string; + /** Max output tokens (defaults to 8192). */ + max_tokens?: number; + /** Temperature (defaults to 0.2). */ + temperature?: number; + /** Top-p (defaults to 0.95). */ + top_p?: number; + /** Force JSON output. When true, wraps schema in json_schema response_format. */ + jsonResponse?: + | { type: "json_object" } + | { + type: "json_schema"; + name: string; + schema: Record; + strict: boolean; + }; + /** Extra retries beyond DEFAULT_RETRIES (default 2). */ + retries?: number; +} + +/** + * Call the LLM with sensible defaults: concurrency cap, retry, model, tokens. + * + * Returns the raw OpenAI ChatCompletion so callers can inspect + * `choices[0].message.content`, `finish_reason`, `usage`, etc. + */ +export async function llmChat( + opts: LlmCallOpts, +): Promise { + const client = getClient(); + if (!client) return null; + + const { + messages, + model = config.AI_LLM_MODEL, + max_tokens = 8192, + temperature = DEFAULT_TEMPERATURE, + top_p = DEFAULT_TOP_P, + jsonResponse, + retries = DEFAULT_RETRIES, + } = opts; + + const responseFormat: + | { type: "json_object" } + | { + type: "json_schema"; + json_schema: { + name: string; + schema: Record; + strict: boolean; + }; + } + | undefined = jsonResponse + ? jsonResponse.type === "json_schema" + ? { + type: "json_schema", + json_schema: { + name: jsonResponse.name, + schema: jsonResponse.schema, + strict: jsonResponse.strict, + }, + } + : jsonResponse + : undefined; + + const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = + { + model, + messages, + temperature, + top_p, + max_tokens, + stream: false, + ...(responseFormat ? { response_format: responseFormat } : {}), + } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming; + + return retryWithBackoff( + async () => { + return withLlmConcurrency(async () => + client.chat.completions.create(params), + ); + }, + { + retries, + minTimeout: 0, + maxTimeout: 0, + factor: 2, + logger: log, + }, + ); +} + +/** + * Convenience for the legacy text-only badword detection call in + * `indonesianTextNormalizer`. Returns parsed flags or []. + */ +export async function llmDetectBadwords(text: string): Promise { + const completion = await llmChat({ + messages: [ + { + role: "user", + content: + "Deteksi kata kasar / pelanggaran ringan dari teks Indonesia berikut. " + + 'Balas hanya JSON object dengan format {"flags":[...]} dan gunakan hanya flag valid ini: ' + + Array.from(VALID_PRIMARY_AI_FLAGS).join(", ") + + ". Jika tidak ada pelanggaran, flags harus array kosong. Teks: " + + text, + }, + ], + max_tokens: 200, + temperature: 0.1, + top_p: 0.9, + jsonResponse: { type: "json_object" }, + retries: 2, + }); + + if (!completion) return []; + const content = completion.choices[0]?.message?.content?.trim(); + if (!content) return []; + return extractFlagsFromContent(content); +} + +/** + * Convenience for vision (image/sticker/emoji) analysis. + * Returns the raw completion content (trimmed) or null. + */ +export async function llmVision( + promptText: string, + imageUrl: { url: string }, +): Promise { + const completion = await llmChat({ + messages: [ + { + role: "user", + content: [ + { type: "text" as const, text: promptText }, + { type: "image_url" as const, image_url: imageUrl }, + ], + }, + ], + model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL, + max_tokens: 500, + temperature: 0.1, + top_p: 0.9, + retries: 2, + }); + + if (!completion) return null; + return completion.choices[0]?.message?.content?.trim() ?? null; +} + +// --------------------------------------------------------------------------- +// Flag extraction (reused from indonesianTextNormalizer) +// --------------------------------------------------------------------------- + +const VALID_PRIMARY_AI_FLAGS = new Set([ + "spam", + "hate_speech", + "sara", + "hoaks", + "harassment", + "vulgar_language", + "sexual_content", + "sexual_deviation", + "violence", + "self_harm", + "doxxing", + "scam", + "misinformation", + "nsfw_image", + "gore_image", + "illegal_content", + "gambling", + "drugs", + "child_safety", + "financial_scam", + "religious_insult", + "self_promo", +]); + +function normalizeFlag(value: string): string | null { + const lower = value.trim().toLowerCase().replace(/[\s-]+/g, "_"); + if (!lower) return null; + if (VALID_PRIMARY_AI_FLAGS.has(lower)) return lower; + return null; +} + +function extractFlagsFromContent(content: string): string[] { + const flags = new Set(); + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + parsed = null; + } + + const addValue = (v: unknown) => { + if (typeof v !== "string") return; + const n = normalizeFlag(v); + if (n) flags.add(n); + }; + + if (Array.isArray(parsed)) { + for (const item of parsed) addValue(item); + } else if (parsed && typeof parsed === "object") { + const obj = parsed as Record; + for (const key of ["flags", "categories", "badwords"]) { + const val = obj[key]; + if (Array.isArray(val)) { + for (const item of val) addValue(item); + } else { + addValue(val); + } + } + } + + if (flags.size > 0) return Array.from(flags); + + const lower = content.toLowerCase(); + for (const flag of VALID_PRIMARY_AI_FLAGS) { + if (lower.includes(flag)) flags.add(flag); + } + + return Array.from(flags); +} diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 061691e..7fb7660 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -4,9 +4,9 @@ import { z } from "zod"; import { config } from "../config.js"; import { createChildLogger } from "../logger.js"; import { retryWithBackoff } from "../retry.js"; -import { withLlmConcurrency } from "./concurrencyLimiter.js"; import { resizeImageForVision } from "./imageResizer.js"; import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js"; +import { llmChat, llmVision } from "./llmClient.js"; import { extractMessageMediaEvidence } from "./messageMetadata.js"; import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js"; import {