Files
GMW/services/discord-gateway/src/modules/ai-moderation/cultureLearner.ts
T
asepharyana 6293d588bc chore(lint): biome cleanup across services — format, sort imports, drop unused
- discord-gateway: 74 lint errors -> 0 (format, import sorting, unused
  imports/vars, dead breath var)
- backend: format + sort imports (11 warnings left: noExplicitAny)
- frontend: remove unused imports, drop dead breathing var, fix
  useExhaustiveDependencies (scroll keyed on messages), a11y biome-ignore
  for drag surface + stopPropagation container (mouse-only gestures)
- remaining warnings are false positives: index keys on static lists,
  <img> in static export (next/image unsupported), noExplicitAny

tsc --noEmit clean on all 3 services; vitest green (60+36).
2026-08-01 22:09:02 +07:00

154 lines
6.0 KiB
TypeScript

import { and, desc, eq, sql } from "drizzle-orm";
import { createChildLogger } from "@/shared/logger/index";
import { config } from "../../shared/config/config.js";
import { getDatabase } from "../../shared/database/drizzle.js";
import { messagesTable } from "../../shared/database/schema.js";
import { updateChannelCulture } from "./channelCultureStore.js";
import { llmChat } from "./llmClient.js";
const CULTURE_LEARNING_INTERVAL = 1000 * 60 * 60 * 12; // 12 hours
const log = createChildLogger("cultureLearner");
async function learnChannelCulture(
channelId: string,
guildId: string,
): Promise<void> {
const db = getDatabase();
// Get recent clean messages for this channel
const recentMessages = await db
.select({
content: messagesTable.content,
username: messagesTable.username,
})
.from(messagesTable)
.where(
and(
eq(messagesTable.channel_id, channelId),
eq(messagesTable.ai_status, "clean"),
),
)
.orderBy(desc(messagesTable.created_at))
.limit(100);
if (recentMessages.length < 10) {
log.debug({ channelId }, "Not enough messages to learn culture");
return;
}
const messagesText = recentMessages
.reverse()
.map((m) => `${m.username}: ${m.content}`)
.join("\n");
const prompt = `Anda adalah AI ahli sosiologi digital dan pembaca budaya online.
Tugas Anda adalah merangkum KEPRIBADIAN SEBUAH CHANNEL — bagaimana "rasanya" berada di channel ini,
siapa saja anggotanya, dan norma tidak tertulis apa yang berlaku.
Pesan-pesan terakhir dari channel (hanya pesan bersih/clean):
<messages>
${messagesText}
</messages>
Berdasarkan pesan-pesan di atas, buatlah ringkasan singkat (maksimal 4 paragraf)
mengenai:
1. **"VIBE" channel** — Bagaimana suasana channel ini? Serius dan profesional? Santai dan penuh tawa?
Campuran? Apakah channel terasa hangat, sarkastik, chaos, atau tertib? Jelaskan "feel"-nya.
2. **Gaya bahasa dan inside jokes** — Bahasa apa yang dominan (Indonesia/Inggris/campuran)?
Apakah ada slang, meme, atau referensi yang khas di channel ini? Istilah-istilah teknis atau
inside joke yang sering muncul? Apakah orang-orang di sini saling sapa atau langsung ke topik?
3. **Topik utama & aktivitas** — Apa yang biasanya dibahas di channel ini? Coding problem? Ngobrol
santai? Sharing berita? Debat? Apakah ada ritual atau rutinitas (misal "pagi bro", "makan siang")?
4. **Dinamika sosial & norma tidak tertulis** — Siapa anggota yang paling aktif? Apakah ada tokoh
sentral atau "pengurus" informal? Apakah channel ini ramah ke pendatang baru atau lebih eksklusif?
Norma apa yang berlaku (misal: no politics, no NSFW, strict on-topic, atau bebas)?
**GAYA RINGKASAN**: Tulislah seperti seorang antropolog yang sedang mendeskripsikan sebuah desa
digital dengan bahasa Indonesia yang hidup dan natural. JANGAN gunakan format bullet point dalam
output — tulis dalam bentuk prosa paragraf yang mengalir. Jangan basa-basi, langsung berikan
ringkasannya.
Contoh gaya yang baik: "Channel ini adalah pusat komunitas developer dengan vibe santai dan
kekeluargaan. Bahasa dominan campuran Indonesia-Inggris dengan banyak istilah teknis React dan
Rust. Inside joke yang sering muncul adalah soal 'production bug' dan 'deploy Friday' — mereka
punya tradisi saling ledek kalau ada yang nge-deploy pas Jumat sore. Topik utama adalah debug
help, code review, dan kadang curhat karir. Channel sangat ramah ke pendatang baru — setiap
pertanyaan baru selalu direspons dengan antusias. Norma tidak tertulis: dilarang keras promosi
judi/NSFW, tapi selain itu bebas. Ada dua anggota senior yang jadi 'mom' channel—sabar jelasin
hal dasar ke newbie tanpa judge."`;
try {
const completion = await llmChat({
messages: [{ role: "user", content: prompt }],
max_tokens: 500,
temperature: 0.7, // Higher temp for summarization
retries: 2,
stream: true, // router always streams SSE; non-stream waits for full body and times out
});
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 updateChannelCulture(channelId, guildId, text);
log.info(
{ channelId, guildId },
"Successfully learned and updated channel culture",
);
} catch (error) {
log.error({ channelId, error }, "Failed to learn channel culture");
}
}
export async function runCultureLearningCycle(): Promise<void> {
const db = getDatabase();
log.info("Starting culture learning cycle");
try {
// Find channels that haven't been analyzed recently
// We do a simple distinct channel_id query with a left join to see if it's stale
const staleChannels = await db.execute(sql`
SELECT m.channel_id, m.guild_id
FROM (SELECT DISTINCT channel_id, guild_id FROM messages) m
LEFT JOIN channel_cultures c ON m.channel_id = c.channel_id
WHERE c.last_analyzed_at IS NULL
OR c.last_analyzed_at < ${Date.now() - CULTURE_LEARNING_INTERVAL}
LIMIT 50
`);
for (const row of staleChannels.rows || staleChannels) {
// Cast the row because execute() returns untyped Record<string, unknown>[]
const channelId = String(row.channel_id);
const guildId = String(row.guild_id);
await learnChannelCulture(channelId, guildId);
}
} catch (error) {
log.error({ error }, "Error in culture learning cycle");
}
}
let cultureInterval: NodeJS.Timeout | null = null;
export function startCultureLearnerWorker(): void {
if (!config.AI_ANALYSIS_ENABLED) return;
if (cultureInterval) return;
// Run once on startup after 1 minute, then every 1 hour
setTimeout(() => {
runCultureLearningCycle().catch((e) => log.error(e));
}, 60000);
cultureInterval = setInterval(
() => {
runCultureLearningCycle().catch((e) => log.error(e));
},
1000 * 60 * 60,
); // Check every hour for channels that reached 12h expiry
log.info("Started background culture learner worker");
}