diff --git a/.gitignore b/.gitignore index c78d034..e0abdb5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ worktrees/ .worktrees/ services/frontend/frontend/dist/ target/ - +nix/ # Gitea CI runner logs .gitea/workflows/*.log diff --git a/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts b/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts index a78879f..9d400e9 100644 --- a/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts +++ b/services/discord-gateway/src/modules/ai-moderation/ai-analysis-worker.ts @@ -25,6 +25,7 @@ import { buildConversationContext, buildLocationContext, } from "./conversationContext.js"; +import { buildConversationContextBlock } from "./moderationBuilders.js"; import { runModerationAnalysis } from "./moderationOrchestrator.js"; const logger = createChildLogger("ai-analysis-worker"); @@ -280,13 +281,11 @@ async function processBatch(job: { maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS, gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS, }); - const contextText = [ - buildLocationContext(messages), - contextLines.descriptor, - ...contextLines.lines, - ] - .filter((l) => l.trim().length > 0) - .join("\n"); + const contextBlock = buildConversationContextBlock({ + location: buildLocationContext(messages), + descriptor: contextLines.descriptor, + lines: contextLines.lines, + }); const targetIds = messages.map((m) => m.id); const contextIds = contextBefore.map((m) => m.id); @@ -300,7 +299,7 @@ async function processBatch(job: { // when media is present), not N per-message calls. const moderationResult = await runModerationAnalysis({ targets: messages, - contextText, + contextBlock, attachments, }); @@ -373,13 +372,11 @@ async function processIndividual(job: { maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS, gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS, }); - const contextText = [ - buildLocationContext([message]), - contextLines.descriptor, - ...contextLines.lines, - ] - .filter((l) => l.trim().length > 0) - .join("\n"); + const contextBlock = buildConversationContextBlock({ + location: buildLocationContext([message]), + descriptor: contextLines.descriptor, + lines: contextLines.lines, + }); const contextIds = contextBefore.map((m) => m.id); const attachments = await messageStore.getAttachmentsForMessages([ @@ -390,7 +387,7 @@ async function processIndividual(job: { try { const moderationResult = await runModerationAnalysis({ targets: [message], - contextText, + contextBlock, attachments, }); diff --git a/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts b/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts index 35a474e..8841a95 100644 --- a/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts +++ b/services/discord-gateway/src/modules/ai-moderation/conversationContext.ts @@ -6,7 +6,7 @@ import { } from "../message-capture/messageMetadata.js"; import type { MessageRecord } from "../message-capture/types.js"; import { sanitizeDiscordTokens } from "./discordTokens.js"; -import { resolveDisplayName } from "./moderationBuilders.js"; +import { escapeXml, resolveDisplayName } from "./moderationBuilders.js"; const logger = createChildLogger("conversationContext"); @@ -124,8 +124,10 @@ export function formatMessageForPrompt( msg: MessageRecord, label: "context" | "target", ): string { - const content = sanitizeDiscordTokens( - renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata), + const content = truncateContextLine( + sanitizeDiscordTokens( + renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata), + ), ); const timestamp = formatTimestamp(msg.created_at); const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata); @@ -134,11 +136,26 @@ export function formatMessageForPrompt( return `[${label}] id=${msg.id} time=${timestamp} user=${resolveDisplayName(msg)}: ${content}${mediaSuffix}${refInfo}`; } +/** Max content chars per context line — a single huge paste (log dump, + * copypasta) must not eat the whole conversation budget. */ +const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500; + +/** Marker appended when a context line's content was cut. Distinct from the + * target-content marker so the model knows which side was truncated. */ +export const CONTEXT_TRUNC_MARKER = "…[konteks dipotong: terlalu panjang]"; + +/** Cap one context message's content to CONTEXT_LINE_CONTENT_MAX_CHARS. */ +export function truncateContextLine(content: string): string { + if (content.length <= CONTEXT_LINE_CONTENT_MAX_CHARS) return content; + return `${content.slice(0, CONTEXT_LINE_CONTENT_MAX_CHARS).trimEnd()}${CONTEXT_TRUNC_MARKER}`; +} + /** - * Builds a one-line `` source line for the batch — channel - * name, thread name and age-restriction flags from captured message metadata. - * The LLM uses it to judge messages in the right channel context (e.g. a - * thread about a specific topic, or an age-restricted channel). + * Builds a structured `` element for the batch — + * channel/thread name and age-restriction flags from captured message + * metadata. The LLM uses it to judge messages in the right channel context + * (e.g. a thread about a specific topic, or an age-restricted channel). + * Returns "" when no channel metadata was captured. */ export function buildLocationContext(targets: MessageRecord[]): string { const target = targets[0]; @@ -155,26 +172,20 @@ export function buildLocationContext(targets: MessageRecord[]): string { }; const ch = meta?.channel; if (!ch) return ""; - const parts: string[] = []; - parts.push( - `id=${target.channel_id}${ - ch.channelName ? ` name=${JSON.stringify(ch.channelName)}` : "" - }`, - ); + const attrs: string[] = [`channel_id="${escapeXml(target.channel_id)}"`]; + if (ch.channelName) + attrs.push(`channel_name="${escapeXml(ch.channelName)}"`); if (target.thread_id || ch.threadName) { - parts.push( - `thread=${target.thread_id}${ - ch.threadName ? ` thread_name=${JSON.stringify(ch.threadName)}` : "" - }`, - ); - } - if (typeof ch.nsfw === "boolean") { - parts.push(`nsfw=${ch.nsfw}`); + if (target.thread_id) + attrs.push(`thread_id="${escapeXml(target.thread_id)}"`); + if (ch.threadName) + attrs.push(`thread_name="${escapeXml(ch.threadName)}"`); } + if (typeof ch.nsfw === "boolean") attrs.push(`nsfw="${ch.nsfw}"`); if (typeof ch.ageRestricted === "boolean") { - parts.push(`age_restricted=${ch.ageRestricted}`); + attrs.push(`age_restricted="${ch.ageRestricted}"`); } - return `[location] ${parts.join(" ")}`; + return ``; } catch { return ""; } diff --git a/services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts b/services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts index 01043de..4ee5120 100644 --- a/services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts +++ b/services/discord-gateway/src/modules/ai-moderation/mediaBatchProcessor.ts @@ -16,8 +16,10 @@ import { getChannelCulture } from "./channelCultureStore.js"; import type { RetryState } from "./llmCaller.js"; import { callModerationLLM } from "./llmCaller.js"; import { prepareMediaMessage } from "./mediaAnalysisClient.js"; +import { buildUserProfilesBlock } from "./moderationBuilders.js"; import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js"; import { buildCorrectedFewShotExamples } from "./textBatchProcessor.js"; +import { getUserProfile } from "./userProfileStore.js"; const log = createChildLogger("mediaBatchProcessor"); @@ -26,7 +28,7 @@ const log = createChildLogger("mediaBatchProcessor"); // --------------------------------------------------------------------------- export async function runMediaBatch( targets: MessageRecord[], - contextText: string, + contextBlock: string, attachments: AttachmentRecord[] | undefined, ): Promise<{ results: AnalysisResult[]; raw: unknown }> { if (!targets.length) return { results: [], raw: null }; @@ -58,14 +60,32 @@ export async function runMediaBatch( const channelCulture = channelCultureObj?.culture_summary; const correctedExamples = await buildCorrectedFewShotExamples(); const systemText = buildSystemPromptModular({ - contextText, mode: "mixed", correctedExamples, channelCulture, }); + // Gather user profiles ONCE for the whole batch and emit a deduplicated + // map; per-message blocks (from prepareMediaMessage) + // reference it via . + const profileByUser = new Map(); + for (const t of targets) { + if (profileByUser.has(t.user_id)) continue; + const profile = await getUserProfile(t.user_id); + profileByUser.set(t.user_id, profile?.profile_summary ?? ""); + } + const userProfilesBlock = buildUserProfilesBlock(profileByUser); + const messagesBlock = prepared.map((p) => p.messageBlock).join("\n"); - const userContent = `\n${messagesBlock}\n`; + // Data/instruction separation: the system prompt is stable per mode — all + // per-batch context (profiles, conversation) lives in the USER payload, + // ordered oldest-first so targets come last. + const userBlocks = [ + userProfilesBlock?.trimEnd() ?? "", + contextBlock?.trimEnd() ?? "", + `\n${messagesBlock}\n`, + ].filter((b) => b.trim().length > 0); + const userContent = userBlocks.join("\n\n"); const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000; const batchTimeout = Math.min( diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts index 75f021b..85d51cb 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts @@ -9,6 +9,7 @@ import { renderDiscordMentions } from "../message-capture/messageMetadata.js"; import { messageStore } from "../message-capture/messageStore.js"; import type { MessageRecord } from "../message-capture/types.js"; import { sanitizeDiscordTokens } from "./discordTokens.js"; +import { sanitizeAiContent } from "./prompts/output.js"; /** Simple XML-escaping for content text. */ export function escapeXml(s: string): string { @@ -19,6 +20,101 @@ export function escapeXml(s: string): string { .replace(/"/g, """); } +// --------------------------------------------------------------------------- +// Conversation context block — structured data for the USER message. +// +// All per-batch context lives in the USER message (not the SYSTEM prompt) so +// the system prompt is stable per mode (cacheable on routers/providers) and +// the role boundary is clean: instructions in SYSTEM, data in USER. +// --------------------------------------------------------------------------- + +/** Outer char cap for the assembled `` inner text. */ +export const CONVERSATION_CONTEXT_MAX_CHARS = 40_000; + +/** + * Wraps per-batch context data into structured XML blocks for the USER + * message: + * + * + * + * [conversation_flow] status=ongoing context_msgs=12 dropped=0 + * [context] id=... time=... user=...: isi pesan + * ... + * + * + * Empty blocks are omitted entirely (never emit a hollow `` + * with no content). The inner text is AI/user-derived and passed through + * `sanitizeAiContent` (CDATA + XML-escape) to block prompt injection. + */ +export function buildConversationContextBlock(input: { + /** Pre-built `` string (or ""). */ + location?: string; + /** `[conversation_flow]` descriptor line from buildConversationContext. */ + descriptor?: string; + /** `[context]` lines, oldest → newest. */ + lines: string[]; +}): string { + const blocks: string[] = []; + const location = input.location?.trim(); + if (location) blocks.push(location); + + const inner = [input.descriptor ?? "", ...input.lines] + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .join("\n"); + if (inner) { + blocks.push( + `\n${sanitizeAiContent(inner, CONVERSATION_CONTEXT_MAX_CHARS)}\n`, + ); + } + return blocks.join("\n"); +} + +// --------------------------------------------------------------------------- +// Per-message content bounds — protects the LLM token budget from a single +// huge paste (stack traces, log dumps, copypasta). Truncation is explicit so +// the model never mistakes the cut for a real message boundary. +// --------------------------------------------------------------------------- + +/** Max characters of a message's content sent to the LLM `` payload. */ +export const AI_CONTENT_MAX_CHARS = 4000; + +/** Marker appended when a message is longer than AI_CONTENT_MAX_CHARS. */ +export const AI_CONTENT_TRUNC_MARKER = "\n…[pesan dipotong: terlalu panjang]"; + +/** Truncate a message's content for the LLM `` payload. */ +export function truncateForAi(content: string): string { + if (content.length <= AI_CONTENT_MAX_CHARS) return content; + return `${content.slice(0, AI_CONTENT_MAX_CHARS)}${AI_CONTENT_TRUNC_MARKER}`; +} + +// --------------------------------------------------------------------------- +// User profile deduplication — a batch can contain many messages from the +// same user. Instead of repeating the (up to 3000-char) profile summary on +// every message, emit a single map per batch and reference +// entries per message with . +// --------------------------------------------------------------------------- + +/** Build a deduplicated `` map block, keyed by Discord user id. */ +export function buildUserProfilesBlock( + profiles: ReadonlyMap, +): string { + const entries = Array.from(profiles.entries()).filter( + ([, text]) => text.trim().length > 0, + ); + if (entries.length === 0) return ""; + const lines = entries.map( + ([userId, text]) => + ` ${sanitizeAiContent(text)}`, + ); + return `\n${lines.join("\n")}\n`; +} + +/** Per-message reference tag pointing at an entry in the `` map. */ +export function buildUserProfileRef(userId: string): string { + return ``; +} + /** * Returns the real text content for AI analysis, stripping fallback text * that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]", diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts b/services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts index a8ffe5c..6e1c21e 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts @@ -35,7 +35,13 @@ const log = createChildLogger("moderationOrchestrator"); // --------------------------------------------------------------------------- export interface ModerationInput { targets: MessageRecord[]; - contextText: string; + /** + * Pre-built XML context block for the USER message (from + * `buildConversationContextBlock`): `` + + * `...`. Kept out of the + * system prompt so it stays stable/cacheable per mode. + */ + contextBlock: string; attachments?: AttachmentRecord[]; } @@ -62,7 +68,7 @@ export interface ModerationOutput { export async function runModerationAnalysis( input: ModerationInput, ): Promise { - const { targets, contextText, attachments } = input; + const { targets, contextBlock, attachments } = input; initSearxngCache(config.REDIS_URL); if (!targets.length) throw new Error("No targets provided for analysis"); @@ -320,10 +326,10 @@ export async function runModerationAnalysis( // Run both paths in parallel const [textBatchResult, mediaBatchResult] = await Promise.all([ textOnlyTargets.length > 0 - ? runTextOnlyBatch(textOnlyTargets, contextText) + ? runTextOnlyBatch(textOnlyTargets, contextBlock) : Promise.resolve({ results: [] as AnalysisResult[], raw: null }), mediaTargets.length > 0 - ? runMediaBatch(mediaTargets, contextText, attachments) + ? runMediaBatch(mediaTargets, contextBlock, attachments) : Promise.resolve({ results: [] as AnalysisResult[], raw: null }), ]); diff --git a/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts b/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts index 4df509e..1b02df6 100644 --- a/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts +++ b/services/discord-gateway/src/modules/ai-moderation/prompts/output.ts @@ -31,8 +31,12 @@ Struktur wajib: ] } +Instruksi per field: +- "message_id": WAJIB sama persis dengan id di input. Setiap di menghasilkan SATU hasil. Jangan gabungkan beberapa pesan, jangan lewati, jangan karang id. +- "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 -Data konteks tersedia: (ringkasan kepribadian pengguna) dan (topik/vibe channel). +Data konteks tersedia: (peta ringkasan kepribadian, di pesan USER), (skor trust), dan (topik/vibe channel). Setiap dapat memuat yang menunjuk ke entri di peta . Gunakan untuk personalisasi analysis, tapi: - Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran. - Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis. @@ -66,7 +70,7 @@ CRITICAL: - Jika pesan adalah BALASAN (reply) ke pesan lain, jelaskan konteks balasannya: apa yang sedang dibicarakan, siapa yang dibalas (tanpa nama, cukup peran/isi pesan yang dibalas), dan bagaimana tanggapan pengirim terhadapnya. - Gunakan informasi dari Media analysis untuk mendeskripsikan gambar. - Analisis harus MEMBERI KONTEKS, bukan hanya menyatakan status. -- GUNAKAN untuk personalisasi analysis — jadikan analysis terasa seperti sistem "mengenal" pengguna. +- GUNAKAN / untuk personalisasi analysis — jadikan analysis terasa seperti sistem "mengenal" pengguna. - Jika perilaku pesan menyimpang dari profil yang diketahui, CATAT dalam analysis sebagai informasi kontekstual yang relevan. - JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik dari yang dipaksakan.`; diff --git a/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts b/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts index fceaf87..9846304 100644 --- a/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts +++ b/services/discord-gateway/src/modules/ai-moderation/prompts/system.ts @@ -39,7 +39,6 @@ Gambar/sticker/embed/preview link sudah DIDESKRIPSIKAN vision model sebelum batc // --------------------------------------------------------------------------- export interface BuildSystemPromptOptions { - contextText: string; /** Prompt mode — determines which sections are included. */ mode: PromptMode; /** @deprecated Use `mode` instead. */ @@ -59,7 +58,6 @@ export interface BuildSystemPromptOptions { export function buildSystemPrompt(options: BuildSystemPromptOptions): string { const { - contextText, mode, includeMediaInstructions, correction, @@ -105,15 +103,34 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string { } 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.**`, + `## Blok Data di Pesan USER\n` + + `Semua data dinamis per-batch dikirim di pesan USER — system prompt ini TIDAK memuat data batch:\n` + + `- = metadata channel/thread (channel_id, channel_name, thread_name, nsfw, age_restricted).\n` + + `- = obrolan SEBELUM pesan target. Baris "[context]" di dalamnya BUKAN yang dinilai.\n` + + `- = peta ringkasan kepribadian per user_id; setiap merujuk lewat .\n` + + `- / = bukti web (lihat "Web Sebagai Bukti Utama").\n` + + `- = pesan-pesan TARGET yang WAJIB dinilai.`, + ); + + parts.push( + `## Konteks Pengguna (Referensi, Bukan Bukti)\n` + + `Konteks per pengguna hanya indikator **referensi** untuk personalisasi analisis, BUKAN bukti pelanggaran:\n` + + `- = histori moderasi pengguna. Skor rendah BUKAN alasan memflag pesan bersih; skor tinggi BUKAN alasan mengabaikan pelanggaran nyata.\n` + + `- (di pesan USER) = peta ringkasan kepribadian per user_id. dalam sebuah pesan menunjuk ke peta itu. Tanpa ref = tidak ada profil untuk pengguna tersebut.\n` + + `- 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 berformat "[context] id=... time= user=: 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` + + `- 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.`, ); parts.push(OUTPUT_INSTRUCTIONS); - // XML-delimited context — prevents prompt injection - const delimitedContext = `\n${sanitizeAiContent(contextText, 8000)}\n`; - parts.push(delimitedContext); - let base = parts.join("\n\n"); if (correction) { diff --git a/services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts b/services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts index 8814e73..fbb4524 100644 --- a/services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts +++ b/services/discord-gateway/src/modules/ai-moderation/textBatchProcessor.ts @@ -19,14 +19,14 @@ import { callModerationLLM } from "./llmCaller.js"; import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js"; import { buildReferenceXml, + buildUserProfileRef, + buildUserProfilesBlock, escapeXml, getAnalysisContent, resolveDisplayName, + truncateForAi, } from "./moderationBuilders.js"; -import { - buildSystemPrompt as buildSystemPromptModular, - sanitizeAiContent, -} from "./moderationPrompt.js"; +import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js"; import { logModerationAnalysis } from "./responseLogger.js"; import { extractSearchQueries, @@ -74,7 +74,7 @@ export async function buildCorrectedFewShotExamples(): Promise { // --------------------------------------------------------------------------- export async function runTextOnlyBatch( targets: MessageRecord[], - contextText: string, + contextBlock: string, ): Promise<{ results: AnalysisResult[]; raw: unknown }> { if (!targets.length) return { results: [], raw: null }; @@ -188,7 +188,8 @@ export async function runTextOnlyBatch( const batch = subBatches[i]; const targetIds = batch.map((t) => t.id); - // User reputation + profiles + // User reputation + profiles (raw summary text — deduplicated into a + // single map per batch; messages only reference it). const userContexts = new Map(); const userProfiles = new Map(); for (const msg of batch) { @@ -201,14 +202,10 @@ export async function runTextOnlyBatch( } if (!userProfiles.has(msg.user_id)) { const profile = await getUserProfile(msg.user_id); - userProfiles.set( - msg.user_id, - profile - ? `${sanitizeAiContent(profile.profile_summary)}` - : "", - ); + userProfiles.set(msg.user_id, profile?.profile_summary ?? ""); } } + const userProfilesBlock = buildUserProfilesBlock(userProfiles); // ── URL images → multimodal vision evidence ───────────────────────── // The text batch fetches inline URLs; whenever one resolved to an image @@ -280,7 +277,6 @@ export async function runTextOnlyBatch( : undefined; const correctedExamples = await buildCorrectedFewShotExamples(); const systemText = buildSystemPromptModular({ - contextText, mode: batchHasImageEvidence ? "mixed" : "text", correction, correctedExamples, @@ -290,7 +286,7 @@ export async function runTextOnlyBatch( const messagesBlock = ( await Promise.all( batch.map(async (msg) => { - const content = getAnalysisContent(msg); + const content = truncateForAi(getAnalysisContent(msg)); const msgUrls = extractUrlsFromText(content); const urlContexts = msgUrls .map((url) => { @@ -307,25 +303,36 @@ export async function runTextOnlyBatch( .map((line) => `\n${line}`) .join(""); const userCtx = userContexts.get(msg.user_id) ?? ""; - const userProfileCtx = userProfiles.get(msg.user_id) ?? ""; + const userProfileRef = (userProfiles.get(msg.user_id) ?? "").trim() + ? buildUserProfileRef(msg.user_id) + : ""; const refXml = await buildReferenceXml(msg); - return `\n ${userCtx}${userProfileCtx ? `\n ${userProfileCtx}` : ""}${refXml ? `\n ${refXml}` : ""}\n ${escapeXml(content)}${webContext}${mediaEvidenceCtx}\n`; + return `\n ${userCtx}${userProfileRef ? `\n ${userProfileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n ${escapeXml(content)}${webContext}${mediaEvidenceCtx}\n`; }), ) ).join("\n"); const searxngBlock = searxngResults.size > 0 - ? `\n\n\n${Array.from(searxngResults.entries()) + ? `\n${Array.from(searxngResults.entries()) .map( ([q, xml]) => ` \n${xml} `, ) .join("\n")}\n` : ""; + // Data/instruction separation: the system prompt is stable per mode — + // all per-batch context (profiles, conversation, web evidence) lives in + // the USER payload, ordered oldest-first so targets come last. + const userBlocks = [ + userProfilesBlock?.trimEnd() ?? "", + contextBlock?.trimEnd() ?? "", + searxngBlock, + `\n${messagesBlock}\n`, + ].filter((b) => b.trim().length > 0); return { system: systemText, - user: `${searxngBlock}\n\n\n${messagesBlock}\n`, + user: userBlocks.join("\n\n"), }; }; diff --git a/services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts b/services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts index 2c85277..080b0a4 100644 --- a/services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/visionAnalyzer.ts @@ -37,16 +37,17 @@ import { } from "./mediaDownloader.js"; import { buildReferenceXml, + buildUserProfileRef, escapeXml, getAnalysisContent, resolveDisplayName, + truncateForAi, } from "./moderationBuilders.js"; import { buildCustomEmojiVisionPrompt, buildGeneralImageVisionPrompt, buildStickerTextOnlyWarning, buildStickerVisionPrompt, - sanitizeAiContent, } from "./moderationPrompt.js"; import { extractSearchQueries, @@ -367,7 +368,14 @@ export async function prepareMediaMessage( const rep = await initializeUserReputation(target.user_id, target.guild_id); const profile = await getUserProfile(target.user_id); const refXml = await buildReferenceXml(target); + // Profile is emitted ONCE per batch in a map (see + // mediaBatchProcessor); here we only reference it to avoid repeating the + // full summary on every message of the same user. + const profileRef = + profile && profile.profile_summary?.trim() + ? buildUserProfileRef(target.user_id) + : ""; - const messageBlock = `\n ${profile ? `\n ${sanitizeAiContent(profile.profile_summary)}` : ""}${refXml ? `\n ${refXml}` : ""}\n ${escapeXml(content)}${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n`; + const messageBlock = `\n ${profileRef ? `\n ${profileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n ${escapeXml(truncateForAi(content))}${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n`; return { targetId, messageBlock }; } diff --git a/services/discord-gateway/tests/conversationContext.test.ts b/services/discord-gateway/tests/conversationContext.test.ts index da8af7a..5f9c7e0 100644 --- a/services/discord-gateway/tests/conversationContext.test.ts +++ b/services/discord-gateway/tests/conversationContext.test.ts @@ -6,7 +6,9 @@ import { buildConversationContext, buildLocationContext, formatMessageForPrompt, + truncateContextLine, } from "../src/modules/ai-moderation/conversationContext.js"; +import { buildConversationContextBlock } from "../src/modules/ai-moderation/moderationBuilders.js"; import { extractOgMeta } from "../src/modules/ai-moderation/urlFetcher.js"; import type { MessageRecord } from "../src/modules/message-capture/types.js"; @@ -162,10 +164,21 @@ describe("formatMessageForPrompt — server nickname (displayName)", () => { ); expect(line).toContain("user=user_n2"); }); + + it("truncates an oversized context message so one paste cannot eat the whole budget", () => { + const huge = "A".repeat(5000); + const line = formatMessageForPrompt(msg("n3", NOW - MIN, huge), "context"); + expect(line).toContain("…[konteks dipotong: terlalu panjang]"); + expect(line.length).toBeLessThan(2000); + }); + + it("keeps short context content intact", () => { + expect(truncateContextLine("pendek")).toBe("pendek"); + }); }); describe("buildLocationContext — channel/thread/nsfw enrichment", () => { - it("renders channel name + thread name from captured metadata", () => { + it("renders a structured element from captured metadata", () => { const t = target(); t.metadata = JSON.stringify({ channel: { @@ -176,10 +189,12 @@ describe("buildLocationContext — channel/thread/nsfw enrichment", () => { }, }); const line = buildLocationContext([t]); - expect(line).toContain("[location]"); - expect(line).toContain('name="general"'); + expect(line).toContain(" { @@ -187,6 +202,50 @@ describe("buildLocationContext — channel/thread/nsfw enrichment", () => { }); }); +describe("buildConversationContextBlock — structured USER-message context", () => { + it("wraps location + descriptor + lines into XML blocks", () => { + const block = buildConversationContextBlock({ + location: buildLocationContext( + (() => { + const t = target(); + t.metadata = JSON.stringify({ + channel: { channelName: "general", nsfw: false }, + }); + return [t]; + })(), + ), + descriptor: "[conversation_flow] status=ongoing context_msgs=1 dropped=0", + lines: ["[context] id=a time=2027-01-01T00:00:00.000Z user=user_a: hai"], + }); + expect(block).toContain(""); + expect(block).toContain("[conversation_flow] status=ongoing"); + expect(block).toContain("[context] id=a"); + // location block comes before conversation block + expect(block.indexOf(""), + ); + }); + + it("omits the conversation block when there are no lines", () => { + const block = buildConversationContextBlock({ + location: "", + descriptor: "", + lines: [], + }); + expect(block).toBe(""); + }); + + it("keeps only the location block when lines are empty but location exists", () => { + const block = buildConversationContextBlock({ + location: '', + descriptor: "", + lines: [], + }); + expect(block).toBe(''); + }); +}); + describe("extractOgMeta — page title/site for ", () => { it("extracts og:title, og:description and og:site_name", () => { const html = ` diff --git a/services/discord-gateway/tests/placeholder.test.ts b/services/discord-gateway/tests/placeholder.test.ts index a97e228..0df3f16 100644 --- a/services/discord-gateway/tests/placeholder.test.ts +++ b/services/discord-gateway/tests/placeholder.test.ts @@ -1,6 +1,8 @@ // ═══════════════════════════════════════════════════════════════════════════════ // 1. AppError Hierarchy // ═══════════════════════════════════════════════════════════════════════════════ + +import { afterEach, describe, expect, it, vi } from "vitest"; import { AppError, ConfigError, @@ -9,7 +11,6 @@ import { UnauthorizedError, ValidationError, } from "../src/shared/errors/index.js"; -import { afterEach, describe, expect, it, vi } from "vitest"; describe("AppError subclasses", () => { it("AppError carries code, statusCode, and details", () => {