feat(ai-moderation): add user profile self-learning system

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 <user_profile> XML tag per-message in moderation prompt
- Start worker alongside cultureLearner in aiAnalyzer.ts
- Migration 0008 for user_profiles table

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-12 20:11:34 +07:00
co-authored by Claude
parent 6effb51b5d
commit fbc2184c6e
8 changed files with 270 additions and 2 deletions
@@ -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");
@@ -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
}
]
}
@@ -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) => {
@@ -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<string, string>();
const userProfiles = new Map<string, string>();
for (const msg of batch) {
if (!userContexts.has(msg.user_id)) {
const rep = await initializeUserReputation(msg.user_id, msg.guild_id);
const contextStr = `<user_reputation trust_score="${rep.trust_score}" />`;
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 ? `<user_profile>${profile.profile_summary}</user_profile>` : "",
);
}
}
const buildContent = async (state: RetryState): Promise<string> => {
@@ -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 `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}\n <content>${content}</content>${webContext}\n</message>`;
const profileLine = userProfileCtx ? `\n ${userProfileCtx}` : "";
return `<message id="${msg.id}" user="${msg.username}">\n ${userCtx}${profileLine}\n <content>${content}</content>${webContext}\n</message>`;
})
.join("\n");
@@ -1020,8 +1031,12 @@ async function prepareMediaMessage(
const rep = await initializeUserReputation(target.user_id, target.guild_id);
const userCtx = `<user_reputation trust_score="${rep.trust_score}" />`;
const profile = await getUserProfile(target.user_id);
const userProfileCtx = profile
? `\n <user_profile>${profile.profile_summary}</user_profile>`
: "";
const messageBlock = `<message id="${target.id}" user="${target.username}">\n ${userCtx}\n <content>${content}</content>${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n</message>`;
const messageBlock = `<message id="${target.id}" user="${target.username}">\n ${userCtx}${userProfileCtx}\n <content>${content}</content>${mediaContext ? ` ${mediaContext}` : ""}${webContext}${mediaAnalysisContext}\n</message>`;
return { targetId, messageBlock };
}
@@ -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 <user_reputation>. 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.**`,
);
@@ -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<void> {
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}":
<messages>
${messagesText}
</messages>
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<void> {
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");
}
@@ -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<UserProfile | null> {
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<void> {
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",
);
}
@@ -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;