feat: remove OpenAI moderation configuration and update AI analysis logic

This commit is contained in:
MythEclipse
2026-05-14 02:44:26 +07:00
parent be6c9f8132
commit 6e203604ec
4 changed files with 62 additions and 62 deletions

View File

@@ -40,9 +40,6 @@ BACKLOG_SYNC_BATCH_SIZE=100
# AI Analysis Configuration # AI Analysis Configuration
AI_ANALYSIS_ENABLED=false AI_ANALYSIS_ENABLED=false
OPENAI_MODERATION_API_KEY=your_openai_moderation_key_here
OPENAI_MODERATION_BASE_URL=https://api.openai.com/v1
OPENAI_MODERATION_MODEL=omni-moderation-latest
AI_LLM_API_KEY=your_9router_key_here AI_LLM_API_KEY=your_9router_key_here
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1 AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
AI_LLM_MODEL=free AI_LLM_MODEL=free

View File

@@ -48,13 +48,6 @@ const configSchema = z.object({
AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000), AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000),
}).superRefine((value, ctx) => { }).superRefine((value, ctx) => {
if (!value.AI_ANALYSIS_ENABLED) return; if (!value.AI_ANALYSIS_ENABLED) return;
if (!value.OPENAI_MODERATION_API_KEY) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OPENAI_MODERATION_API_KEY"],
message: "OPENAI_MODERATION_API_KEY is required when AI_ANALYSIS_ENABLED=true",
});
}
if (!value.AI_LLM_API_KEY) { if (!value.AI_LLM_API_KEY) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,

View File

@@ -11,6 +11,7 @@ import { startWebserver } from "./webserver";
import { registerMessageCapture } from "./moderation/messageCapture"; import { registerMessageCapture } from "./moderation/messageCapture";
import { syncBacklogMessages } from "./moderation/backlogSync"; import { syncBacklogMessages } from "./moderation/backlogSync";
import { getDatabase } from "./muxer-queue"; import { getDatabase } from "./muxer-queue";
import { startPendingAIAnalysisWorker } from "./moderation/aiAnalyzer";
const logger = createChildLogger("bot"); const logger = createChildLogger("bot");
@@ -61,6 +62,7 @@ async function gracefulShutdown(signal: string) {
client.on("ready", async () => { client.on("ready", async () => {
logger.info({ user: client.user?.tag }, "Bot logged in"); logger.info({ user: client.user?.tag }, "Bot logged in");
registerMessageCapture(client, db); registerMessageCapture(client, db);
startPendingAIAnalysisWorker(db);
syncBacklogMessages(client, db).catch((error) => { syncBacklogMessages(client, db).catch((error) => {
logger.warn({ error }, "Backlog sync failed"); logger.warn({ error }, "Backlog sync failed");
}); });

View File

@@ -2,20 +2,13 @@ import { config } from "../config";
import { createChildLogger } from "../logger"; import { createChildLogger } from "../logger";
import type { SqliteDatabase } from "../muxer-queue"; import type { SqliteDatabase } from "../muxer-queue";
import { retryWithBackoff } from "../retry"; import { retryWithBackoff } from "../retry";
import { getMessageById, updateMessageAIAnalysis } from "./messageStore"; import { getMessageById, getPendingAIAnalysisMessages, updateMessageAIAnalysis } from "./messageStore";
import type { MessageRecord } from "./types"; import type { MessageRecord } from "./types";
const logger = createChildLogger("ai-analyzer"); const logger = createChildLogger("ai-analyzer");
const queuedMessageIds = new Set<string>(); const queuedMessageIds = new Set<string>();
let isProcessing = false; let isProcessing = false;
interface ModerationResult {
flagged: boolean;
flags: string[];
score: number;
raw: unknown;
}
interface ChatCompletionResponse { interface ChatCompletionResponse {
choices?: Array<{ choices?: Array<{
message?: { message?: {
@@ -24,6 +17,13 @@ interface ChatCompletionResponse {
}>; }>;
} }
interface LLMAnalysis {
status: "clean" | "flagged";
flags: string[];
score: number;
analysis: string;
}
function getAnalysisText(message: MessageRecord): string { function getAnalysisText(message: MessageRecord): string {
return (message.edited_content || message.content || "").trim(); return (message.edited_content || message.content || "").trim();
} }
@@ -47,39 +47,31 @@ async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
} }
} }
async function runModeration(text: string): Promise<ModerationResult> { function parseLLMAnalysis(content: string): LLMAnalysis {
const response = await retryWithBackoff( const jsonStart = content.indexOf("{");
() => fetchJson(`${config.OPENAI_MODERATION_BASE_URL}/moderations`, { const jsonEnd = content.lastIndexOf("}");
method: "POST", if (jsonStart >= 0 && jsonEnd > jsonStart) {
headers: { try {
"Authorization": `Bearer ${config.OPENAI_MODERATION_API_KEY}`, const parsed = JSON.parse(content.slice(jsonStart, jsonEnd + 1));
"Content-Type": "application/json", const status = parsed.status === "flagged" ? "flagged" : "clean";
}, const flags = Array.isArray(parsed.flags) ? parsed.flags.map(String) : [];
body: JSON.stringify({ const score = Math.max(0, Math.min(1, Number(parsed.score) || 0));
model: config.OPENAI_MODERATION_MODEL, const analysis = typeof parsed.analysis === "string" ? parsed.analysis : content;
input: text, return { status, flags, score, analysis };
}), } catch {
}), // Fall through to text-only parsing.
{ retries: 2, logger }, }
) as any; }
const result = response.results?.[0] || {};
const categories = result.categories || {};
const categoryScores = result.category_scores || {};
const flags = Object.entries(categories)
.filter(([, flagged]) => Boolean(flagged))
.map(([name]) => name);
const score = Math.max(0, ...Object.values(categoryScores).map((value) => Number(value) || 0));
return { return {
flagged: Boolean(result.flagged) || flags.length > 0, status: /flagged|bahaya|berisiko|toxic|hate|harassment|violence|sexual|self-harm/i.test(content) ? "flagged" : "clean",
flags, flags: [],
score, score: 0,
raw: response, analysis: content.trim() || "Tidak ada analisis dari LLM.",
}; };
} }
async function runLLMAnalysis(text: string, moderation: ModerationResult): Promise<string> { async function runLLMAnalysis(text: string): Promise<{ result: LLMAnalysis; raw: unknown }> {
const response = await retryWithBackoff( const response = await retryWithBackoff(
() => fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, { () => fetchJson(`${config.AI_LLM_BASE_URL}/chat/completions`, {
method: "POST", method: "POST",
@@ -92,16 +84,11 @@ async function runLLMAnalysis(text: string, moderation: ModerationResult): Promi
messages: [ messages: [
{ {
role: "system", role: "system",
content: "Kamu analis moderation Discord. Jawab singkat dalam Bahasa Indonesia: ringkasan risiko, alasan, dan aksi yang disarankan. Jangan mengulang pesan mentah secara panjang.", content: "Kamu analis moderation Discord. Nilai pesan untuk toxic, harassment, hate, violence, sexual, self-harm, spam, scam, atau unsafe content. Balas JSON valid saja dengan schema: {\"status\":\"clean|flagged\",\"flags\":[\"...\"],\"score\":0..1,\"analysis\":\"ringkasan singkat Bahasa Indonesia + alasan + aksi disarankan\"}.",
}, },
{ {
role: "user", role: "user",
content: JSON.stringify({ content: text,
message: text,
moderationFlagged: moderation.flagged,
moderationFlags: moderation.flags,
moderationScore: moderation.score,
}),
}, },
], ],
temperature: 0.2, temperature: 0.2,
@@ -110,7 +97,8 @@ async function runLLMAnalysis(text: string, moderation: ModerationResult): Promi
{ retries: 2, logger }, { retries: 2, logger },
) as ChatCompletionResponse; ) as ChatCompletionResponse;
return response.choices?.[0]?.message?.content?.trim() || "Tidak ada analisis dari LLM."; const content = response.choices?.[0]?.message?.content?.trim() || "";
return { result: parseLLMAnalysis(content), raw: response };
} }
async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Promise<void> { async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Promise<void> {
@@ -118,14 +106,13 @@ async function analyzeAndStore(db: SqliteDatabase, message: MessageRecord): Prom
if (!config.AI_ANALYSIS_ENABLED || text.length === 0) return; if (!config.AI_ANALYSIS_ENABLED || text.length === 0) return;
try { try {
const moderation = await runModeration(text); const { result, raw } = await runLLMAnalysis(text);
const analysis = await runLLMAnalysis(text, moderation);
const row = updateMessageAIAnalysis(db, message.id, { const row = updateMessageAIAnalysis(db, message.id, {
status: moderation.flagged ? "flagged" : "clean", status: result.status,
flags: JSON.stringify(moderation.flags), flags: JSON.stringify(result.flags),
score: moderation.score, score: result.score,
raw: JSON.stringify(moderation.raw), raw: JSON.stringify(raw),
analysis, analysis: result.analysis,
analyzedAt: Date.now(), analyzedAt: Date.now(),
error: null, error: null,
}); });
@@ -150,7 +137,8 @@ async function drainQueue(db: SqliteDatabase): Promise<void> {
isProcessing = true; isProcessing = true;
try { try {
while (queuedMessageIds.size > 0) { while (queuedMessageIds.size > 0) {
const [messageId] = queuedMessageIds; const messageId = queuedMessageIds.values().next().value as string | undefined;
if (!messageId) break;
queuedMessageIds.delete(messageId); queuedMessageIds.delete(messageId);
const message = getMessageById(db, messageId); const message = getMessageById(db, messageId);
if (message) await analyzeAndStore(db, message); if (message) await analyzeAndStore(db, message);
@@ -162,8 +150,28 @@ async function drainQueue(db: SqliteDatabase): Promise<void> {
export function queueMessageAnalysis(db: SqliteDatabase, messageId: string): void { export function queueMessageAnalysis(db: SqliteDatabase, messageId: string): void {
if (!config.AI_ANALYSIS_ENABLED) return; if (!config.AI_ANALYSIS_ENABLED) return;
logger.debug({ messageId }, "Queueing AI analysis");
queuedMessageIds.add(messageId); queuedMessageIds.add(messageId);
setImmediate(() => { setImmediate(() => {
drainQueue(db).catch((error) => logger.error({ error }, "AI analysis queue failed")); drainQueue(db).catch((error) => logger.error({ error }, "AI analysis queue failed"));
}); });
} }
export function startPendingAIAnalysisWorker(db: SqliteDatabase): void {
if (!config.AI_ANALYSIS_ENABLED) {
logger.info("AI analysis disabled");
return;
}
logger.info("AI analysis worker started");
setInterval(() => {
if (isProcessing) return;
const pendingMessages = getPendingAIAnalysisMessages(db, 3);
if (pendingMessages.length === 0) return;
logger.info({ count: pendingMessages.length }, "Queueing pending AI analysis messages");
for (const message of pendingMessages) {
queuedMessageIds.add(message.id);
}
drainQueue(db).catch((error) => logger.error({ error }, "Pending AI analysis worker failed"));
}, 15000);
}