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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user