chore: session auto-commit

This commit is contained in:
MythEclipse
2026-06-02 17:55:42 +07:00
parent a2d4ba3f95
commit 292fcbf238
6 changed files with 580 additions and 339 deletions
@@ -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 = /<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.
*/
@@ -51,7 +25,6 @@ interface BadwordCacheEntry {
const badwordCache = new Map<string, BadwordCacheEntry>();
const inFlightBadwordLookups = new Map<string, Promise<string[]>>();
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<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
// ---------------------------------------------------------------------------
@@ -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
* 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) },
@@ -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,
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 ||
+5 -142
View File
@@ -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 = /<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.
*/
@@ -51,7 +25,6 @@ interface BadwordCacheEntry {
const badwordCache = new Map<string, BadwordCacheEntry>();
const inFlightBadwordLookups = new Map<string, Promise<string[]>>();
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<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
// ---------------------------------------------------------------------------
@@ -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
* 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) },
+276
View File
@@ -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);
}
+1 -1
View File
@@ -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 {