chore: session auto-commit
This commit is contained in:
@@ -1,37 +1,11 @@
|
|||||||
import OpenAI from "openai";
|
|
||||||
import { config } from "../../shared/config/config.js";
|
|
||||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||||
import { getCachedText, upsertCachedText } from "./textCacheStore.js";
|
import { getCachedText, upsertCachedText } from "./textCacheStore.js";
|
||||||
|
import { llmDetectBadwords } from "./llmClient.js";
|
||||||
|
|
||||||
const log = createChildLogger("indonesianTextNormalizer");
|
const log = createChildLogger("indonesianTextNormalizer");
|
||||||
|
|
||||||
const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/g;
|
const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/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.
|
* In-memory cache TTL (10 min) — fastest path for repeated identical texts.
|
||||||
*/
|
*/
|
||||||
@@ -51,7 +25,6 @@ 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[]>>();
|
||||||
let primaryModerationClient: OpenAI | null = null;
|
|
||||||
|
|
||||||
export interface ModerationTextEvidence {
|
export interface ModerationTextEvidence {
|
||||||
raw: string;
|
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<string>();
|
|
||||||
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<string, unknown>;
|
|
||||||
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<string[]> {
|
|
||||||
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
|
// Two-tier cache + Primary AI pipeline
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -244,7 +106,8 @@ async function callPrimaryAiModeration(text: string): Promise<string[]> {
|
|||||||
* 2. **DB cache** (DB_CACHE_TTL_MS, 24 h) — same full-text key, persisted
|
* 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
|
* across restarts. Uses the FULL normalized text (not per-word) because
|
||||||
* context matters: "kau" alone is clean, but "awas kau" can be a threat.
|
* 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
|
* No local hardcoded badword list — all detection goes through AI APIs
|
||||||
* to eliminate false positives from substring matching.
|
* to eliminate false positives from substring matching.
|
||||||
@@ -275,10 +138,10 @@ export async function detectIndonesianBadwords(
|
|||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tier 3: Primary AI only ──
|
// ── Tier 3: Primary AI only (via centralized llmClient) ──
|
||||||
let finalHits: string[] = [];
|
let finalHits: string[] = [];
|
||||||
try {
|
try {
|
||||||
finalHits = await callPrimaryAiModeration(text);
|
finalHits = await llmDetectBadwords(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn(
|
log.warn(
|
||||||
{ error: error instanceof Error ? error.message : String(error) },
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
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<OpenAI.Chat.Completions.ChatCompletion | null> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string[]> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string>();
|
||||||
|
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<string, unknown>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ import type {
|
|||||||
AttachmentRecord,
|
AttachmentRecord,
|
||||||
MessageRecord,
|
MessageRecord,
|
||||||
} from "../message-capture/types.js";
|
} from "../message-capture/types.js";
|
||||||
import { withLlmConcurrency } from "./concurrencyLimiter.js";
|
|
||||||
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
||||||
|
import { llmChat, llmVision } from "./llmClient.js";
|
||||||
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
||||||
import { logModerationAnalysis, logModerationError } from "./responseLogger.js";
|
import { logModerationAnalysis, logModerationError } from "./responseLogger.js";
|
||||||
import {
|
import {
|
||||||
@@ -592,39 +592,7 @@ const analyzeSingleMediaImage = async (
|
|||||||
: buildGeneralImageVisionPrompt(image.sourceLabel, messageId);
|
: buildGeneralImageVisionPrompt(image.sourceLabel, messageId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const completion = await retryWithBackoff(
|
const content = await llmVision(promptText, image.image_url);
|
||||||
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();
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
await upsertCachedMediaAnalysis(
|
await upsertCachedMediaAnalysis(
|
||||||
@@ -693,26 +661,21 @@ async function callModerationLLM(
|
|||||||
try {
|
try {
|
||||||
const content = await buildContent(state);
|
const content = await buildContent(state);
|
||||||
|
|
||||||
const completion = await withLlmConcurrency(async () =>
|
const completion = await llmChat({
|
||||||
openai.chat.completions.create({
|
messages: [{ role: "user", content }],
|
||||||
model: config.AI_LLM_MODEL,
|
max_tokens: 16384,
|
||||||
messages: [{ role: "user", content }],
|
jsonResponse: {
|
||||||
temperature: 0.2,
|
type: "json_schema",
|
||||||
top_p: 0.95,
|
name: "moderation_result",
|
||||||
// Sufficient for 20 moderation results (each ~70-150 tokens).
|
schema: MODERATION_JSON_SCHEMA,
|
||||||
// Previous 4096 caused LLM truncation after ~6 results.
|
strict: true,
|
||||||
max_tokens: 16384,
|
},
|
||||||
response_format: {
|
retries: 0,
|
||||||
type: "json_schema",
|
});
|
||||||
json_schema: {
|
|
||||||
name: "moderation_result",
|
if (!completion) {
|
||||||
schema: MODERATION_JSON_SCHEMA,
|
throw new Error("LLM client unavailable (no API key)");
|
||||||
strict: true,
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
stream: false,
|
|
||||||
} as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!completion.choices ||
|
!completion.choices ||
|
||||||
|
|||||||
@@ -1,37 +1,11 @@
|
|||||||
import OpenAI from "openai";
|
|
||||||
import { config } from "../config.js";
|
|
||||||
import { createChildLogger } from "../logger.js";
|
import { createChildLogger } from "../logger.js";
|
||||||
import { getCachedText, upsertCachedText } from "./textCacheStore.js";
|
import { getCachedText, upsertCachedText } from "./textCacheStore.js";
|
||||||
|
import { llmDetectBadwords } from "./llmClient.js";
|
||||||
|
|
||||||
const log = createChildLogger("indonesianTextNormalizer");
|
const log = createChildLogger("indonesianTextNormalizer");
|
||||||
|
|
||||||
const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/g;
|
const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/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.
|
* In-memory cache TTL (10 min) — fastest path for repeated identical texts.
|
||||||
*/
|
*/
|
||||||
@@ -51,7 +25,6 @@ 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[]>>();
|
||||||
let primaryModerationClient: OpenAI | null = null;
|
|
||||||
|
|
||||||
export interface ModerationTextEvidence {
|
export interface ModerationTextEvidence {
|
||||||
raw: string;
|
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<string>();
|
|
||||||
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<string, unknown>;
|
|
||||||
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<string[]> {
|
|
||||||
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
|
// Two-tier cache + Primary AI pipeline
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -244,7 +106,8 @@ async function callPrimaryAiModeration(text: string): Promise<string[]> {
|
|||||||
* 2. **DB cache** (DB_CACHE_TTL_MS, 24 h) — same full-text key, persisted
|
* 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
|
* across restarts. Uses the FULL normalized text (not per-word) because
|
||||||
* context matters: "kau" alone is clean, but "awas kau" can be a threat.
|
* 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
|
* No local hardcoded badword list — all detection goes through AI APIs
|
||||||
* to eliminate false positives from substring matching.
|
* to eliminate false positives from substring matching.
|
||||||
@@ -275,10 +138,10 @@ export async function detectIndonesianBadwords(
|
|||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tier 3: Primary AI only ──
|
// ── Tier 3: Primary AI only (via centralized llmClient) ──
|
||||||
let finalHits: string[] = [];
|
let finalHits: string[] = [];
|
||||||
try {
|
try {
|
||||||
finalHits = await callPrimaryAiModeration(text);
|
finalHits = await llmDetectBadwords(text);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn(
|
log.warn(
|
||||||
{ error: error instanceof Error ? error.message : String(error) },
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
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<OpenAI.Chat.Completions.ChatCompletion | null> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string[]> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string>();
|
||||||
|
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<string, unknown>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import { z } from "zod";
|
|||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import { createChildLogger } from "../logger.js";
|
import { createChildLogger } from "../logger.js";
|
||||||
import { retryWithBackoff } from "../retry.js";
|
import { retryWithBackoff } from "../retry.js";
|
||||||
import { withLlmConcurrency } from "./concurrencyLimiter.js";
|
|
||||||
import { resizeImageForVision } from "./imageResizer.js";
|
import { resizeImageForVision } from "./imageResizer.js";
|
||||||
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
import { formatModerationTextEvidenceForPrompt } from "./indonesianTextNormalizer.js";
|
||||||
|
import { llmChat, llmVision } from "./llmClient.js";
|
||||||
import { extractMessageMediaEvidence } from "./messageMetadata.js";
|
import { extractMessageMediaEvidence } from "./messageMetadata.js";
|
||||||
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
Reference in New Issue
Block a user