From fbc2184c6eecd0c3a3509d220af473c3b8087f3d Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Fri, 12 Jun 2026 20:11:34 +0700 Subject: [PATCH] feat(ai-moderation): add user profile self-learning system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add user_profiles table, store, and background learner worker that summarizes user communication style, topics, and personality. - New user_profiles table (user_id PK, guild_id, profile_summary, last_analyzed_at) - userProfileStore.ts — CRUD (get/update) following channelCultureStore pattern - userProfileLearner.ts — background worker: queries 100 recent msgs per user, calls LLM for personality summary, updates every 12h - Inject XML tag per-message in moderation prompt - Start worker alongside cultureLearner in aiAnalyzer.ts - Migration 0008 for user_profiles table Co-Authored-By: Claude --- .../0008_add_user_profiles_table.sql | 8 + .../drizzle/migrations/meta/_journal.json | 7 + .../src/modules/ai-moderation/aiAnalyzer.ts | 3 + .../ai-moderation/llmModerationClient.ts | 19 ++- .../modules/ai-moderation/moderationPrompt.ts | 10 ++ .../ai-moderation/userProfileLearner.ts | 138 ++++++++++++++++++ .../modules/ai-moderation/userProfileStore.ts | 62 ++++++++ .../src/shared/database/schema.ts | 25 ++++ 8 files changed, 270 insertions(+), 2 deletions(-) create mode 100644 services/discord-gateway/drizzle/migrations/0008_add_user_profiles_table.sql create mode 100644 services/discord-gateway/src/modules/ai-moderation/userProfileLearner.ts create mode 100644 services/discord-gateway/src/modules/ai-moderation/userProfileStore.ts diff --git a/services/discord-gateway/drizzle/migrations/0008_add_user_profiles_table.sql b/services/discord-gateway/drizzle/migrations/0008_add_user_profiles_table.sql new file mode 100644 index 0000000..aa36ce1 --- /dev/null +++ b/services/discord-gateway/drizzle/migrations/0008_add_user_profiles_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS "user_profiles" ( + "user_id" text PRIMARY KEY NOT NULL, + "guild_id" text NOT NULL, + "profile_summary" text NOT NULL, + "last_analyzed_at" bigint NOT NULL +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_user_profiles_guild_id" ON "user_profiles" USING btree ("guild_id"); diff --git a/services/discord-gateway/drizzle/migrations/meta/_journal.json b/services/discord-gateway/drizzle/migrations/meta/_journal.json index 53b1641..202f499 100644 --- a/services/discord-gateway/drizzle/migrations/meta/_journal.json +++ b/services/discord-gateway/drizzle/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1781174400000, "tag": "0007_mascot_chat_table", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1781270000000, + "tag": "0008_add_user_profiles_table", + "breakpoints": true } ] } \ No newline at end of file diff --git a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts index 4c4878d..7192119 100644 --- a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts @@ -133,6 +133,9 @@ export function startPendingAIAnalysisWorker( import("./cultureLearner.js") .then((m) => m.startCultureLearnerWorker()) .catch(console.error); + import("./userProfileLearner.js") + .then((m) => m.startUserProfileLearnerWorker()) + .catch(console.error); setInterval(() => { revertStuckProcessingMessages(300000).catch((err: unknown) => { diff --git a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts index 37e25fb..e8f900e 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts @@ -44,6 +44,7 @@ import { } from "./textCacheStore.js"; import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js"; import { initializeUserReputation } from "./userReputationStore.js"; +import { getUserProfile } from "./userProfileStore.js"; export { sniffImageMimeType } from "./imageMimeSniffer.js"; export { extractJson } from "./jsonExtractor.js"; @@ -762,12 +763,20 @@ async function runTextOnlyBatch( // Abstract user reputation (no history — prevents confirmation bias) const userContexts = new Map(); + const userProfiles = new Map(); for (const msg of batch) { if (!userContexts.has(msg.user_id)) { const rep = await initializeUserReputation(msg.user_id, msg.guild_id); const contextStr = ``; userContexts.set(msg.user_id, contextStr); } + if (!userProfiles.has(msg.user_id)) { + const profile = await getUserProfile(msg.user_id); + userProfiles.set( + msg.user_id, + profile ? `${profile.profile_summary}` : "", + ); + } } const buildContent = async (state: RetryState): Promise => { @@ -804,9 +813,11 @@ async function runTextOnlyBatch( .join("\n"); const webContext = urlContexts ? `\n${urlContexts}` : ""; const userCtx = userContexts.get(msg.user_id) ?? ""; + const userProfileCtx = userProfiles.get(msg.user_id) ?? ""; // XML delimiters wrap each message for prompt safety (R1) - return `\n ${userCtx}\n ${content}${webContext}\n`; + const profileLine = userProfileCtx ? `\n ${userProfileCtx}` : ""; + return `\n ${userCtx}${profileLine}\n ${content}${webContext}\n`; }) .join("\n"); @@ -1020,8 +1031,12 @@ async function prepareMediaMessage( const rep = await initializeUserReputation(target.user_id, target.guild_id); const userCtx = ``; + const profile = await getUserProfile(target.user_id); + const userProfileCtx = profile + ? `\n ${profile.profile_summary}` + : ""; - const messageBlock = `\n ${userCtx}\n ${content}${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n`; + const messageBlock = `\n ${userCtx}${userProfileCtx}\n ${content}${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n`; return { targetId, messageBlock }; } diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts b/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts index a88afec..493de81 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationPrompt.ts @@ -605,6 +605,10 @@ export interface BuildSystemPromptOptions { * Formatted XML block containing the AI-generated channel culture summary. */ channelCulture?: string; + /** + * Formatted profile summary for the user being moderated. + */ + userProfile?: string; } export function buildSystemPrompt(options: BuildSystemPromptOptions): string { @@ -615,6 +619,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { correction, correctedExamples, channelCulture, + userProfile, } = options; // Backward compatibility: if mode is not set but includeMediaInstructions is, @@ -649,6 +654,11 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { parts.push(`## Kultur Channel (Pembelajaran AI)\n${channelCulture}`); } + // User Profile Injection (Learning) + if (userProfile) { + parts.push(`## Profil Pengirim (Pembelajaran AI)\n${userProfile}`); + } + parts.push( `## Konteks Pengguna\nSetiap pesan mungkin memiliki tag . Tag ini hanya indikator **referensi**, bukan bukti pelanggaran. Nilai trust_score yang rendah bukan alasan untuk memflag pesan yang bersih. Nilai trust_score yang tinggi bukan alasan untuk mengabaikan pelanggaran nyata. **Setiap pesan harus dinilai berdasarkan isinya sendiri.**`, ); diff --git a/services/discord-gateway/src/modules/ai-moderation/userProfileLearner.ts b/services/discord-gateway/src/modules/ai-moderation/userProfileLearner.ts new file mode 100644 index 0000000..72221f9 --- /dev/null +++ b/services/discord-gateway/src/modules/ai-moderation/userProfileLearner.ts @@ -0,0 +1,138 @@ +import { createChildLogger } from "@bete/shared/logger"; +import { and, desc, eq, sql } from "drizzle-orm"; +import { config } from "../../shared/config/config.js"; +import { getDatabase } from "../../shared/database/drizzle.js"; +import { + messagesTable, + userProfilesTable, +} from "../../shared/database/schema.js"; +import { updateUserProfile } from "./userProfileStore.js"; +import { llmChat } from "./llmClient.js"; + +const PROFILE_LEARNING_INTERVAL = 1000 * 60 * 60 * 12; // 12 hours +const log = createChildLogger("userProfileLearner"); + +async function learnUserProfile( + userId: string, + guildId: string, +): Promise { + const db = getDatabase(); + + // Get recent messages for this user + const recentMessages = await db + .select({ + content: messagesTable.content, + }) + .from(messagesTable) + .where( + and( + eq(messagesTable.user_id, userId), + eq(messagesTable.guild_id, guildId), + ), + ) + .orderBy(desc(messagesTable.created_at)) + .limit(100); + + if (recentMessages.length < 10) { + log.debug({ userId }, "Not enough messages to learn user profile"); + return; + } + + const messagesText = recentMessages + .reverse() + .map((m) => m.content) + .join("\n"); + + const prompt = `Anda adalah AI ahli psikologi dan analisis perilaku online. +Tugas Anda adalah merangkum profil kepribadian seorang pengguna berdasarkan +riwayat pesan-pesan mereka di server Discord. + +Pesan-pesan terakhir dari user "${userId}": + +${messagesText} + + +Berdasarkan pesan-pesan di atas, buatlah ringkasan singkat (maksimal 3 paragraf) +mengenai: +1. Gaya komunikasi (formal/casual/teknis/bercanda/serius) +2. Topik-topik yang sering dibahas +3. Kepribadian dan karakter yang terpancar +4. Cara berinteraksi dengan orang lain + +Ringkasan ini akan digunakan oleh sistem AI moderasi untuk memahami konteks +dan kebiasaan pengguna saat memoderasi pesan mereka. +Jangan menambahkan teks basa-basi, langsung berikan ringkasannya.`; + + try { + const completion = await llmChat({ + messages: [{ role: "user", content: prompt }], + max_tokens: 500, + temperature: 0.7, // Higher temp for summarization + retries: 2, + }); + + if (!completion) throw new Error("Empty response from LLM"); + const text = completion.choices[0]?.message?.content?.trim(); + if (!text) throw new Error("Empty response from LLM"); + + await updateUserProfile(userId, guildId, text); + log.info( + { userId, guildId }, + "Successfully learned and updated user profile", + ); + } catch (error) { + log.error({ userId, error }, "Failed to learn user profile"); + } +} + +export async function runUserProfileLearningCycle(): Promise { + const db = getDatabase(); + log.info("Starting user profile learning cycle"); + + try { + // Find users that haven't been profiled recently + // We query distinct user_id with enough messages and stale/no profile + const staleUsers = await db.execute(sql` + SELECT m.user_id, m.guild_id + FROM ( + SELECT user_id, guild_id, COUNT(*) as msg_count + FROM messages + GROUP BY user_id, guild_id + HAVING COUNT(*) >= 10 + ) m + LEFT JOIN user_profiles p ON m.user_id = p.user_id + WHERE p.last_analyzed_at IS NULL + OR p.last_analyzed_at < ${Date.now() - PROFILE_LEARNING_INTERVAL} + LIMIT 50 + `); + + for (const row of staleUsers.rows || staleUsers) { + const userId = String(row.user_id); + const guildId = String(row.guild_id); + await learnUserProfile(userId, guildId); + } + } catch (error) { + log.error({ error }, "Error in user profile learning cycle"); + } +} + +let profileInterval: NodeJS.Timeout | null = null; + +export function startUserProfileLearnerWorker(): void { + if (!config.AI_ANALYSIS_ENABLED) return; + if (profileInterval) return; + + // Run once on startup after 1 minute, then every 1 hour + setTimeout(() => { + runUserProfileLearningCycle().catch((e) => log.error(e)); + }, 60000); + + profileInterval = setInterval( + () => { + runUserProfileLearningCycle().catch((e) => log.error(e)); + }, + 1000 * 60 * 60, + ); // Check every hour for users that reached 12h expiry + + log.info("Started user profile learner worker"); +} diff --git a/services/discord-gateway/src/modules/ai-moderation/userProfileStore.ts b/services/discord-gateway/src/modules/ai-moderation/userProfileStore.ts new file mode 100644 index 0000000..dd68f24 --- /dev/null +++ b/services/discord-gateway/src/modules/ai-moderation/userProfileStore.ts @@ -0,0 +1,62 @@ +import { createChildLogger } from "@bete/shared/logger"; +import { eq } from "drizzle-orm"; +import { getDatabase } from "../../shared/database/drizzle.js"; +import { + UserProfile, + userProfilesTable, +} from "../../shared/database/schema.js"; + +const logger = createChildLogger("userProfileStore"); + +/** + * Fetch the AI-generated profile summary for a user. + */ +export async function getUserProfile( + userId: string, +): Promise { + const db = getDatabase(); + const existing = await db + .select() + .from(userProfilesTable) + .where(eq(userProfilesTable.user_id, userId)) + .limit(1); + + if (existing[0]) { + logger.debug({ userId }, "User profile lookup: found"); + } else { + logger.debug({ userId }, "User profile lookup: not found"); + } + return existing[0] || null; +} + +/** + * Update the AI-generated profile summary for a user. + */ +export async function updateUserProfile( + userId: string, + guildId: string, + profileSummary: string, +): Promise { + const db = getDatabase(); + + await db + .insert(userProfilesTable) + .values({ + user_id: userId, + guild_id: guildId, + profile_summary: profileSummary, + last_analyzed_at: Date.now(), + }) + .onConflictDoUpdate({ + target: userProfilesTable.user_id, + set: { + profile_summary: profileSummary, + last_analyzed_at: Date.now(), + }, + }); + + logger.debug( + { userId, guildId, profileSummary }, + "User profile updated", + ); +} diff --git a/services/discord-gateway/src/shared/database/schema.ts b/services/discord-gateway/src/shared/database/schema.ts index 4ad7984..525263c 100644 --- a/services/discord-gateway/src/shared/database/schema.ts +++ b/services/discord-gateway/src/shared/database/schema.ts @@ -365,6 +365,27 @@ export const pgCorrectedModerationsTable = pgTable( }), ); +/** + * User Profiles Table (PostgreSQL) + * Stores AI-generated summaries of user personality, communication style, + * and behavior patterns based on their message history. + * Injected as context for AI moderation like channel cultures. + */ +export const pgUserProfilesTable = pgTable( + "user_profiles", + { + user_id: pgText("user_id").primaryKey(), + guild_id: pgText("guild_id").notNull(), + profile_summary: pgText("profile_summary").notNull(), + last_analyzed_at: pgBigint("last_analyzed_at", { + mode: "number", + }).notNull(), + }, + (table) => ({ + guildIdx: pgIndex("idx_user_profiles_guild_id").on(table.guild_id), + }), +); + /** * Mascot Chat Messages Table (PostgreSQL) * Stores AI mascot chat conversation history @@ -406,6 +427,7 @@ export const stickerCacheTable = pgStickerCacheTable; export const correctedModerationsTable = pgCorrectedModerationsTable; export const userReputationsTable = pgUserReputationsTable; export const channelCulturesTable = pgChannelCulturesTable; +export const userProfilesTable = pgUserProfilesTable; export const mascotChatMessagesTable = pgMascotChatMessagesTable; // Export table types for use in queries @@ -449,6 +471,9 @@ export type UserReputationInsert = typeof userReputationsTable.$inferInsert; export type ChannelCulture = typeof channelCulturesTable.$inferSelect; export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert; +export type UserProfile = typeof userProfilesTable.$inferSelect; +export type UserProfileInsert = typeof userProfilesTable.$inferInsert; + export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect; export type MascotChatMessageInsert = typeof mascotChatMessagesTable.$inferInsert;