Compare commits
6
Commits
d2e97ae11d
...
a4abe3abea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4abe3abea | ||
|
|
b67856462f | ||
|
|
30828a5534 | ||
|
|
a3e5a8c1b9 | ||
|
|
f82b5caae4 | ||
|
|
9e2b107fcd |
No files matched your search
@@ -31,13 +31,6 @@ export interface ChatbotHistoryRow {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerInsights {
|
|
||||||
total_messages: number;
|
|
||||||
active_users: number;
|
|
||||||
flagged: number;
|
|
||||||
warned: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ChatbotRepository {
|
export class ChatbotRepository {
|
||||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
@@ -83,56 +76,6 @@ export class ChatbotRepository {
|
|||||||
"Chat history cleared",
|
"Chat history cleared",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getServerInsights(
|
|
||||||
guildId?: string,
|
|
||||||
channelId?: string,
|
|
||||||
): Promise<ServerInsights> {
|
|
||||||
try {
|
|
||||||
const db = getDatabase();
|
|
||||||
const conditions: SQL[] = [];
|
|
||||||
|
|
||||||
if (guildId) {
|
|
||||||
conditions.push(eq(pgMessagesTable.guild_id, guildId));
|
|
||||||
}
|
|
||||||
if (channelId) {
|
|
||||||
conditions.push(eq(pgMessagesTable.channel_id, channelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
|
|
||||||
const [result] = await db
|
|
||||||
.select({
|
|
||||||
total_messages: sql<number>`COUNT(*)::int`,
|
|
||||||
active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
|
|
||||||
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
|
||||||
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
|
||||||
})
|
|
||||||
.from(pgMessagesTable)
|
|
||||||
.where(where);
|
|
||||||
|
|
||||||
const insights = result ?? {
|
|
||||||
total_messages: 0,
|
|
||||||
active_users: 0,
|
|
||||||
flagged: 0,
|
|
||||||
warned: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
logger.debug({ guildId, channelId, insights }, "Server insights fetched");
|
|
||||||
return insights;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(
|
|
||||||
{ error, guildId, channelId },
|
|
||||||
"Failed to load server insights",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
total_messages: 0,
|
|
||||||
active_users: 0,
|
|
||||||
flagged: 0,
|
|
||||||
warned: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatbotRepository = new ChatbotRepository();
|
export const chatbotRepository = new ChatbotRepository();
|
||||||
@@ -6,7 +6,8 @@ import type {
|
|||||||
SaveConversationInput,
|
SaveConversationInput,
|
||||||
} from "./chatbot.repository.js";
|
} from "./chatbot.repository.js";
|
||||||
import { chatbotRepository } from "./chatbot.repository.js";
|
import { chatbotRepository } from "./chatbot.repository.js";
|
||||||
import { executeTool, tools } from "./chatbot.tools.js";
|
import { tools } from "./chatbot.toolDefs.js";
|
||||||
|
import { executeTool } from "./chatbot.tools.js";
|
||||||
|
|
||||||
const logger = createChildLogger("chatbot.service");
|
const logger = createChildLogger("chatbot.service");
|
||||||
|
|
||||||
@@ -17,22 +18,26 @@ class ChatbotService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ userId, messageLength: message.length },
|
{ userId, messageLength: message.length, context },
|
||||||
"processMessage called",
|
"processMessage called",
|
||||||
);
|
);
|
||||||
const recentContext = await this.getRecentConversationContext(userId);
|
const recentContext = await this.getRecentConversationContext(userId);
|
||||||
const serverInsights = await chatbotRepository.getServerInsights(
|
// Scope the agent to the server/channel the user is chatting in. We no
|
||||||
context?.guildId,
|
// longer bake server stats into the prompt — the model must pull current
|
||||||
context?.channelId,
|
// data via tools (see buildSystemPrompt), so it always answers from live
|
||||||
);
|
// numbers instead of a stale snapshot.
|
||||||
|
const scope = {
|
||||||
|
guildId: context?.guildId,
|
||||||
|
channelId: context?.channelId,
|
||||||
|
};
|
||||||
|
|
||||||
// Build LLM messages
|
const systemPrompt = this.buildSystemPrompt(scope);
|
||||||
const systemPrompt = this.buildSystemPrompt(serverInsights);
|
|
||||||
const conversationHistory = this.buildHistoryMessages(recentContext);
|
const conversationHistory = this.buildHistoryMessages(recentContext);
|
||||||
const llmResponse = await this.callLLM(
|
const llmResponse = await this.callLLM(
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
conversationHistory,
|
conversationHistory,
|
||||||
message,
|
message,
|
||||||
|
scope,
|
||||||
);
|
);
|
||||||
|
|
||||||
return llmResponse;
|
return llmResponse;
|
||||||
@@ -66,27 +71,29 @@ class ChatbotService {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSystemPrompt(insights: {
|
private buildSystemPrompt(scope: {
|
||||||
total_messages: number;
|
guildId?: string;
|
||||||
active_users: number;
|
channelId?: string;
|
||||||
flagged: number;
|
|
||||||
warned: number;
|
|
||||||
}): string {
|
}): string {
|
||||||
return `Kamu lagi ngobrol sama chatbot Discord Watcher — temen ngobrol yang tau keadaan server.
|
const scopeLine = scope.guildId
|
||||||
|
? `- Scope: kamu menjawab soal server/guild id="${scope.guildId}"${scope.channelId ? `, channel id="${scope.channelId}"` : ""}.`
|
||||||
|
: "- Scope: tidak ada guild spesifik — jawab umum soal server ini.";
|
||||||
|
return `Kamu adalah chatbot Discord Watcher — temen ngobrol yang tau keadaan server, dan kamu PUNYA AKSES ke data server lewat tools.
|
||||||
|
|
||||||
Data server saat ini:
|
${scopeLine}
|
||||||
- Pesan: ${insights.total_messages}
|
|
||||||
- User aktif: ${insights.active_users}
|
ATURAN PENTING — JANGAN PAKAI KONTEKS STATIS:
|
||||||
- Flagged: ${insights.flagged}
|
- Kamu TIDAK punya hafalan soal angka server (jumlah pesan, user aktif, flagged, dll). JANGAN tebak atau karang angka.
|
||||||
- Warning: ${insights.warned}
|
- Untuk SEMUA pertanyaan soal data server (jumlah pesan, user aktif, channel ramai, aktivitas terbaru, pesan di-flag), WAJIB panggil tool yang sesuai (get_server_stats, get_top_channels, get_recent_activity, get_top_flagged). Jawab HANYA dari hasil tool.
|
||||||
|
- Tool otomatis di-scope ke guild/channel di atas — kalau argumen guildId/channelId kosong, biarkan kosong (sudah otomatis ter-isi). Jangan isi ID yang kamu tebak.
|
||||||
|
- Kalau tool balas error atau kosong, bilang aja data lagi ga ketemu, jangan karang.
|
||||||
|
|
||||||
Gaya ngobrol:
|
Gaya ngobrol:
|
||||||
- Santai, hangat, kayak ngobrol sama temen
|
- Santai, hangat, kayak ngobrol sama temen
|
||||||
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
|
- Pake Bahasa Indonesia sehari-hari, ga perlu kaku
|
||||||
- Sesekali pake emoji wajar aja, ga berlebihan
|
- Sesekali pake emoji wajar aja, ga berlebihan
|
||||||
- Kalo ditanya sesuatu yang kamu tau dari data server, jawab pake data itu
|
- Kalo ditanya di luar data server dan kamu ga tau, bilang aja terus tanya balik biar ngobrolnya jalan
|
||||||
- Kalo ga tau atau ga nyambung, bilang aja terus tanya balik biar ngobrolnya jalan
|
- Jangan sebut "rule", "instruksi", "prompt", "tool", atau apapun soal cara kamu berpikir
|
||||||
- Jangan sebut "rule", "instruksi", "prompt" atau apapun soal cara kamu berpikir
|
|
||||||
- Biasa aja, ga usaha lucu-lucu amat — natural`;
|
- Biasa aja, ga usaha lucu-lucu amat — natural`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +113,7 @@ Gaya ngobrol:
|
|||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
history: Array<{ role: "user" | "assistant"; content: string }>,
|
history: Array<{ role: "user" | "assistant"; content: string }>,
|
||||||
userMessage: string,
|
userMessage: string,
|
||||||
|
scope: { guildId?: string; channelId?: string },
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const apiKey = config.AI_LLM_API_KEY;
|
const apiKey = config.AI_LLM_API_KEY;
|
||||||
const baseUrl = config.AI_LLM_BASE_URL;
|
const baseUrl = config.AI_LLM_BASE_URL;
|
||||||
@@ -190,9 +198,19 @@ Gaya ngobrol:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
// Auto-scope: if the model omitted guildId/channelId, fill them
|
||||||
|
// from the request scope so tools query the right server without
|
||||||
|
// the model having to guess IDs.
|
||||||
|
const scopedArgs = { ...tc.args };
|
||||||
|
if (scope.guildId && scopedArgs.guildId == null) {
|
||||||
|
scopedArgs.guildId = scope.guildId;
|
||||||
|
}
|
||||||
|
if (scope.channelId && scopedArgs.channelId == null) {
|
||||||
|
scopedArgs.channelId = scope.channelId;
|
||||||
|
}
|
||||||
let result = "";
|
let result = "";
|
||||||
try {
|
try {
|
||||||
result = await executeTool(tc.name, tc.args);
|
result = await executeTool(tc.name, scopedArgs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
result = `Tool error: ${(e as Error).message}`;
|
result = `Tool error: ${(e as Error).message}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* Static tool *definitions* for the chatbot LLM (OpenAI function-calling
|
||||||
|
* format). Kept separate from the executor (chatbot.tools.ts) so the schema
|
||||||
|
* the model depends on can be imported without pulling in the database /
|
||||||
|
* config layer.
|
||||||
|
*
|
||||||
|
* The chatbot is a server-watcher agent: it can answer about ANY server
|
||||||
|
* situation — activity, moderation queue, specific users, channels, voice
|
||||||
|
* recordings, AI correction history, and trends over time — by calling these
|
||||||
|
* tools, which the executor implements against real tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ToolDef {
|
||||||
|
type: "function";
|
||||||
|
function: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: {
|
||||||
|
type: "object";
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: ToolDef[] = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_server_stats",
|
||||||
|
description:
|
||||||
|
"Ambil statistik ringkas server/guild: total pesan, user aktif, jumlah pesan flagged, warn, dan clean. Panggil untuk jawab pertanyaan umum soal kondisi server. guildId/channelId otomatis ter-isi dari scope; kosongkan untuk semua data.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_top_channels",
|
||||||
|
description:
|
||||||
|
"Ambil daftar channel paling aktif (jumlah pesan terbanyak). Panggil untuk 'channel mana paling ramai' atau aktivitas per-channel.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah channel teratas (default 5, max 10).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_recent_activity",
|
||||||
|
description:
|
||||||
|
"Ambil pesan terbaru di server: siapa, di channel mana, jam berapa, isinya. Panggil untuk 'lagi ngapain' / aktivitas terbaru.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah pesan terakhir (default 5, max 20).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_top_flagged",
|
||||||
|
description:
|
||||||
|
"Ambil pesan dengan ai_status flagged (beserta alasan, severity, analysis). Panggil untuk bahas pesan bermasalah / kerjaan moderator.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah pesan (default 5)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "search_messages",
|
||||||
|
description:
|
||||||
|
"Cari pesan berdasarkan kata kunci di isi pesan (case-insensitive, LIKE). Untuk 'ada yang bahas X gak?' / temukan topik tertentu. Hindari kata terlalu umum.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: {
|
||||||
|
type: "string",
|
||||||
|
description: "Kata kunci pencarian (wajib).",
|
||||||
|
},
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah hasil (default 5)." },
|
||||||
|
},
|
||||||
|
required: ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_user_messages",
|
||||||
|
description:
|
||||||
|
"Ambil pesan terbaru dari satu user tertentu (user_id), opsional di-scope ke guild/channel. Untuk 'chat si A gimana akhir-akhir ini?' — butuh user_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "ID user (wajib)." },
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah pesan (default 10)." },
|
||||||
|
},
|
||||||
|
required: ["userId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_user_profile",
|
||||||
|
description:
|
||||||
|
"Ambil ringkasan profil AI dari seorang user (pola perilaku, gaya bicara) dari tabel user_profiles. Untuk 'siapa si A?' / konteks perilaku. Butuh user_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "ID user (wajib)." },
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
},
|
||||||
|
required: ["userId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_user_reputation",
|
||||||
|
description:
|
||||||
|
"Ambil skor trust, jumlah infraction, dan streak pesan bersih seorang user dari user_reputations. Untuk 'berapa trust score si A?' / riwayat pelanggaran. Butuh user_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "ID user (wajib)." },
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
},
|
||||||
|
required: ["userId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_channel_culture",
|
||||||
|
description:
|
||||||
|
"Ambil ringkasan norma/slang channel dari tabel channel_cultures (AI-generated). Untuk 'norma channel ini gimana?' / konteks sebelum nge-flag. Butuh channel_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
channelId: { type: "string", description: "ID channel (wajib)." },
|
||||||
|
},
|
||||||
|
required: ["channelId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_message_detail",
|
||||||
|
description:
|
||||||
|
"Ambil 1 pesan lengkap beserta hasil analisis AI-nya (status, flags, score, severity, kategori, analysis, recommended action). Untuk jelasin keputusan moderasi pada pesan tertentu. Butuh message_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
messageId: { type: "string", description: "ID pesan (wajib)." },
|
||||||
|
},
|
||||||
|
required: ["messageId"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_message_reviews",
|
||||||
|
description:
|
||||||
|
"Ambil antrean review moderasi manual (message_reviews) berdasarkan status: pending/approved/rejected/escalated. Untuk 'ada review moderasi pending?' / cek kerjaan human moderator. guildId otomatis ter-isi.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
status: {
|
||||||
|
type: "string",
|
||||||
|
description:
|
||||||
|
"Status review: pending / approved / rejected / escalated (opsional, default semua).",
|
||||||
|
},
|
||||||
|
limit: { type: "number", description: "Jumlah (default 10)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_voice_recordings",
|
||||||
|
description:
|
||||||
|
"Ambil rekaman suara terbaru (voice_recordings): user, channel, transkripsi, status upload. Untuk 'ada rekaman suara terbaru?' / cek transkripsi. Bisa di-scope ke user_id atau channel_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
userId: { type: "string", description: "Filter user (opsional)." },
|
||||||
|
channelId: {
|
||||||
|
type: "string",
|
||||||
|
description: "Filter channel (opsional).",
|
||||||
|
},
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah (default 10)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_moderation_timeline",
|
||||||
|
description:
|
||||||
|
"Ambil tren harian: per hari, jumlah total pesan vs flagged vs warn vs clean. Untuk 'minggu ini pelanggaran naik?' / lihat tren moderasi. guildId otomatis ter-isi.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
channelId: { type: "string", description: "ID channel (opsional)." },
|
||||||
|
days: {
|
||||||
|
type: "number",
|
||||||
|
description: "Jumlah hari ke belakang (default 14, max 60).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "get_corrections",
|
||||||
|
description:
|
||||||
|
"Ambil riwayat koreksi false-positive AI (corrected_moderations): pesan yang awalnya di-flag tapi dikoreksi manusia, beserta alasannya. Untuk 'AI pernah salah nge-flag apa aja?' / audit akurasi moderasi.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
guildId: { type: "string", description: "ID server (opsional)." },
|
||||||
|
limit: { type: "number", description: "Jumlah (default 10)." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,116 +1,26 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { and, desc, eq, like, sql } from "drizzle-orm";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
import {
|
||||||
|
pgChannelCulturesTable,
|
||||||
|
pgCorrectedModerationsTable,
|
||||||
|
pgMessageReviewsTable,
|
||||||
|
pgMessagesTable,
|
||||||
|
pgUserProfilesTable,
|
||||||
|
pgUserReputationsTable,
|
||||||
|
pgVoiceRecordingsTable,
|
||||||
|
} from "../../shared/index.js";
|
||||||
|
import { tools } from "./chatbot.toolDefs.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tools the chatbot LLM can call. Definitions describe the schema to the
|
* Executor for the chatbot's server-watcher tools. The tool *definitions*
|
||||||
* model; the executor implements each one against the real database.
|
* live in chatbot.toolDefs.ts (no DB import); this file implements each one
|
||||||
* This turns the chatbot from "blind stats guesser" into an agent that
|
* against the real database.
|
||||||
* pulls real, current server data on demand.
|
*
|
||||||
|
* All queries use parameterized drizzle operators (eq/like/and) — never string
|
||||||
|
* interpolation into raw SQL — so model-supplied arguments cannot inject SQL.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ToolResult = string;
|
export type ToolResult = string;
|
||||||
|
|
||||||
/** JSON schema for a tool definition (OpenAI function-calling format). */
|
|
||||||
export interface ToolDef {
|
|
||||||
type: "function";
|
|
||||||
function: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
parameters: {
|
|
||||||
type: "object";
|
|
||||||
properties: Record<string, unknown>;
|
|
||||||
required?: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const tools: ToolDef[] = [
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_server_stats",
|
|
||||||
description:
|
|
||||||
"Ambil statistik ringkas server/guild saat ini: total pesan, user aktif, jumlah pesan flagged, dan jumlah warning. Panggil ini untuk menjawab pertanyaan umum tentang kondisi server. Opsional fill guild_id untuk scope ke guild tertentu, channel_id untuk scope ke channel.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID guild/server (opsional). Kosongkan = semua data.",
|
|
||||||
},
|
|
||||||
channelId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID channel (opsional).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_top_channels",
|
|
||||||
description:
|
|
||||||
"Ambil daftar channel paling aktif (jumlah pesan terbanyak) di server. Panggil buat jawab 'channel mana paling ramai' atau aktivitas per-channel.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID server (opsional).",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Jumlah channel teratas (default 5, max 10).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_recent_activity",
|
|
||||||
description:
|
|
||||||
"Ambil aktivitas/pesan terbaru di server: siapa yang baru ngomong, di channel mana, jam berapa. Panggil buat jawaban soal 'lagi ngapain' / aktivitas terbaru di server.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID server (opsional).",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Jumlah pesan terakhir (default 5).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: "get_top_flagged",
|
|
||||||
description:
|
|
||||||
"Ambil pesan yang paling sering di-flag atau kena warning. Panggil buat jawab soal pesan bermasalah / moderator.",
|
|
||||||
parameters: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
guildId: {
|
|
||||||
type: "string",
|
|
||||||
description: "ID server (opsional).",
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: "number",
|
|
||||||
description: "Jumlah pesan (default 5).",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Executes a tool call against the real DB and returns a readable result. */
|
/** Executes a tool call against the real DB and returns a readable result. */
|
||||||
export async function executeTool(
|
export async function executeTool(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -122,9 +32,11 @@ export async function executeTool(
|
|||||||
typeof args.channelId === "string" && args.channelId
|
typeof args.channelId === "string" && args.channelId
|
||||||
? args.channelId
|
? args.channelId
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const userId =
|
||||||
|
typeof args.userId === "string" && args.userId ? args.userId : undefined;
|
||||||
const limitRaw =
|
const limitRaw =
|
||||||
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
|
typeof args.limit === "number" ? args.limit : Number(args.limit) || 5;
|
||||||
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 10);
|
const limit = Math.min(Math.max(1, Math.round(limitRaw)), 20);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (name) {
|
switch (name) {
|
||||||
@@ -133,9 +45,48 @@ export async function executeTool(
|
|||||||
case "get_top_channels":
|
case "get_top_channels":
|
||||||
return await topChannels(guildId, limit);
|
return await topChannels(guildId, limit);
|
||||||
case "get_recent_activity":
|
case "get_recent_activity":
|
||||||
return await recentActivity(guildId, limit);
|
return await recentActivity(guildId, channelId, limit);
|
||||||
case "get_top_flagged":
|
case "get_top_flagged":
|
||||||
return await topFlagged(guildId, limit);
|
return await topFlagged(guildId, channelId, limit);
|
||||||
|
case "search_messages":
|
||||||
|
return await searchMessages(
|
||||||
|
String(args.query ?? ""),
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
case "get_user_messages":
|
||||||
|
return await userMessages(userId, guildId, channelId, limit);
|
||||||
|
case "get_user_profile":
|
||||||
|
return await userProfile(userId, guildId);
|
||||||
|
case "get_user_reputation":
|
||||||
|
return await userReputation(userId, guildId);
|
||||||
|
case "get_channel_culture":
|
||||||
|
return await channelCulture(
|
||||||
|
typeof args.channelId === "string" ? args.channelId : undefined,
|
||||||
|
);
|
||||||
|
case "get_message_detail":
|
||||||
|
return await messageDetail(
|
||||||
|
typeof args.messageId === "string" ? args.messageId : undefined,
|
||||||
|
);
|
||||||
|
case "get_message_reviews":
|
||||||
|
return await messageReviews(
|
||||||
|
guildId,
|
||||||
|
typeof args.status === "string" ? args.status : undefined,
|
||||||
|
limit,
|
||||||
|
);
|
||||||
|
case "get_voice_recordings":
|
||||||
|
return await voiceRecordings(userId, channelId, guildId, limit);
|
||||||
|
case "get_moderation_timeline":
|
||||||
|
return await moderationTimeline(
|
||||||
|
guildId,
|
||||||
|
channelId,
|
||||||
|
typeof args.days === "number"
|
||||||
|
? Math.min(Math.max(1, args.days), 60)
|
||||||
|
: 14,
|
||||||
|
);
|
||||||
|
case "get_corrections":
|
||||||
|
return await corrections(guildId, limit);
|
||||||
default:
|
default:
|
||||||
return `Unknown tool: ${name}`;
|
return `Unknown tool: ${name}`;
|
||||||
}
|
}
|
||||||
@@ -145,6 +96,23 @@ export async function executeTool(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Query helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
function scopeMessages(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
): ReturnType<typeof and> | undefined {
|
||||||
|
const conds = [];
|
||||||
|
if (guildId) conds.push(eq(pgMessagesTable.guild_id, guildId));
|
||||||
|
if (channelId) conds.push(eq(pgMessagesTable.channel_id, channelId));
|
||||||
|
return conds.length ? and(...conds) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape LIKE wildcards so user input can't break the pattern. */
|
||||||
|
function likePattern(q: string): string {
|
||||||
|
return q.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tool executors ──────────────────────────────────────────
|
// ── Tool executors ──────────────────────────────────────────
|
||||||
|
|
||||||
async function serverStats(
|
async function serverStats(
|
||||||
@@ -152,81 +120,330 @@ async function serverStats(
|
|||||||
channelId?: string,
|
channelId?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions: string[] = [];
|
const [result] = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
if (channelId) conditions.push(`channel_id = '${channelId}'`);
|
total_messages: sql<number>`COUNT(*)::int`,
|
||||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
active_users: sql<number>`COUNT(DISTINCT ${pgMessagesTable.user_id})::int`,
|
||||||
|
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
||||||
|
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
||||||
|
clean: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'clean')::int`,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(scopeMessages(guildId, channelId));
|
||||||
|
|
||||||
const result = await db.execute(
|
const r = result ?? {
|
||||||
sql.raw(
|
total_messages: 0,
|
||||||
`SELECT COUNT(*)::int AS total_messages,
|
active_users: 0,
|
||||||
COUNT(DISTINCT user_id)::int AS active_users,
|
flagged: 0,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged,
|
warned: 0,
|
||||||
COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS warned
|
clean: 0,
|
||||||
FROM messages ${cond}`,
|
};
|
||||||
),
|
return JSON.stringify(r);
|
||||||
);
|
|
||||||
const rows =
|
|
||||||
(result as unknown as { rows: Record<string, unknown>[] }).rows ?? [];
|
|
||||||
const r = rows[0] ?? {};
|
|
||||||
return JSON.stringify({
|
|
||||||
total_messages: r.total_messages ?? 0,
|
|
||||||
active_users: r.active_users ?? 0,
|
|
||||||
flagged: r.flagged ?? 0,
|
|
||||||
warned: r.warned ?? 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function topChannels(guildId?: string, limit = 5): Promise<string> {
|
async function topChannels(guildId?: string, limit = 5): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions: string[] = [];
|
const rows = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
count: sql<number>`COUNT(*)::int`,
|
||||||
const result = await db.execute(
|
})
|
||||||
sql.raw(
|
.from(pgMessagesTable)
|
||||||
`SELECT channel_id,
|
.where(scopeMessages(guildId))
|
||||||
COUNT(*)::int AS count
|
.groupBy(pgMessagesTable.channel_id)
|
||||||
FROM messages ${cond}
|
.orderBy(desc(sql`COUNT(*)`))
|
||||||
GROUP BY channel_id
|
.limit(limit);
|
||||||
ORDER BY count DESC
|
return JSON.stringify(rows);
|
||||||
LIMIT ${limit}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const rows = (result as unknown as { rows: unknown[] }).rows ?? [];
|
|
||||||
return JSON.stringify(rows.slice(0, limit));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recentActivity(guildId?: string, limit = 5): Promise<string> {
|
async function recentActivity(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions: string[] = [];
|
const rows = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
const cond = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
id: pgMessagesTable.id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
const result = await db.execute(
|
user_id: pgMessagesTable.user_id,
|
||||||
sql.raw(
|
channel_id: pgMessagesTable.channel_id,
|
||||||
`SELECT username, content, channel_id, created_at
|
content: pgMessagesTable.content,
|
||||||
FROM messages ${cond}
|
created_at: pgMessagesTable.created_at,
|
||||||
ORDER BY created_at DESC
|
ai_status: pgMessagesTable.ai_status,
|
||||||
LIMIT ${limit}`,
|
})
|
||||||
),
|
.from(pgMessagesTable)
|
||||||
);
|
.where(scopeMessages(guildId, channelId))
|
||||||
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function topFlagged(guildId?: string, limit = 5): Promise<string> {
|
async function topFlagged(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<string> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
const conditions = ["ai_status IN ('flagged', 'warn')"];
|
const rows = await db
|
||||||
if (guildId) conditions.push(`guild_id = '${guildId}'`);
|
.select({
|
||||||
const cond = `WHERE ${conditions.join(" AND ")}`;
|
id: pgMessagesTable.id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
const result = await db.execute(
|
channel_id: pgMessagesTable.channel_id,
|
||||||
sql.raw(
|
content: pgMessagesTable.content,
|
||||||
`SELECT username, content, channel_id, ai_status, created_at
|
ai_status: pgMessagesTable.ai_status,
|
||||||
FROM messages ${cond}
|
ai_severity: pgMessagesTable.ai_severity,
|
||||||
ORDER BY created_at DESC
|
ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
|
||||||
LIMIT ${limit}`,
|
ai_analysis: pgMessagesTable.ai_analysis,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
scopeMessages(guildId, channelId),
|
||||||
|
eq(pgMessagesTable.ai_status, "flagged"),
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
return JSON.stringify((result as unknown as { rows: unknown[] }).rows ?? []);
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchMessages(
|
||||||
|
query: string,
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 5,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!query.trim()) return JSON.stringify({ error: "query kosong" });
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessagesTable.id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
content: pgMessagesTable.content,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
ai_status: pgMessagesTable.ai_status,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
scopeMessages(guildId, channelId),
|
||||||
|
like(pgMessagesTable.content, `%${likePattern(query)}%`),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userMessages(
|
||||||
|
userId?: string,
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
||||||
|
const conds = [eq(pgMessagesTable.user_id, userId)];
|
||||||
|
if (guildId) conds.push(eq(pgMessagesTable.guild_id, guildId));
|
||||||
|
if (channelId) conds.push(eq(pgMessagesTable.channel_id, channelId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessagesTable.id,
|
||||||
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
content: pgMessagesTable.content,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
ai_status: pgMessagesTable.ai_status,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(and(...conds))
|
||||||
|
.orderBy(desc(pgMessagesTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userProfile(userId?: string, guildId?: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
||||||
|
const conds = [eq(pgUserProfilesTable.user_id, userId)];
|
||||||
|
if (guildId) conds.push(eq(pgUserProfilesTable.guild_id, guildId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
user_id: pgUserProfilesTable.user_id,
|
||||||
|
guild_id: pgUserProfilesTable.guild_id,
|
||||||
|
profile_summary: pgUserProfilesTable.profile_summary,
|
||||||
|
last_analyzed_at: pgUserProfilesTable.last_analyzed_at,
|
||||||
|
})
|
||||||
|
.from(pgUserProfilesTable)
|
||||||
|
.where(and(...conds))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "profil tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userReputation(
|
||||||
|
userId?: string,
|
||||||
|
guildId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!userId) return JSON.stringify({ error: "userId wajib" });
|
||||||
|
const conds = [eq(pgUserReputationsTable.user_id, userId)];
|
||||||
|
if (guildId) conds.push(eq(pgUserReputationsTable.guild_id, guildId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
user_id: pgUserReputationsTable.user_id,
|
||||||
|
guild_id: pgUserReputationsTable.guild_id,
|
||||||
|
trust_score: pgUserReputationsTable.trust_score,
|
||||||
|
clean_message_streak: pgUserReputationsTable.clean_message_streak,
|
||||||
|
total_infractions: pgUserReputationsTable.total_infractions,
|
||||||
|
last_infraction_at: pgUserReputationsTable.last_infraction_at,
|
||||||
|
})
|
||||||
|
.from(pgUserReputationsTable)
|
||||||
|
.where(and(...conds))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "reputasi tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function channelCulture(channelId?: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!channelId) return JSON.stringify({ error: "channelId wajib" });
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
channel_id: pgChannelCulturesTable.channel_id,
|
||||||
|
culture_summary: pgChannelCulturesTable.culture_summary,
|
||||||
|
last_analyzed_at: pgChannelCulturesTable.last_analyzed_at,
|
||||||
|
})
|
||||||
|
.from(pgChannelCulturesTable)
|
||||||
|
.where(eq(pgChannelCulturesTable.channel_id, channelId))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "culture tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function messageDetail(messageId?: string): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
if (!messageId) return JSON.stringify({ error: "messageId wajib" });
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessagesTable.id,
|
||||||
|
guild_id: pgMessagesTable.guild_id,
|
||||||
|
channel_id: pgMessagesTable.channel_id,
|
||||||
|
user_id: pgMessagesTable.user_id,
|
||||||
|
username: pgMessagesTable.username,
|
||||||
|
content: pgMessagesTable.content,
|
||||||
|
created_at: pgMessagesTable.created_at,
|
||||||
|
ai_status: pgMessagesTable.ai_status,
|
||||||
|
ai_moderation_flags: pgMessagesTable.ai_moderation_flags,
|
||||||
|
ai_moderation_score: pgMessagesTable.ai_moderation_score,
|
||||||
|
ai_severity: pgMessagesTable.ai_severity,
|
||||||
|
ai_categories: pgMessagesTable.ai_categories,
|
||||||
|
ai_analysis: pgMessagesTable.ai_analysis,
|
||||||
|
ai_recommended_action: pgMessagesTable.ai_recommended_action,
|
||||||
|
ai_confidence: pgMessagesTable.ai_confidence,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(eq(pgMessagesTable.id, messageId))
|
||||||
|
.limit(1);
|
||||||
|
return JSON.stringify(rows[0] ?? { error: "pesan tidak ditemukan" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function messageReviews(
|
||||||
|
guildId?: string,
|
||||||
|
status?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conds = [];
|
||||||
|
if (guildId) conds.push(eq(pgMessageReviewsTable.guild_id, guildId));
|
||||||
|
if (status) conds.push(eq(pgMessageReviewsTable.status, status as never));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgMessageReviewsTable.id,
|
||||||
|
message_id: pgMessageReviewsTable.message_id,
|
||||||
|
reviewer_id: pgMessageReviewsTable.reviewer_id,
|
||||||
|
status: pgMessageReviewsTable.status,
|
||||||
|
notes: pgMessageReviewsTable.notes,
|
||||||
|
created_at: pgMessageReviewsTable.created_at,
|
||||||
|
reviewed_at: pgMessageReviewsTable.reviewed_at,
|
||||||
|
})
|
||||||
|
.from(pgMessageReviewsTable)
|
||||||
|
.where(conds.length ? and(...conds) : undefined)
|
||||||
|
.orderBy(desc(pgMessageReviewsTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function voiceRecordings(
|
||||||
|
userId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
guildId?: string,
|
||||||
|
limit = 10,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const conds = [];
|
||||||
|
if (userId) conds.push(eq(pgVoiceRecordingsTable.user_id, userId));
|
||||||
|
if (channelId) conds.push(eq(pgVoiceRecordingsTable.channel_id, channelId));
|
||||||
|
if (guildId) conds.push(eq(pgVoiceRecordingsTable.guild_id, guildId));
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgVoiceRecordingsTable.id,
|
||||||
|
username: pgVoiceRecordingsTable.username,
|
||||||
|
channel_name: pgVoiceRecordingsTable.channel_name,
|
||||||
|
filename: pgVoiceRecordingsTable.filename,
|
||||||
|
size_bytes: pgVoiceRecordingsTable.size_bytes,
|
||||||
|
upload_status: pgVoiceRecordingsTable.upload_status,
|
||||||
|
transcription: pgVoiceRecordingsTable.transcription,
|
||||||
|
created_at: pgVoiceRecordingsTable.created_at,
|
||||||
|
})
|
||||||
|
.from(pgVoiceRecordingsTable)
|
||||||
|
.where(conds.length ? and(...conds) : undefined)
|
||||||
|
.orderBy(desc(pgVoiceRecordingsTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moderationTimeline(
|
||||||
|
guildId?: string,
|
||||||
|
channelId?: string,
|
||||||
|
days = 14,
|
||||||
|
): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const day = sql<string>`to_char(to_timestamp(${pgMessagesTable.created_at} / 1000), 'YYYY-MM-DD')`;
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
day,
|
||||||
|
total: sql<number>`COUNT(*)::int`,
|
||||||
|
flagged: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'flagged')::int`,
|
||||||
|
warned: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'warn')::int`,
|
||||||
|
clean: sql<number>`COUNT(*) FILTER (WHERE ${pgMessagesTable.ai_status} = 'clean')::int`,
|
||||||
|
})
|
||||||
|
.from(pgMessagesTable)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
scopeMessages(guildId, channelId),
|
||||||
|
// only the last N days
|
||||||
|
sql`${pgMessagesTable.created_at} >= extract(epoch FROM now() - (${days} || ' days')::interval) * 1000`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.groupBy(day)
|
||||||
|
.orderBy(day);
|
||||||
|
return JSON.stringify(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function corrections(guildId?: string, limit = 10): Promise<string> {
|
||||||
|
const db = getDatabase();
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: pgCorrectedModerationsTable.id,
|
||||||
|
message_id: pgCorrectedModerationsTable.message_id,
|
||||||
|
original_flags: pgCorrectedModerationsTable.original_flags,
|
||||||
|
corrected_flags: pgCorrectedModerationsTable.corrected_flags,
|
||||||
|
correction_notes: pgCorrectedModerationsTable.correction_notes,
|
||||||
|
content_snippet: pgCorrectedModerationsTable.content_snippet,
|
||||||
|
created_at: pgCorrectedModerationsTable.created_at,
|
||||||
|
})
|
||||||
|
.from(pgCorrectedModerationsTable)
|
||||||
|
.orderBy(desc(pgCorrectedModerationsTable.created_at))
|
||||||
|
.limit(limit);
|
||||||
|
return JSON.stringify(rows);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { tools } from "../src/modules/chatbot/chatbot.toolDefs.js";
|
||||||
|
|
||||||
|
const names = tools.map((t) => t.function.name);
|
||||||
|
|
||||||
|
describe("chatbot tool definitions", () => {
|
||||||
|
it("exposes a stable, non-empty tool set", () => {
|
||||||
|
expect(tools.length).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(new Set(names).size).toBe(names.length); // no dup names
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every tool declares a name, description, and object parameters", () => {
|
||||||
|
for (const t of tools) {
|
||||||
|
expect(t.type).toBe("function");
|
||||||
|
expect(typeof t.function.name).toBe("string");
|
||||||
|
expect(t.function.description.length).toBeGreaterThan(10);
|
||||||
|
expect(t.function.parameters.type).toBe("object");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("required-only tools declare required args", () => {
|
||||||
|
const byName = new Map(tools.map((t) => [t.function.name, t]));
|
||||||
|
for (const [name, required] of [
|
||||||
|
["search_messages", "query"],
|
||||||
|
["get_user_messages", "userId"],
|
||||||
|
["get_user_profile", "userId"],
|
||||||
|
["get_user_reputation", "userId"],
|
||||||
|
["get_channel_culture", "channelId"],
|
||||||
|
["get_message_detail", "messageId"],
|
||||||
|
] as const) {
|
||||||
|
const tool = byName.get(name);
|
||||||
|
expect(tool, `missing tool ${name}`).toBeDefined();
|
||||||
|
expect(tool!.function.parameters.required).toContain(required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("covers the core server-watcher situations", () => {
|
||||||
|
for (const required of [
|
||||||
|
"get_server_stats",
|
||||||
|
"get_top_channels",
|
||||||
|
"get_recent_activity",
|
||||||
|
"get_top_flagged",
|
||||||
|
"search_messages",
|
||||||
|
"get_user_messages",
|
||||||
|
"get_user_profile",
|
||||||
|
"get_user_reputation",
|
||||||
|
"get_channel_culture",
|
||||||
|
"get_message_detail",
|
||||||
|
"get_message_reviews",
|
||||||
|
"get_voice_recordings",
|
||||||
|
"get_moderation_timeline",
|
||||||
|
"get_corrections",
|
||||||
|
]) {
|
||||||
|
expect(names, `missing ${required}`).toContain(required);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,7 +47,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
title: "Pesan bersih dengan slang",
|
title: "Pesan bersih dengan slang",
|
||||||
input: "[target] id=12345 user=budi: anjay wkwk gaskeun santuy bro",
|
input: "[target] id=12345 user=budi: anjay wkwk gaskeun santuy bro",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"12345","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Slang Indonesia umum, tanpa pelanggaran."}]}',
|
'{"results":[{"message_id":"12345","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Slang Indonesia umum, tanpa pelanggaran."}]}',
|
||||||
modes: ["text", "mixed"],
|
modes: ["text", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=67890 user=anon: lu goblok banget sih kontol, mampus aja lo",
|
"[target] id=67890 user=anon: lu goblok banget sih kontol, mampus aja lo",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"67890","status":"flagged","flags":["harassment","vulgar_language"],"score":0.85,"severity":"high","confidence":0.9,"recommended_action":"delete","evidence":["lu goblok banget sih kontol"],"analysis":"Insult langsung dengan kata kasar terarah ke individu."}]}',
|
'{"results":[{"message_id":"67890","status":"flagged","flags":["harassment","vulgar_language"],"severity":"high","evidence":["lu goblok banget sih kontol"],"analysis":"Insult langsung dengan kata kasar terarah ke individu."}]}',
|
||||||
modes: ["text", "mixed"],
|
modes: ["text", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -64,7 +64,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
title: "Emoji Huruf (Evasion)",
|
title: "Emoji Huruf (Evasion)",
|
||||||
input: "[target] id=16161 user=sneaky: gsap expo 🇬 🇦 🇾",
|
input: "[target] id=16161 user=sneaky: gsap expo 🇬 🇦 🇾",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"16161","status":"flagged","flags":["sexual_deviation"],"score":0.8,"severity":"medium","confidence":0.95,"recommended_action":"delete","evidence":["🇬 🇦 🇾"],"analysis":"Regional indicator mengeja kata terlarang — evasi untuk topik yang dibatasi server."}]}',
|
'{"results":[{"message_id":"16161","status":"flagged","flags":["sexual_deviation"],"severity":"medium","evidence":["🇬 🇦 🇾"],"analysis":"Regional indicator mengeja kata terlarang — evasi untuk topik yang dibatasi server."}]}',
|
||||||
modes: ["text", "mixed"],
|
modes: ["text", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,7 +72,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
title: "Typo QWERTY Programming (False Positive Prevention)",
|
title: "Typo QWERTY Programming (False Positive Prevention)",
|
||||||
input: "[target] id=17171 user=dian432: Apakah bisa ngodonf disitu?",
|
input: "[target] id=17171 user=dian432: Apakah bisa ngodonf disitu?",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"17171","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Kata \'ngodonf\' typo QWERTY natural (f-g, o-i) dari \'ngoding\', bukan obfuscation. Diskusi teknis wajar."}]}',
|
'{"results":[{"message_id":"17171","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Kata \'ngodonf\' typo QWERTY natural (f-g, o-i) dari \'ngoding\', bukan obfuscation. Diskusi teknis wajar."}]}',
|
||||||
modes: ["text", "mixed"],
|
modes: ["text", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,7 +81,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=17172 user=dev: TypeError: Cannot read properties of undefined (reading 'map'). Kalo ada yang tau solusinya tolong bantu",
|
"[target] id=17172 user=dev: TypeError: Cannot read properties of undefined (reading 'map'). Kalo ada yang tau solusinya tolong bantu",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"17172","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Diskusi teknis normal tentang error programming, bukan pelanggaran."}]}',
|
'{"results":[{"message_id":"17172","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Diskusi teknis normal tentang error programming, bukan pelanggaran."}]}',
|
||||||
modes: ["text", "mixed"],
|
modes: ["text", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,19 +90,9 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=17173 user=dev: Guys pake Cursor lebih enak daripada VSCode? Gw pake Claude buat bantuin debugging",
|
"[target] id=17173 user=dev: Guys pake Cursor lebih enak daripada VSCode? Gw pake Claude buat bantuin debugging",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"17173","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Diskusi tools programming biasa (Cursor, VSCode, Claude), tidak ada pelanggaran."}]}',
|
'{"results":[{"message_id":"17173","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Diskusi tools programming biasa (Cursor, VSCode, Claude), tidak ada pelanggaran."}]}',
|
||||||
modes: ["text", "mixed"],
|
modes: ["text", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "19",
|
|
||||||
title: "Diskusi orientasi seksual LGBT (dilarang — zero tolerance)",
|
|
||||||
input:
|
|
||||||
"[target] id=17174 user=alex: btw gw gay, semoga ga masalah ya. Gw cmn mau jujur aja",
|
|
||||||
output:
|
|
||||||
'{"results":[{"message_id":"17174","status":"flagged","flags":["sexual_deviation"],"score":0.85,"severity":"high","confidence":0.95,"recommended_action":"delete","evidence":["btw gw gay, semoga ga masalah ya"],"analysis":"Pengakuan orientasi LGBT — server zero tolerance terhadap segala diskusi/coming out. Dihapus."}]}',
|
|
||||||
modes: ["text", "mixed"],
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Mixed examples ──
|
// ── Mixed examples ──
|
||||||
{
|
{
|
||||||
id: "3",
|
id: "3",
|
||||||
@@ -110,7 +100,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
'[target] id=11111 user=citra: <:singa_injek:123456> [sticker: "Singa injek pejabat"]',
|
'[target] id=11111 user=citra: <:singa_injek:123456> [sticker: "Singa injek pejabat"]',
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"11111","status":"clean","flags":[],"score":0.1,"severity":"none","confidence":0.8,"recommended_action":"none","evidence":[],"analysis":"Sticker kartun satir dengan nama provokatif, bukan ancaman nyata."}]}',
|
'{"results":[{"message_id":"11111","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Sticker kartun satir dengan nama provokatif, bukan ancaman nyata."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,7 +109,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=88888 user=sexist: dasar perempuan ngerti apa sih, logika lo aja kagak bener",
|
"[target] id=88888 user=sexist: dasar perempuan ngerti apa sih, logika lo aja kagak bener",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"88888","status":"flagged","flags":["hate_speech","harassment"],"score":0.82,"severity":"high","confidence":0.9,"recommended_action":"delete","evidence":["dasar perempuan ngerti apa sih"],"analysis":"Komentar seksis merendahkan yang menyasar gender — stereotip ofensif terarah."}]}',
|
'{"results":[{"message_id":"88888","status":"flagged","flags":["hate_speech","harassment"],"severity":"high","evidence":["dasar perempuan ngerti apa sih"],"analysis":"Komentar seksis merendahkan yang menyasar gender — stereotip ofensif terarah."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -128,7 +118,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=99999 user=drama: si budi kemarin ngomongin lo di belakang, masa tega banget dia, ayo kita konfrontasi di sini aja",
|
"[target] id=99999 user=drama: si budi kemarin ngomongin lo di belakang, masa tega banget dia, ayo kita konfrontasi di sini aja",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"99999","status":"warn","flags":["conflict_instigation"],"score":0.65,"severity":"low","confidence":0.75,"recommended_action":"warn","evidence":["ayo kita konfrontasi di sini aja"],"analysis":"Mengajak konfrontasi masalah personal di channel publik, berpotensi memicu drama."}]}',
|
'{"results":[{"message_id":"99999","status":"warn","flags":["conflict_instigation"],"severity":"low","evidence":["ayo kita konfrontasi di sini aja"],"analysis":"Mengajak konfrontasi masalah personal di channel publik, berpotensi memicu drama."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -137,7 +127,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=10101 user=fox: mau liat foto pake kostum hewan? DM aja, khusus 18+",
|
"[target] id=10101 user=fox: mau liat foto pake kostum hewan? DM aja, khusus 18+",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"10101","status":"flagged","flags":["sexual_deviation"],"score":0.85,"severity":"high","confidence":0.9,"recommended_action":"delete","evidence":["mau liat foto pake kostum hewan? DM aja, khusus 18+"],"analysis":"Ajakan aktivitas seksual eksplisit \'DM khusus 18+\'. Melanggar kebijakan."}]}',
|
'{"results":[{"message_id":"10101","status":"flagged","flags":["sexual_deviation"],"severity":"high","evidence":["mau liat foto pake kostum hewan? DM aja, khusus 18+"],"analysis":"Ajakan aktivitas seksual eksplisit \'DM khusus 18+\'. Melanggar kebijakan."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,7 +136,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=10505 user=dev: ERROR: Cannot read properties of undefined (reading 'data'). Stack trace: at Module._compile (node:internal/modules/cjs/loader:1256:14)",
|
"[target] id=10505 user=dev: ERROR: Cannot read properties of undefined (reading 'data'). Stack trace: at Module._compile (node:internal/modules/cjs/loader:1256:14)",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"10505","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Error log programming biasa antara developer, aman."}]}',
|
'{"results":[{"message_id":"10505","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Error log programming biasa antara developer, aman."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -155,7 +145,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=12121 user=pejabat_munafik_dajjal: Halo teman-teman, ada yang main game?",
|
"[target] id=12121 user=pejabat_munafik_dajjal: Halo teman-teman, ada yang main game?",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"12121","status":"flagged","flags":["offensive_username"],"score":0.3,"severity":"low","confidence":0.95,"recommended_action":"warn","evidence":["Username \'pejabat_munafik_dajjal\' mengandung unsur ofensif/SARA"],"analysis":"Username ofensif menyerang pejabat dengan label SARA, tapi isi pesan bersih — flag ringan."}]}',
|
'{"results":[{"message_id":"12121","status":"flagged","flags":["offensive_username"],"severity":"low","evidence":["Username \'pejabat_munafik_dajjal\' mengandung unsur ofensif/SARA"],"analysis":"Username ofensif menyerang pejabat dengan label SARA, tapi isi pesan bersih — flag ringan."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -164,7 +154,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=13131 user=nazi_babi_itu: bener tuh nih ras emang harus dibasmi",
|
"[target] id=13131 user=nazi_babi_itu: bener tuh nih ras emang harus dibasmi",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"13131","status":"flagged","flags":["offensive_username","hate_speech","sara"],"score":0.9,"severity":"high","confidence":0.95,"recommended_action":"delete","evidence":["bener tuh nih ras emang harus dibasmi"],"analysis":"Username SARA dan isi pesan memperkuat kebencian dengan ajakan kekerasan. Pelanggaran berat."}]}',
|
'{"results":[{"message_id":"13131","status":"flagged","flags":["offensive_username","hate_speech","sara"],"severity":"high","evidence":["bener tuh nih ras emang harus dibasmi"],"analysis":"Username SARA dan isi pesan memperkuat kebencian dengan ajakan kekerasan. Pelanggaran berat."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -173,7 +163,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
'[target] id=14141 user=hater: ++++++K1[[ your $€/F" "~`| \\\\0ve $ 1F ¥°U |}iE ®©',
|
'[target] id=14141 user=hater: ++++++K1[[ your $€/F" "~`| \\\\0ve $ 1F ¥°U |}iE ®©',
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"14141","status":"flagged","flags":["harassment","hate_speech"],"score":0.95,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["++++++K1[[ your $€/F"],"analysis":"Obfuscation simbol menyembunyikan frasa ancaman \'Kill yourself\'. Ancaman berat yang disamarkan."}]}',
|
'{"results":[{"message_id":"14141","status":"flagged","flags":["harassment","hate_speech"],"severity":"critical","evidence":["++++++K1[[ your $€/F"],"analysis":"Obfuscation simbol menyembunyikan frasa ancaman \'Kill yourself\'. Ancaman berat yang disamarkan."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -181,7 +171,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
title: "Vulgaritas Bahasa Asing / All-Caps",
|
title: "Vulgaritas Bahasa Asing / All-Caps",
|
||||||
input: "[target] id=15151 user=troll: AKU RAJA TITTEN",
|
input: "[target] id=15151 user=troll: AKU RAJA TITTEN",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"15151","status":"flagged","flags":["vulgar_language"],"score":0.85,"severity":"medium","confidence":0.9,"recommended_action":"delete","evidence":["AKU RAJA TITTEN"],"analysis":"Kata vulgar bahasa asing (\'titten\') dengan huruf kapital — melanggar meski format seperti candaan."}]}',
|
'{"results":[{"message_id":"15151","status":"flagged","flags":["vulgar_language"],"severity":"medium","evidence":["AKU RAJA TITTEN"],"analysis":"Kata vulgar bahasa asing (\'titten\') dengan huruf kapital — melanggar meski format seperti candaan."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -190,7 +180,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
'[target] id=20201 user=parodist: Kitabonia 11:17 — "Dan bersabdalah Sang Admin: barang siapa yang melakukan spam, niscaya akan kena mute tujuh hari tujuh malam"',
|
'[target] id=20201 user=parodist: Kitabonia 11:17 — "Dan bersabdalah Sang Admin: barang siapa yang melakukan spam, niscaya akan kena mute tujuh hari tujuh malam"',
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"20201","status":"flagged","flags":["sara"],"score":0.92,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["Kitabonia 11:17"],"analysis":"Ayat palsu dengan format kitab suci memparodikan wahyu — penistaan agama, bukan humor."}]}',
|
'{"results":[{"message_id":"20201","status":"flagged","flags":["sara"],"severity":"critical","evidence":["Kitabonia 11:17"],"analysis":"Ayat palsu dengan format kitab suci memparodikan wahyu — penistaan agama, bukan humor."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -199,7 +189,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=22223 user=edgy: Shirkmaxxing grindset, nanti halalmaxxing juga",
|
"[target] id=22223 user=edgy: Shirkmaxxing grindset, nanti halalmaxxing juga",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"22223","status":"flagged","flags":["sara"],"score":0.88,"severity":"high","confidence":0.95,"recommended_action":"delete","evidence":["Shirkmaxxing grindset"],"analysis":"Istilah suci agama (shirk, halal) sebagai bahan candaan meme — penistaan konsep teologis."}]}',
|
'{"results":[{"message_id":"22223","status":"flagged","flags":["sara"],"severity":"high","evidence":["Shirkmaxxing grindset"],"analysis":"Istilah suci agama (shirk, halal) sebagai bahan candaan meme — penistaan konsep teologis."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -207,7 +197,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
title: "Ekspresi keagamaan normal (AMAN, BUKAN SARA)",
|
title: "Ekspresi keagamaan normal (AMAN, BUKAN SARA)",
|
||||||
input: "[target] id=27278 user=muslim_user: Astaghfirullah, sabar ya bro",
|
input: "[target] id=27278 user=muslim_user: Astaghfirullah, sabar ya bro",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"27278","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Istighfar untuk menenangkan teman — ekspresi keagamaan wajar Indonesia, bukan penistaan."}]}',
|
'{"results":[{"message_id":"27278","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Istighfar untuk menenangkan teman — ekspresi keagamaan wajar Indonesia, bukan penistaan."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -218,7 +208,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=22222 user=rina: Aku suka nasgor loh [Media analysis for message 22222] [gambar di atas adalah attachment foto.jpg dari pesan id=22222]: Gambar menampilkan tangkapan layar aplikasi chat dengan teks percakapan biasa. Tidak ada konten melanggar terlihat. Aman.",
|
"[target] id=22222 user=rina: Aku suka nasgor loh [Media analysis for message 22222] [gambar di atas adalah attachment foto.jpg dari pesan id=22222]: Gambar menampilkan tangkapan layar aplikasi chat dengan teks percakapan biasa. Tidak ada konten melanggar terlihat. Aman.",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"22222","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Percakapan sehari-hari tentang makanan; gambar screenshot chat biasa tanpa pelanggaran."}]}',
|
'{"results":[{"message_id":"22222","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Percakapan sehari-hari tentang makanan; gambar screenshot chat biasa tanpa pelanggaran."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -227,7 +217,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
'[target] id=33333 user=spammer: MAIN DI SINI GACOR PARAH https://judionline.xyz [Media analysis for message 33333] [gambar di atas adalah attachment slot.jpg dari pesan id=33333]: Gambar menampilkan antarmuka situs judi online dengan mesin slot, chip, dan tombol deposit. Terlihat logo "JudiOnline" dan odds taruhan.',
|
'[target] id=33333 user=spammer: MAIN DI SINI GACOR PARAH https://judionline.xyz [Media analysis for message 33333] [gambar di atas adalah attachment slot.jpg dari pesan id=33333]: Gambar menampilkan antarmuka situs judi online dengan mesin slot, chip, dan tombol deposit. Terlihat logo "JudiOnline" dan odds taruhan.',
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"33333","status":"flagged","flags":["gambling"],"score":0.92,"severity":"high","confidence":0.92,"recommended_action":"delete","evidence":["MAIN DI SINI GACOR PARAH","https://judionline.xyz"],"analysis":"Promosi situs judi dengan link, teks promosi, dan gambar antarmuka judi yang jelas."}]}',
|
'{"results":[{"message_id":"33333","status":"flagged","flags":["gambling"],"severity":"high","evidence":["MAIN DI SINI GACOR PARAH","https://judionline.xyz"],"analysis":"Promosi situs judi dengan link, teks promosi, dan gambar antarmuka judi yang jelas."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -236,7 +226,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=44444 user=dev: [Media analysis for message 44444] [gambar di atas adalah attachment screenshot.png dari pesan id=44444]: Screenshot terminal Linux dengan background hitam dan teks hijau. Terlihat output command 'ls -la' dan 'git status'. Tidak ada teks atau elemen mencurigakan.",
|
"[target] id=44444 user=dev: [Media analysis for message 44444] [gambar di atas adalah attachment screenshot.png dari pesan id=44444]: Screenshot terminal Linux dengan background hitam dan teks hijau. Terlihat output command 'ls -la' dan 'git status'. Tidak ada teks atau elemen mencurigakan.",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"44444","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Screenshot terminal Linux (ls -la, git status) — aktivitas coding biasa, tidak ada pelanggaran."}]}',
|
'{"results":[{"message_id":"44444","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Screenshot terminal Linux (ls -la, git status) — aktivitas coding biasa, tidak ada pelanggaran."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -245,7 +235,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
'[target] id=55555 user=promotor: [Media analysis for message 55555] [gambar di atas adalah attachment promo.jpg dari pesan id=55555]: Screenshot website dengan background merah dan emas. Terlihat teks "DEPOSIT NOW", "BONUS 100%", "SLOT GACOR", chip poker, dan roda roulette. Ada tombol "DAFTAR" dan "LOGIN".',
|
'[target] id=55555 user=promotor: [Media analysis for message 55555] [gambar di atas adalah attachment promo.jpg dari pesan id=55555]: Screenshot website dengan background merah dan emas. Terlihat teks "DEPOSIT NOW", "BONUS 100%", "SLOT GACOR", chip poker, dan roda roulette. Ada tombol "DAFTAR" dan "LOGIN".',
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"55555","status":"flagged","flags":["gambling"],"score":0.94,"severity":"high","confidence":0.94,"recommended_action":"delete","evidence":["Gambar antarmuka situs judi dengan chip, roulette, tombol deposit"],"analysis":"Gambar promosi judi jelas: chip, roulette, teks deposit dan bonus. Melanggar gambling."}]}',
|
'{"results":[{"message_id":"55555","status":"flagged","flags":["gambling"],"severity":"high","evidence":["Gambar antarmuka situs judi dengan chip, roulette, tombol deposit"],"analysis":"Gambar promosi judi jelas: chip, roulette, teks deposit dan bonus. Melanggar gambling."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -254,7 +244,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=21212 user=troll: ayo bikin agama, aku tuhannya, yg mau jadi malaikat DM aku",
|
"[target] id=21212 user=troll: ayo bikin agama, aku tuhannya, yg mau jadi malaikat DM aku",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"21212","status":"flagged","flags":["sara"],"score":0.95,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["ayo bikin agama, aku tuhannya"],"analysis":"Mengajak membuat agama palsu dan mengaku Tuhan — penistaan agama serius, bukan candaan."}]}',
|
'{"results":[{"message_id":"21212","status":"flagged","flags":["sara"],"severity":"critical","evidence":["ayo bikin agama, aku tuhannya"],"analysis":"Mengajak membuat agama palsu dan mengaku Tuhan — penistaan agama serius, bukan candaan."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -263,7 +253,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=23234 user=edgelord: Hashem is watching you jerk off lol",
|
"[target] id=23234 user=edgelord: Hashem is watching you jerk off lol",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"23234","status":"flagged","flags":["sara"],"score":0.94,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["Hashem is watching you jerk off lol"],"analysis":"Nama suci (Hashem) dalam konteks vulgar merendahkan — blasphemy serius."}]}',
|
'{"results":[{"message_id":"23234","status":"flagged","flags":["sara"],"severity":"critical","evidence":["Hashem is watching you jerk off lol"],"analysis":"Nama suci (Hashem) dalam konteks vulgar merendahkan — blasphemy serius."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -272,7 +262,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=24245 user=provokator: nabi palsu ba'al, sembah aja patung",
|
"[target] id=24245 user=provokator: nabi palsu ba'al, sembah aja patung",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"24245","status":"flagged","flags":["sara","hate_speech"],"score":0.9,"severity":"high","confidence":0.95,"recommended_action":"delete","evidence":["nabi palsu ba\'al"],"analysis":"Sindiran konsep nabi dengan referensi Ba\'al + ajakan menyembah patung — penistaan monoteis."}]}',
|
'{"results":[{"message_id":"24245","status":"flagged","flags":["sara","hate_speech"],"severity":"high","evidence":["nabi palsu ba\'al"],"analysis":"Sindiran konsep nabi dengan referensi Ba\'al + ajakan menyembah patung — penistaan monoteis."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -281,7 +271,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=25256 user=sindiran: Bodoh admin-admin kita itu. Mereka tidak minta petunjuk dari Tuhan",
|
"[target] id=25256 user=sindiran: Bodoh admin-admin kita itu. Mereka tidak minta petunjuk dari Tuhan",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"25256","status":"flagged","flags":["sara","hate_speech"],"score":0.82,"severity":"high","confidence":0.9,"recommended_action":"delete","evidence":["tidak minta petunjuk dari Tuhan"],"analysis":"Framework agama (\'petunjuk dari Tuhan\') dipakai untuk mengejek — parodi merendahkan."}]}',
|
'{"results":[{"message_id":"25256","status":"flagged","flags":["sara","hate_speech"],"severity":"high","evidence":["tidak minta petunjuk dari Tuhan"],"analysis":"Framework agama (\'petunjuk dari Tuhan\') dipakai untuk mengejek — parodi merendahkan."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -290,7 +280,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=26267 user=provokator: Islam/Kristen itu agama sesat semua, cuma agama gue yg bener",
|
"[target] id=26267 user=provokator: Islam/Kristen itu agama sesat semua, cuma agama gue yg bener",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"26267","status":"flagged","flags":["sara","hate_speech","conflict_instigation"],"score":0.95,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["Islam/Kristen itu agama sesat semua"],"analysis":"Menyerang agama lain sebagai \'sesat\' dengan truth claim provokatif — provokasi antar-agama serius."}]}',
|
'{"results":[{"message_id":"26267","status":"flagged","flags":["sara","hate_speech","conflict_instigation"],"severity":"critical","evidence":["Islam/Kristen itu agama sesat semua"],"analysis":"Menyerang agama lain sebagai \'sesat\' dengan truth claim provokatif — provokasi antar-agama serius."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -299,7 +289,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=28289 user=thinker: Menurutku konsep trinitas dalam Kristen menarik dibandingkan tauhid dalam Islam. Apa pendapat kalian?",
|
"[target] id=28289 user=thinker: Menurutku konsep trinitas dalam Kristen menarik dibandingkan tauhid dalam Islam. Apa pendapat kalian?",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"28289","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.9,"recommended_action":"none","evidence":[],"analysis":"Perbandingan teologis dengan bahasa sopan dan tone diskusi — bukan provokasi. Aman."}]}',
|
'{"results":[{"message_id":"28289","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Perbandingan teologis dengan bahasa sopan dan tone diskusi — bukan provokasi. Aman."}]}',
|
||||||
modes: ["media", "mixed"],
|
modes: ["media", "mixed"],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -310,7 +300,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=29292 user=promotor: Join sini bro https://discord.gg/xyzk123 diskusi coding seru",
|
"[target] id=29292 user=promotor: Join sini bro https://discord.gg/xyzk123 diskusi coding seru",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"29292","status":"warn","flags":["spam"],"score":0.55,"severity":"low","confidence":0.7,"recommended_action":"warn","evidence":["https://discord.gg/xyzk123"],"analysis":"Promosi server lain via invite di channel publik tanpa izin — potensi spam."}]}',
|
'{"results":[{"message_id":"29292","status":"warn","flags":["spam"],"severity":"low","evidence":["https://discord.gg/xyzk123"],"analysis":"Promosi server lain via invite di channel publik tanpa izin — potensi spam."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -321,7 +311,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=30303 user=rainbow: jujur aja gua lesbian, terima apa adanya",
|
"[target] id=30303 user=rainbow: jujur aja gua lesbian, terima apa adanya",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"30303","status":"flagged","flags":["sexual_deviation"],"score":0.9,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["jujur aja gua lesbian"],"analysis":"Pengakuan orientasi lesbian — zero tolerance LGBT di server ini. Dihapus."}]}',
|
'{"results":[{"message_id":"30303","status":"flagged","flags":["sexual_deviation"],"severity":"critical","evidence":["jujur aja gua lesbian"],"analysis":"Pengakuan orientasi lesbian — zero tolerance LGBT di server ini. Dihapus."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -332,7 +322,7 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=31313 user=hot_takes: gw sih dukung palestina, israel biadab banget",
|
"[target] id=31313 user=hot_takes: gw sih dukung palestina, israel biadab banget",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"31313","status":"flagged","flags":["conflict_instigation","sara"],"score":0.95,"severity":"critical","confidence":0.95,"recommended_action":"delete","evidence":["gw sih dukung palestina"],"analysis":"Segala diskusi Israel/Palestina/Yahudi dilarang total — tidak ada debat, dukungan, atau berita. Dihapus."}]}',
|
'{"results":[{"message_id":"31313","status":"flagged","flags":["conflict_instigation","sara"],"severity":"critical","evidence":["gw sih dukung palestina"],"analysis":"Segala diskusi Israel/Palestina/Yahudi dilarang total — tidak ada debat, dukungan, atau berita. Dihapus."}]}',
|
||||||
modes: ["text", "media", "mixed"],
|
modes: ["text", "media", "mixed"],
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -343,18 +333,10 @@ export const ALL_EXAMPLES: ExampleDef[] = [
|
|||||||
input:
|
input:
|
||||||
"[target] id=32323 user=physics_student: Cukup cuman tubuh manusia vs gravitasi. Konsep energy conservation di sini penting buat analisis statis.",
|
"[target] id=32323 user=physics_student: Cukup cuman tubuh manusia vs gravitasi. Konsep energy conservation di sini penting buat analisis statis.",
|
||||||
output:
|
output:
|
||||||
'{"results":[{"message_id":"32323","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.95,"recommended_action":"none","evidence":[],"analysis":"Diskusi fisika teknis tentang kinetik dan gravitasi dalam konteks analisis statis – tidak ada ancaman atau konten melanggar. Penggunaan istilah fisika untuk perhitungan teknis adalah hal wajar."}]}',
|
'{"results":[{"message_id":"32323","status":"clean","flags":[],"severity":"none","evidence":[],"analysis":"Diskusi fisika teknis tentang kinetik dan gravitasi dalam konteks analisis statis – tidak ada ancaman atau konten melanggar. Penggunaan istilah fisika untuk perhitungan teknis adalah hal wajar."}]}',
|
||||||
modes: ["text", "mixed"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "33",
|
|
||||||
title: "Diskusi drone/senjata dalam konteks teknis (AMAN, bukan ancaman)",
|
|
||||||
input:
|
|
||||||
"[target] id=33333 user=engineer: Pengirim menyiratkan penggunaan energi kinetik dari jatuh (tubuh manusia vs gravitasi) sebagai metode untuk 'menetralisir' target dalam konteks diskusi senjata drone sebelumnya.",
|
|
||||||
output:
|
|
||||||
'{"results":[{"message_id":"33333","status":"clean","flags":[],"score":0.0,"severity":"none","confidence":0.9,"recommended_action":"none","evidence":[],"analysis":"Diskusi teknis tentang drone dan aplikasi fisika dalam konteks engineering – tidak ada ajuan aksi atau ancaman nyata. Penggunaan istilah senjata dalam konteks diskusi teori adalah hal wajar."}]}',
|
|
||||||
modes: ["text", "mixed"],
|
modes: ["text", "mixed"],
|
||||||
},
|
},
|
||||||
|
// ── Invite link / promosi server ──
|
||||||
];
|
];
|
||||||
|
|
||||||
// Derive per-mode strings from the single ALL_EXAMPLES array (zero duplication)
|
// Derive per-mode strings from the single ALL_EXAMPLES array (zero duplication)
|
||||||
|
|||||||
@@ -36,12 +36,10 @@ Instruksi per field:
|
|||||||
- "evidence": kutipan PERSIS frasa yang melanggar (maks 1 baris). Pelanggaran di gambar/sticker → kutip deskripsi Media analysis. Pelanggaran lewat balasan/referensi → sebut konteks pesan yang dibalas. Boleh tambah label sumber, mis. [media analysis] / [web_search] / [reply]. Kosong jika clean.
|
- "evidence": kutipan PERSIS frasa yang melanggar (maks 1 baris). Pelanggaran di gambar/sticker → kutip deskripsi Media analysis. Pelanggaran lewat balasan/referensi → sebut konteks pesan yang dibalas. Boleh tambah label sumber, mis. [media analysis] / [web_search] / [reply]. Kosong jika clean.
|
||||||
|
|
||||||
## PERSONALITY & MEMORI — Profil Pengguna dan Kultur Channel
|
## PERSONALITY & MEMORI — Profil Pengguna dan Kultur Channel
|
||||||
Data konteks tersedia: <user_profiles> (peta ringkasan kepribadian, di pesan USER), <user_reputation> (skor trust), dan <channel_culture> (topik/vibe channel). Setiap <message> dapat memuat <user_profile_ref user_id="..."/> yang menunjuk ke entri di peta <user_profiles>.
|
Data konteks tersedia: <user_profiles> (peta ringkasan kepribadian, di pesan USER), <user_reputation> (skor trust), dan <channel_culture> (topik/vibe channel). Setiap <message> dapat memuat <user_profile_ref user_id=".../> yang menunjuk ke entri di peta <user_profiles>.
|
||||||
Gunakan untuk personalisasi analysis, tapi:
|
Gunakan untuk personalisasi analysis, tapi:
|
||||||
- Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran.
|
- Profil/history adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran. <user_history> (kutipan pesan pernah di-flag) = cari POLA berulang (spam link SAMA, provokasi berulang konten SAMA); JANGAN gunakan untuk "menginterpretasi ulang" pesan bersih yang terpisah. Pesan baru tanpa pola pengulangan jelas → CLEAN.
|
||||||
- Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis.
|
- Perubahan perilaku mencolok (mis. teknis tiba-tiba provokatif) layak dicatat. JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik.
|
||||||
- <user_history> (kutipan pesan yang pernah di-flag) = pola pelanggaran lama. Gunakan untuk mendeteksi PENGULANGAN KEKONSISTEN (spam link yang SAMA, provokasi yang MENGULANG KONTEN NYATA YANG SAMA). JANGAN pernah gunakan history untuk "menginterpretasi ulang" pesan bersih yang TERPISAH DARI riwayat. Setiap pesan BARU dinilai TERSAMBUNG (standalone). Jika tidak ada pola pengulangan yang jelas → CLEAN. Contoh: Jika sebelumnya ada pesan dengan link scam.example.com yang di-flag, dan pesan baru jLine truncated
|
|
||||||
- JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik.
|
|
||||||
- Channel culture coding/teknis → pesan teknis lebih wajar; channel santai → slang lebih wajar. Jangan dipakai mengabaikan pelanggaran nyata.
|
- Channel culture coding/teknis → pesan teknis lebih wajar; channel santai → slang lebih wajar. Jangan dipakai mengabaikan pelanggaran nyata.
|
||||||
|
|
||||||
## FORMAT WAJIB — analysis HARUS deskriptif berdasarkan konten:
|
## FORMAT WAJIB — analysis HARUS deskriptif berdasarkan konten:
|
||||||
|
|||||||
@@ -103,36 +103,21 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
parts.push(
|
parts.push(
|
||||||
`## Blok Data di Pesan USER\n` +
|
`## Blok Data (di pesan USER)\n` +
|
||||||
`Semua data dinamis per-batch dikirim di pesan USER — system prompt ini TIDAK memuat data batch:\n` +
|
`System prompt ini TIDAK berisi data batch — semua data per-batch ada di pesan USER:\n` +
|
||||||
`- <location_context .../> = metadata channel/thread (channel_id, channel_name, thread_name, topic, nsfw, age_restricted). topic = deskripsi resmi channel — pakai untuk menilai kesesuaian pesan dengan tujuan channel.\n` +
|
`- <location_context .../>: metadata channel/thread (channel_name, thread_name, topic, nsfw, age_restricted). topic = tujuan resmi channel; gunakan menilai kesesuaian pesan.\n` +
|
||||||
`- <conversation_context> = obrolan SEBELUM pesan target. Baris "[context]" di dalamnya BUKAN yang dinilai.\n` +
|
`- <conversation_context>: obrolan SEBELUM target. Baris pertama "[conversation_flow] status=... context_msgs=... dropped=..." = metadata sistem (ongoing/sparse/cold_start), BUKAN pesan dinilai. Baris "[context] id=... time=... user=...: isi" = konteks, BUKAN target.\n` +
|
||||||
`- <user_profiles> = peta ringkasan kepribadian per user_id (attr as_of = kapan profil terakhir dibuat — profil lama mungkin tidak mencerminkan perilaku terkini); setiap <message> merujuk lewat <user_profile_ref user_id="..."/>.\n` +
|
`- <user_profiles>/<user_profile_ref>: peta ringkasan kepribadian per user_id (as_of = kapan dibuat; profil lama mungkin usang). <user_reputation trust_score total_infractions clean_streak last_offense_days_ago repeat_offender>: histori moderasi (repeat_offender=true = pelanggaran ≤7 hari). <user_history>: kutipan pesan pernah di-flag — cari POLA berulang (spam link sama), BUKAN bukti pesan bersih.\n` +
|
||||||
`- <web_searches> / <web_content> = bukti web (lihat "Web Sebagai Bukti Utama").\n` +
|
`- <web_searches>/<web_content>: bukti web (prioritas tertinggi). <term_glossary>: definisi kata/slang/jargon (SearXNG) — pakai pahami kata asing, JANGAN tebak arti.\n` +
|
||||||
`- <term_glossary> = kamus istilah: definisi kata/slang/jargon yang jarang dikenal (hasil pencarian Wikipedia via SearXNG). Gunakan untuk memahami arti kata yang tidak kamu kenal — JANGAN menebak atau mengarang arti.\n` +
|
`- <messages_to_analyze>: pesan TARGET yang WAJIB dinilai. Atribut <message>: id, user, time (ISO), repetitions (N = teks sama muncul N× di batch → sinyal spam), bot (true = bot), edited (true = hasil edit setelah posting → evasi potensial).`,
|
||||||
`- <messages_to_analyze> = pesan-pesan TARGET yang WAJIB dinilai. Atribut <message>: id, user (nama server), time (ISO — kapan pesan dikirim), repetitions (N = teks pendek sama muncul N kali di batch — sinyal spam), bot (true jika dari bot), edited (true jika konten adalah hasil edit setelah posting).`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
parts.push(
|
parts.push(
|
||||||
`## Konteks Pengguna (Referensi, Bukan Bukti)\n` +
|
`## Framing & Aturan Konteks\n` +
|
||||||
`Konteks per pengguna hanya indikator **referensi** untuk personalisasi analisis, BUKAN bukti pelanggaran:\n` +
|
`- Hasilkan SATU hasil per message_id — jangan gabung, lewati, atau karang id.\n` +
|
||||||
`- <user_reputation trust_score="..." total_infractions="..." clean_streak="..." last_offense_days_ago="..." repeat_offender="..."> = histori moderasi pengguna. Skor rendah BUKAN alasan memflag pesan bersih; skor tinggi BUKAN alasan mengabaikan pelanggaran nyata. repeat_offender="true" = ada pelanggaran dalam 7 hari terakhir.\n` +
|
`- Setiap target dinilai BERDASARKAN ISINYA SENDIRI. Konteks memengaruhi interpretasi, tapi TIDAK menggantikan isi pesan. Profil/riwayat = REFERENSI personalisasi, BUKAN bukti pelanggaran (lihat "PERSONALITY & MEMORI").\n` +
|
||||||
`- <user_history> (di dalam <user_reputation>) = kutipan pesan-pesan pengguna yang PERNAH di-flag. Gunakan untuk mengenali POLA berulang (spam link sama, provokasi), tapi JANGAN memflag pesan bersih hanya karena riwayat.\n` +
|
`- Marker "[pesan dipotong: terlalu panjang]" = TARGET dipotong; "[konteks dipotong: ...]" = konteks dipotong. Nilai dari bagian terlihat; pemotongan BUKAN pelanggaran/evasi.\n` +
|
||||||
`- <user_profiles> (di pesan USER) = peta ringkasan kepribadian per user_id. <user_profile_ref user_id="..."/> dalam sebuah pesan menunjuk ke peta itu. Tanpa ref = tidak ada profil untuk pengguna tersebut.\n` +
|
`- time= = kapan dikirim (rekonsiliasi spam beruntun / bump pesan lama). bot=true = otomatisasi, bukan pelanggaran personal.`,
|
||||||
`- Profil berguna untuk mengenali penyimpangan perilaku mencolok (mis. pengguna teknis tiba-tiba provokatif), tapi JANGAN memflag atau meloloskan hanya karena profil.\n` +
|
|
||||||
`**Setiap pesan dinilai berdasarkan isinya sendiri.**`,
|
|
||||||
);
|
|
||||||
|
|
||||||
parts.push(
|
|
||||||
`## Framing: Konteks vs Target\n` +
|
|
||||||
`- Baris dalam <conversation_context> berformat "[context] id=... time=<ISO> user=<nama>: isi", diurutkan paling lama → paling baru. Baris pertama biasanya "[conversation_flow] status=... context_msgs=... dropped=..." — metadata sistem tentang status percakapan (ongoing/sparse/cold_start), BUKAN pesan yang dinilai.\n` +
|
|
||||||
`- <messages_to_analyze> berisi pesan-pesan TARGET yang WAJIB dinilai. Hasilkan SATU hasil per message_id — jangan menggabungkan beberapa pesan, jangan melewati, jangan mengarang id.\n` +
|
|
||||||
`- Setiap target dinilai berdasarkan isinya sendiri; konteks percakapan memengaruhi interpretasi, bukan menggantikan isi pesan.\n` +
|
|
||||||
`- Marker "…[pesan dipotong: terlalu panjang]" = konten TARGET sengaja dipotong; marker "…[konteks dipotong: terlalu panjang]" = konten pesan KONTEKS dipotong. Nilai dari bagian yang terlihat; pemotongan BUKAN pelanggaran dan BUKAN teknik evasi.\n` +
|
|
||||||
`- Atribut time= pada <message> target = kapan pesan dikirim (ISO). Pakai untuk menilai kerelevanan waktu (mis. pesan lama di-bump, spam beruntun dalam menit yang sama).\n` +
|
|
||||||
`- repetitions="N" pada <message> = teks pendek yang sama muncul N kali dalam batch — pertimbangkan sebagai sinyal spam, tapi nilai tetap dari isi pesan.\n` +
|
|
||||||
`- bot="true" = pengirim adalah bot (otomatisasi), bukan pengguna manusia — jangan perlakukan sebagai pelanggaran personal, tapi kontennya tetap dinilai.\n` +
|
|
||||||
`- edited="true" = konten yang ditampilkan adalah hasil edit setelah posting (sinyal potensi evasi), nilai konten saat ini apa adanya.`,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
parts.push(OUTPUT_INSTRUCTIONS);
|
parts.push(OUTPUT_INSTRUCTIONS);
|
||||||
|
|||||||
@@ -195,6 +195,10 @@ export async function runTextOnlyBatch(
|
|||||||
? await getChannelCulture(channelId)
|
? await getChannelCulture(channelId)
|
||||||
: null;
|
: null;
|
||||||
const channelCulture = channelCultureObj?.culture_summary;
|
const channelCulture = channelCultureObj?.culture_summary;
|
||||||
|
// Corrected false-positive examples are static per batch — fetch ONCE
|
||||||
|
// here instead of inside the per-sub-batch retry closure (which would
|
||||||
|
// re-query the DB on every sub-batch and every parse-error retry).
|
||||||
|
const correctedExamples = await buildCorrectedFewShotExamples();
|
||||||
|
|
||||||
for (let i = 0; i < subBatches.length; i++) {
|
for (let i = 0; i < subBatches.length; i++) {
|
||||||
const batch = subBatches[i];
|
const batch = subBatches[i];
|
||||||
@@ -296,7 +300,6 @@ export async function runTextOnlyBatch(
|
|||||||
preview: state.lastInvalidContent?.slice(0, 800) ?? "<empty>",
|
preview: state.lastInvalidContent?.slice(0, 800) ?? "<empty>",
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
|
||||||
const systemText = buildSystemPromptModular({
|
const systemText = buildSystemPromptModular({
|
||||||
mode: batchHasImageEvidence ? "mixed" : "text",
|
mode: batchHasImageEvidence ? "mixed" : "text",
|
||||||
correction,
|
correction,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LayoutDashboard } from "lucide-react";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { isActivePath, navItems } from "@/lib/navigation";
|
import { isActivePath, navItems } from "@/lib/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -42,12 +41,6 @@ export function NavRail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="glass m-3 mr-0 flex w-[68px] flex-col items-center gap-1 rounded-[18px] py-4">
|
<nav className="glass m-3 mr-0 flex w-[68px] flex-col items-center gap-1 rounded-[18px] py-4">
|
||||||
<NavItem
|
|
||||||
href="/dashboard"
|
|
||||||
label="Dashboard"
|
|
||||||
active={isActivePath(path, "/dashboard")}
|
|
||||||
Icon={LayoutDashboard}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavItem
|
<NavItem
|
||||||
|
|||||||
Reference in new issue
Block a user