feat(ai-moderation): enhance context handling with structured XML blocks and user profiles

This commit is contained in:
asepharyana
2026-08-10 16:46:55 +07:00
parent 4a51f3055c
commit 0a5254bf20
12 changed files with 307 additions and 81 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ worktrees/
.worktrees/ .worktrees/
services/frontend/frontend/dist/ services/frontend/frontend/dist/
target/ target/
nix/
# Gitea CI runner logs # Gitea CI runner logs
.gitea/workflows/*.log .gitea/workflows/*.log
@@ -25,6 +25,7 @@ import {
buildConversationContext, buildConversationContext,
buildLocationContext, buildLocationContext,
} from "./conversationContext.js"; } from "./conversationContext.js";
import { buildConversationContextBlock } from "./moderationBuilders.js";
import { runModerationAnalysis } from "./moderationOrchestrator.js"; import { runModerationAnalysis } from "./moderationOrchestrator.js";
const logger = createChildLogger("ai-analysis-worker"); const logger = createChildLogger("ai-analysis-worker");
@@ -280,13 +281,11 @@ async function processBatch(job: {
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS, maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS, gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
}); });
const contextText = [ const contextBlock = buildConversationContextBlock({
buildLocationContext(messages), location: buildLocationContext(messages),
contextLines.descriptor, descriptor: contextLines.descriptor,
...contextLines.lines, lines: contextLines.lines,
] });
.filter((l) => l.trim().length > 0)
.join("\n");
const targetIds = messages.map((m) => m.id); const targetIds = messages.map((m) => m.id);
const contextIds = contextBefore.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. // when media is present), not N per-message calls.
const moderationResult = await runModerationAnalysis({ const moderationResult = await runModerationAnalysis({
targets: messages, targets: messages,
contextText, contextBlock,
attachments, attachments,
}); });
@@ -373,13 +372,11 @@ async function processIndividual(job: {
maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS, maxAgeMs: config.AI_ANALYSIS_CONTEXT_MAX_AGE_MS,
gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS, gapMs: config.AI_ANALYSIS_CONTEXT_GAP_MS,
}); });
const contextText = [ const contextBlock = buildConversationContextBlock({
buildLocationContext([message]), location: buildLocationContext([message]),
contextLines.descriptor, descriptor: contextLines.descriptor,
...contextLines.lines, lines: contextLines.lines,
] });
.filter((l) => l.trim().length > 0)
.join("\n");
const contextIds = contextBefore.map((m) => m.id); const contextIds = contextBefore.map((m) => m.id);
const attachments = await messageStore.getAttachmentsForMessages([ const attachments = await messageStore.getAttachmentsForMessages([
@@ -390,7 +387,7 @@ async function processIndividual(job: {
try { try {
const moderationResult = await runModerationAnalysis({ const moderationResult = await runModerationAnalysis({
targets: [message], targets: [message],
contextText, contextBlock,
attachments, attachments,
}); });
@@ -6,7 +6,7 @@ import {
} from "../message-capture/messageMetadata.js"; } from "../message-capture/messageMetadata.js";
import type { MessageRecord } from "../message-capture/types.js"; import type { MessageRecord } from "../message-capture/types.js";
import { sanitizeDiscordTokens } from "./discordTokens.js"; import { sanitizeDiscordTokens } from "./discordTokens.js";
import { resolveDisplayName } from "./moderationBuilders.js"; import { escapeXml, resolveDisplayName } from "./moderationBuilders.js";
const logger = createChildLogger("conversationContext"); const logger = createChildLogger("conversationContext");
@@ -124,8 +124,10 @@ export function formatMessageForPrompt(
msg: MessageRecord, msg: MessageRecord,
label: "context" | "target", label: "context" | "target",
): string { ): string {
const content = sanitizeDiscordTokens( const content = truncateContextLine(
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata), sanitizeDiscordTokens(
renderDiscordMentions(msg.edited_content ?? msg.content, msg.metadata),
),
); );
const timestamp = formatTimestamp(msg.created_at); const timestamp = formatTimestamp(msg.created_at);
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata); 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}`; 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 `<location_context>` source line for the batch — channel * Builds a structured `<location_context .../>` element for the batch —
* name, thread name and age-restriction flags from captured message metadata. * channel/thread name and age-restriction flags from captured message
* The LLM uses it to judge messages in the right channel context (e.g. a * metadata. The LLM uses it to judge messages in the right channel context
* thread about a specific topic, or an age-restricted channel). * (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 { export function buildLocationContext(targets: MessageRecord[]): string {
const target = targets[0]; const target = targets[0];
@@ -155,26 +172,20 @@ export function buildLocationContext(targets: MessageRecord[]): string {
}; };
const ch = meta?.channel; const ch = meta?.channel;
if (!ch) return ""; if (!ch) return "";
const parts: string[] = []; const attrs: string[] = [`channel_id="${escapeXml(target.channel_id)}"`];
parts.push( if (ch.channelName)
`id=${target.channel_id}${ attrs.push(`channel_name="${escapeXml(ch.channelName)}"`);
ch.channelName ? ` name=${JSON.stringify(ch.channelName)}` : ""
}`,
);
if (target.thread_id || ch.threadName) { if (target.thread_id || ch.threadName) {
parts.push( if (target.thread_id)
`thread=${target.thread_id}${ attrs.push(`thread_id="${escapeXml(target.thread_id)}"`);
ch.threadName ? ` thread_name=${JSON.stringify(ch.threadName)}` : "" if (ch.threadName)
}`, attrs.push(`thread_name="${escapeXml(ch.threadName)}"`);
);
}
if (typeof ch.nsfw === "boolean") {
parts.push(`nsfw=${ch.nsfw}`);
} }
if (typeof ch.nsfw === "boolean") attrs.push(`nsfw="${ch.nsfw}"`);
if (typeof ch.ageRestricted === "boolean") { if (typeof ch.ageRestricted === "boolean") {
parts.push(`age_restricted=${ch.ageRestricted}`); attrs.push(`age_restricted="${ch.ageRestricted}"`);
} }
return `[location] ${parts.join(" ")}`; return `<location_context ${attrs.join(" ")}/>`;
} catch { } catch {
return ""; return "";
} }
@@ -16,8 +16,10 @@ import { getChannelCulture } from "./channelCultureStore.js";
import type { RetryState } from "./llmCaller.js"; import type { RetryState } from "./llmCaller.js";
import { callModerationLLM } from "./llmCaller.js"; import { callModerationLLM } from "./llmCaller.js";
import { prepareMediaMessage } from "./mediaAnalysisClient.js"; import { prepareMediaMessage } from "./mediaAnalysisClient.js";
import { buildUserProfilesBlock } from "./moderationBuilders.js";
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js"; import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
import { buildCorrectedFewShotExamples } from "./textBatchProcessor.js"; import { buildCorrectedFewShotExamples } from "./textBatchProcessor.js";
import { getUserProfile } from "./userProfileStore.js";
const log = createChildLogger("mediaBatchProcessor"); const log = createChildLogger("mediaBatchProcessor");
@@ -26,7 +28,7 @@ const log = createChildLogger("mediaBatchProcessor");
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function runMediaBatch( export async function runMediaBatch(
targets: MessageRecord[], targets: MessageRecord[],
contextText: string, contextBlock: string,
attachments: AttachmentRecord[] | undefined, attachments: AttachmentRecord[] | undefined,
): Promise<{ results: AnalysisResult[]; raw: unknown }> { ): Promise<{ results: AnalysisResult[]; raw: unknown }> {
if (!targets.length) return { results: [], raw: null }; if (!targets.length) return { results: [], raw: null };
@@ -58,14 +60,32 @@ export async function runMediaBatch(
const channelCulture = channelCultureObj?.culture_summary; const channelCulture = channelCultureObj?.culture_summary;
const correctedExamples = await buildCorrectedFewShotExamples(); const correctedExamples = await buildCorrectedFewShotExamples();
const systemText = buildSystemPromptModular({ const systemText = buildSystemPromptModular({
contextText,
mode: "mixed", mode: "mixed",
correctedExamples, correctedExamples,
channelCulture, channelCulture,
}); });
// Gather user profiles ONCE for the whole batch and emit a deduplicated
// <user_profiles> map; per-message blocks (from prepareMediaMessage)
// reference it via <user_profile_ref>.
const profileByUser = new Map<string, string>();
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 messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
const userContent = `<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`; // 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() ?? "",
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
].filter((b) => b.trim().length > 0);
const userContent = userBlocks.join("\n\n");
const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000; const perMsgTimeout = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
const batchTimeout = Math.min( const batchTimeout = Math.min(
@@ -9,6 +9,7 @@ import { renderDiscordMentions } from "../message-capture/messageMetadata.js";
import { messageStore } from "../message-capture/messageStore.js"; import { messageStore } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.js"; import type { MessageRecord } from "../message-capture/types.js";
import { sanitizeDiscordTokens } from "./discordTokens.js"; import { sanitizeDiscordTokens } from "./discordTokens.js";
import { sanitizeAiContent } from "./prompts/output.js";
/** Simple XML-escaping for content text. */ /** Simple XML-escaping for content text. */
export function escapeXml(s: string): string { export function escapeXml(s: string): string {
@@ -19,6 +20,101 @@ export function escapeXml(s: string): string {
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
// ---------------------------------------------------------------------------
// 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 `<conversation_context>` inner text. */
export const CONVERSATION_CONTEXT_MAX_CHARS = 40_000;
/**
* Wraps per-batch context data into structured XML blocks for the USER
* message:
*
* <location_context channel_id="..." channel_name="..." nsfw="..."/>
* <conversation_context>
* [conversation_flow] status=ongoing context_msgs=12 dropped=0
* [context] id=... time=... user=...: isi pesan
* ...
* </conversation_context>
*
* Empty blocks are omitted entirely (never emit a hollow `<conversation_context>`
* 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 `<location_context .../>` 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(
`<conversation_context>\n${sanitizeAiContent(inner, CONVERSATION_CONTEXT_MAX_CHARS)}\n</conversation_context>`,
);
}
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 `<content>` 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 `<content>` 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 <user_profiles> map per batch and reference
// entries per message with <user_profile_ref user_id="..."/>.
// ---------------------------------------------------------------------------
/** Build a deduplicated `<user_profiles>` map block, keyed by Discord user id. */
export function buildUserProfilesBlock(
profiles: ReadonlyMap<string, string>,
): string {
const entries = Array.from(profiles.entries()).filter(
([, text]) => text.trim().length > 0,
);
if (entries.length === 0) return "";
const lines = entries.map(
([userId, text]) =>
` <user_profile user_id="${escapeXml(userId)}">${sanitizeAiContent(text)}</user_profile>`,
);
return `<user_profiles>\n${lines.join("\n")}\n</user_profiles>`;
}
/** Per-message reference tag pointing at an entry in the `<user_profiles>` map. */
export function buildUserProfileRef(userId: string): string {
return `<user_profile_ref user_id="${escapeXml(userId)}"/>`;
}
/** /**
* Returns the real text content for AI analysis, stripping fallback text * Returns the real text content for AI analysis, stripping fallback text
* that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]", * that getDisplayContent() synthesized ("[Attachment: ...]", "[Sticker: ...]",
@@ -35,7 +35,13 @@ const log = createChildLogger("moderationOrchestrator");
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface ModerationInput { export interface ModerationInput {
targets: MessageRecord[]; targets: MessageRecord[];
contextText: string; /**
* Pre-built XML context block for the USER message (from
* `buildConversationContextBlock`): `<location_context .../>` +
* `<conversation_context>...</conversation_context>`. Kept out of the
* system prompt so it stays stable/cacheable per mode.
*/
contextBlock: string;
attachments?: AttachmentRecord[]; attachments?: AttachmentRecord[];
} }
@@ -62,7 +68,7 @@ export interface ModerationOutput {
export async function runModerationAnalysis( export async function runModerationAnalysis(
input: ModerationInput, input: ModerationInput,
): Promise<ModerationOutput> { ): Promise<ModerationOutput> {
const { targets, contextText, attachments } = input; const { targets, contextBlock, attachments } = input;
initSearxngCache(config.REDIS_URL); initSearxngCache(config.REDIS_URL);
if (!targets.length) throw new Error("No targets provided for analysis"); if (!targets.length) throw new Error("No targets provided for analysis");
@@ -320,10 +326,10 @@ export async function runModerationAnalysis(
// Run both paths in parallel // Run both paths in parallel
const [textBatchResult, mediaBatchResult] = await Promise.all([ const [textBatchResult, mediaBatchResult] = await Promise.all([
textOnlyTargets.length > 0 textOnlyTargets.length > 0
? runTextOnlyBatch(textOnlyTargets, contextText) ? runTextOnlyBatch(textOnlyTargets, contextBlock)
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }), : Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
mediaTargets.length > 0 mediaTargets.length > 0
? runMediaBatch(mediaTargets, contextText, attachments) ? runMediaBatch(mediaTargets, contextBlock, attachments)
: Promise.resolve({ results: [] as AnalysisResult[], raw: null }), : Promise.resolve({ results: [] as AnalysisResult[], raw: null }),
]); ]);
@@ -31,8 +31,12 @@ Struktur wajib:
] ]
} }
Instruksi per field:
- "message_id": WAJIB sama persis dengan id di input. Setiap <message> di <messages_to_analyze> 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 ## PERSONALITY & MEMORI — Profil Pengguna dan Kultur Channel
Data konteks tersedia: <user_profile> (ringkasan kepribadian pengguna) dan <channel_culture> (topik/vibe channel). Data konteks tersedia: <user_profiles> (peta ringkasan kepribadian, di pesan USER), <user_reputation> (skor trust), dan <channel_culture> (topik/vibe channel). Setiap <message> dapat memuat <user_profile_ref user_id="..."/> yang menunjuk ke entri di peta <user_profiles>.
Gunakan untuk personalisasi analysis, tapi: Gunakan untuk personalisasi analysis, tapi:
- Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran. - Profil adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran.
- Perubahan perilaku mencolok (biasanya teknis tiba-tiba provokatif) layak dicatat di analysis. - 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. - 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. - Gunakan informasi dari Media analysis untuk mendeskripsikan gambar.
- Analisis harus MEMBERI KONTEKS, bukan hanya menyatakan status. - Analisis harus MEMBERI KONTEKS, bukan hanya menyatakan status.
- GUNAKAN <user_profile> untuk personalisasi analysis — jadikan analysis terasa seperti sistem "mengenal" pengguna. - GUNAKAN <user_profile_ref>/<user_profiles> 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. - 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.`; - JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik dari yang dipaksakan.`;
@@ -39,7 +39,6 @@ Gambar/sticker/embed/preview link sudah DIDESKRIPSIKAN vision model sebelum batc
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export interface BuildSystemPromptOptions { export interface BuildSystemPromptOptions {
contextText: string;
/** Prompt mode — determines which sections are included. */ /** Prompt mode — determines which sections are included. */
mode: PromptMode; mode: PromptMode;
/** @deprecated Use `mode` instead. */ /** @deprecated Use `mode` instead. */
@@ -59,7 +58,6 @@ export interface BuildSystemPromptOptions {
export function buildSystemPrompt(options: BuildSystemPromptOptions): string { export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
const { const {
contextText,
mode, mode,
includeMediaInstructions, includeMediaInstructions,
correction, correction,
@@ -105,15 +103,34 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
} }
parts.push( 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.**`, `## Blok Data di Pesan USER\n` +
`Semua data dinamis per-batch dikirim di pesan USER — system prompt ini TIDAK memuat data batch:\n` +
`- <location_context .../> = metadata channel/thread (channel_id, channel_name, thread_name, nsfw, age_restricted).\n` +
`- <conversation_context> = obrolan SEBELUM pesan target. Baris "[context]" di dalamnya BUKAN yang dinilai.\n` +
`- <user_profiles> = peta ringkasan kepribadian per user_id; setiap <message> merujuk lewat <user_profile_ref user_id="..."/>.\n` +
`- <web_searches> / <web_content> = bukti web (lihat "Web Sebagai Bukti Utama").\n` +
`- <messages_to_analyze> = 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` +
`- <user_reputation trust_score="..."> = histori moderasi pengguna. Skor rendah BUKAN alasan memflag pesan bersih; skor tinggi BUKAN alasan mengabaikan pelanggaran nyata.\n` +
`- <user_profiles> (di pesan USER) = peta ringkasan kepribadian per user_id. <user_profile_ref 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 <conversation_context> berformat "[context] id=... time=<ISO> user=<nama>: 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` +
`- <messages_to_analyze> 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); parts.push(OUTPUT_INSTRUCTIONS);
// XML-delimited context — prevents prompt injection
const delimitedContext = `<conversation_context>\n${sanitizeAiContent(contextText, 8000)}\n</conversation_context>`;
parts.push(delimitedContext);
let base = parts.join("\n\n"); let base = parts.join("\n\n");
if (correction) { if (correction) {
@@ -19,14 +19,14 @@ import { callModerationLLM } from "./llmCaller.js";
import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js"; import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
import { import {
buildReferenceXml, buildReferenceXml,
buildUserProfileRef,
buildUserProfilesBlock,
escapeXml, escapeXml,
getAnalysisContent, getAnalysisContent,
resolveDisplayName, resolveDisplayName,
truncateForAi,
} from "./moderationBuilders.js"; } from "./moderationBuilders.js";
import { import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
buildSystemPrompt as buildSystemPromptModular,
sanitizeAiContent,
} from "./moderationPrompt.js";
import { logModerationAnalysis } from "./responseLogger.js"; import { logModerationAnalysis } from "./responseLogger.js";
import { import {
extractSearchQueries, extractSearchQueries,
@@ -74,7 +74,7 @@ export async function buildCorrectedFewShotExamples(): Promise<string> {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function runTextOnlyBatch( export async function runTextOnlyBatch(
targets: MessageRecord[], targets: MessageRecord[],
contextText: string, contextBlock: string,
): Promise<{ results: AnalysisResult[]; raw: unknown }> { ): Promise<{ results: AnalysisResult[]; raw: unknown }> {
if (!targets.length) return { results: [], raw: null }; if (!targets.length) return { results: [], raw: null };
@@ -188,7 +188,8 @@ export async function runTextOnlyBatch(
const batch = subBatches[i]; const batch = subBatches[i];
const targetIds = batch.map((t) => t.id); const targetIds = batch.map((t) => t.id);
// User reputation + profiles // User reputation + profiles (raw summary text — deduplicated into a
// single <user_profiles> map per batch; messages only reference it).
const userContexts = new Map<string, string>(); const userContexts = new Map<string, string>();
const userProfiles = new Map<string, string>(); const userProfiles = new Map<string, string>();
for (const msg of batch) { for (const msg of batch) {
@@ -201,14 +202,10 @@ export async function runTextOnlyBatch(
} }
if (!userProfiles.has(msg.user_id)) { if (!userProfiles.has(msg.user_id)) {
const profile = await getUserProfile(msg.user_id); const profile = await getUserProfile(msg.user_id);
userProfiles.set( userProfiles.set(msg.user_id, profile?.profile_summary ?? "");
msg.user_id,
profile
? `<user_profile>${sanitizeAiContent(profile.profile_summary)}</user_profile>`
: "",
);
} }
} }
const userProfilesBlock = buildUserProfilesBlock(userProfiles);
// ── URL images → multimodal vision evidence ───────────────────────── // ── URL images → multimodal vision evidence ─────────────────────────
// The text batch fetches inline URLs; whenever one resolved to an image // The text batch fetches inline URLs; whenever one resolved to an image
@@ -280,7 +277,6 @@ export async function runTextOnlyBatch(
: undefined; : undefined;
const correctedExamples = await buildCorrectedFewShotExamples(); const correctedExamples = await buildCorrectedFewShotExamples();
const systemText = buildSystemPromptModular({ const systemText = buildSystemPromptModular({
contextText,
mode: batchHasImageEvidence ? "mixed" : "text", mode: batchHasImageEvidence ? "mixed" : "text",
correction, correction,
correctedExamples, correctedExamples,
@@ -290,7 +286,7 @@ export async function runTextOnlyBatch(
const messagesBlock = ( const messagesBlock = (
await Promise.all( await Promise.all(
batch.map(async (msg) => { batch.map(async (msg) => {
const content = getAnalysisContent(msg); const content = truncateForAi(getAnalysisContent(msg));
const msgUrls = extractUrlsFromText(content); const msgUrls = extractUrlsFromText(content);
const urlContexts = msgUrls const urlContexts = msgUrls
.map((url) => { .map((url) => {
@@ -307,25 +303,36 @@ export async function runTextOnlyBatch(
.map((line) => `\n${line}`) .map((line) => `\n${line}`)
.join(""); .join("");
const userCtx = userContexts.get(msg.user_id) ?? ""; 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); const refXml = await buildReferenceXml(msg);
return `<message id="${msg.id}" user="${resolveDisplayName(msg)}">\n ${userCtx}${userProfileCtx ? `\n ${userProfileCtx}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}${mediaEvidenceCtx}\n</message>`; return `<message id="${msg.id}" user="${resolveDisplayName(msg)}">\n ${userCtx}${userProfileRef ? `\n ${userProfileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}${mediaEvidenceCtx}\n</message>`;
}), }),
) )
).join("\n"); ).join("\n");
const searxngBlock = const searxngBlock =
searxngResults.size > 0 searxngResults.size > 0
? `\n\n<web_searches>\n${Array.from(searxngResults.entries()) ? `<web_searches>\n${Array.from(searxngResults.entries())
.map( .map(
([q, xml]) => ([q, xml]) =>
` <search_query query="${escapeXml(q)}">\n${xml} </search_query>`, ` <search_query query="${escapeXml(q)}">\n${xml} </search_query>`,
) )
.join("\n")}\n</web_searches>` .join("\n")}\n</web_searches>`
: ""; : "";
// 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,
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
].filter((b) => b.trim().length > 0);
return { return {
system: systemText, system: systemText,
user: `${searxngBlock}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`, user: userBlocks.join("\n\n"),
}; };
}; };
@@ -37,16 +37,17 @@ import {
} from "./mediaDownloader.js"; } from "./mediaDownloader.js";
import { import {
buildReferenceXml, buildReferenceXml,
buildUserProfileRef,
escapeXml, escapeXml,
getAnalysisContent, getAnalysisContent,
resolveDisplayName, resolveDisplayName,
truncateForAi,
} from "./moderationBuilders.js"; } from "./moderationBuilders.js";
import { import {
buildCustomEmojiVisionPrompt, buildCustomEmojiVisionPrompt,
buildGeneralImageVisionPrompt, buildGeneralImageVisionPrompt,
buildStickerTextOnlyWarning, buildStickerTextOnlyWarning,
buildStickerVisionPrompt, buildStickerVisionPrompt,
sanitizeAiContent,
} from "./moderationPrompt.js"; } from "./moderationPrompt.js";
import { import {
extractSearchQueries, extractSearchQueries,
@@ -367,7 +368,14 @@ export async function prepareMediaMessage(
const rep = await initializeUserReputation(target.user_id, target.guild_id); const rep = await initializeUserReputation(target.user_id, target.guild_id);
const profile = await getUserProfile(target.user_id); const profile = await getUserProfile(target.user_id);
const refXml = await buildReferenceXml(target); const refXml = await buildReferenceXml(target);
// Profile is emitted ONCE per batch in a <user_profiles> 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 = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}">\n <user_reputation trust_score="${rep.trust_score}" />${profile ? `\n <user_profile>${sanitizeAiContent(profile.profile_summary)}</user_profile>` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n</message>`; const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}">\n <user_reputation trust_score="${rep.trust_score}" />${profileRef ? `\n ${profileRef}` : ""}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n</message>`;
return { targetId, messageBlock }; return { targetId, messageBlock };
} }
@@ -6,7 +6,9 @@ import {
buildConversationContext, buildConversationContext,
buildLocationContext, buildLocationContext,
formatMessageForPrompt, formatMessageForPrompt,
truncateContextLine,
} from "../src/modules/ai-moderation/conversationContext.js"; } 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 { extractOgMeta } from "../src/modules/ai-moderation/urlFetcher.js";
import type { MessageRecord } from "../src/modules/message-capture/types.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"); 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", () => { describe("buildLocationContext — channel/thread/nsfw enrichment", () => {
it("renders channel name + thread name from captured metadata", () => { it("renders a structured <location_context/> element from captured metadata", () => {
const t = target(); const t = target();
t.metadata = JSON.stringify({ t.metadata = JSON.stringify({
channel: { channel: {
@@ -176,10 +189,12 @@ describe("buildLocationContext — channel/thread/nsfw enrichment", () => {
}, },
}); });
const line = buildLocationContext([t]); const line = buildLocationContext([t]);
expect(line).toContain("[location]"); expect(line).toContain("<location_context");
expect(line).toContain('name="general"'); expect(line).toContain('channel_id="c1"');
expect(line).toContain('channel_name="general"');
expect(line).toContain('thread_name="tanya coding"'); expect(line).toContain('thread_name="tanya coding"');
expect(line).toContain("nsfw=false"); expect(line).toContain('nsfw="false"');
expect(line).toContain('age_restricted="false"');
}); });
it("returns empty when no metadata", () => { it("returns empty when no metadata", () => {
@@ -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("<location_context");
expect(block).toContain("<conversation_context>");
expect(block).toContain("[conversation_flow] status=ongoing");
expect(block).toContain("[context] id=a");
// location block comes before conversation block
expect(block.indexOf("<location_context")).toBeLessThan(
block.indexOf("<conversation_context>"),
);
});
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: '<location_context channel_id="c1"/>',
descriptor: "",
lines: [],
});
expect(block).toBe('<location_context channel_id="c1"/>');
});
});
describe("extractOgMeta — page title/site for <web_content>", () => { describe("extractOgMeta — page title/site for <web_content>", () => {
it("extracts og:title, og:description and og:site_name", () => { it("extracts og:title, og:description and og:site_name", () => {
const html = ` const html = `
@@ -1,6 +1,8 @@
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// 1. AppError Hierarchy // 1. AppError Hierarchy
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
AppError, AppError,
ConfigError, ConfigError,
@@ -9,7 +11,6 @@ import {
UnauthorizedError, UnauthorizedError,
ValidationError, ValidationError,
} from "../src/shared/errors/index.js"; } from "../src/shared/errors/index.js";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("AppError subclasses", () => { describe("AppError subclasses", () => {
it("AppError carries code, statusCode, and details", () => { it("AppError carries code, statusCode, and details", () => {