perf(ai-moderation): remove per-user reputation from analysis context

User: 'jangan ada reputasi juga' — no profile, no reputation in the prompt,
raw messages only.

- textBatchProcessor: drop initializeUserReputation fetch + <user_reputation>
  tag injection (kept the minimal <message> tag + reply/reference context).
- visionAnalyzer (prepareMediaMessage): same removal.
- prompts/system.ts + prompts/output.ts: replace <user_reputation>/<user_history>
  instructions with an explicit 'no per-user profile/reputation context'
  note so the LLM judges purely on message content + conversation/web/location.
- mediaBatchProcessor: fix stale comment.

Trust/infraction state is STILL written to the DB (userReputationsTable) for
enforcement — only the LLM context injection is removed, so moderation
actions (mute/ban via infraction thresholds) keep working.

Net: even smaller prompts (no per-user context at all) → more messages fit
per request, and one fewer DB round-trip per unique user per sub-batch.

tsc, biome, vitest (129) all clean.
This commit is contained in:
asepharyana
2026-08-16 20:44:39 +07:00
parent aa280c48b7
commit 2825250804
5 changed files with 17 additions and 40 deletions
@@ -63,10 +63,9 @@ export async function runMediaBatch(
channelCulture,
});
// Per-message blocks (from prepareMediaMessage) carry their own
// <user_reputation> history; personal profile descriptions are omitted
// (they bloat the prompt and add a per-user DB round-trip for little
// moderation signal — see textBatchProcessor).
// Per-message blocks (from prepareMediaMessage) contain the message
// content + reference/reply context only — no per-user reputation or
// profile context is injected (kept minimal per user request).
const messagesBlock = prepared.map((p) => p.messageBlock).join("\n");
// Data/instruction separation: the system prompt is stable per mode — all
// per-batch context (conversation) lives in the USER payload, ordered
@@ -35,10 +35,10 @@ 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 — Reputasi Pengguna dan Kultur Channel
Data konteks tersedia: <user_reputation> (skor trust + histori infraction + repeat_offender), dan <channel_culture> (topik/vibe channel).
## KONTEKS — Kultur Channel
Data konteks tersedia: <channel_culture> (topik/vibe channel). Tidak ada data profil/reputasi per-user — nilai tiap pesan murni dari isinya.
Gunakan untuk personalisasi analysis, tapi:
- Profil/history adalah KONTEKS, bukan bukti. Profil mencurigakan ≠ flag; profil bersih ≠ loloskan pelanggaran. <user_history> (kutipan pesan pernah di-flag) = cari POLA berulang (spam link SAMA, provokasi berulang konten SAMA); JANGAN gunakan untuk "menginterpretasi ulang" pesan bersih yang terpisah. Pesan baru tanpa pola pengulangan jelas → CLEAN.
- Konteks adalah KONTEKS, bukan bukti. Riwayat di <conversation_context> membantu pahami alur, tapi pesan bersih tanpa pelanggaran → CLEAN. JANGAN gunakan konteks untuk "menginterpretasi ulang" pesan bersih yang terpisah.
- Perubahan perilaku mencolok (mis. teknis tiba-tiba provokatif) layak dicatat. JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik.
- Channel culture coding/teknis → pesan teknis lebih wajar; channel santai → slang lebih wajar. Jangan dipakai mengabaikan pelanggaran nyata.
@@ -70,8 +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 <user_reputation> (repeat_offender, last_offense_days_ago) untuk memberi konteks histori — analisis terasa seperti sistem "mengenal" histori pengguna tanpa deskripsi profil pribadi.
- Jika perilaku pesan menyimpang dari pola histori yang diketahui, CATAT dalam analysis sebagai informasi kontekstual yang relevan.
- Nilai tiap pesan murni dari isinya sendiri + <conversation_context> + <web_searches> + <location_context>. Tidak ada reputasi/profil per-user di context.
- JANGAN paksa referensi profil jika tidak relevan — analysis natural lebih baik dari yang dipaksakan.`;
// ---------------------------------------------------------------------------
@@ -107,7 +107,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions): string {
`System prompt ini TIDAK berisi data batch — semua data per-batch ada di pesan USER:\n` +
`- <location_context .../>: metadata channel/thread (channel_name, thread_name, topic, nsfw, age_restricted). topic = tujuan resmi channel; gunakan menilai kesesuaian pesan.\n` +
`- <conversation_context>: obrolan SEBELUM target. Baris pertama "[conversation_flow] status=... context_msgs=... dropped=..." = metadata sistem (ongoing/sparse/cold_start), BUKAN pesan dinilai. Baris "[context] id=... time=... user=...: isi" = konteks, BUKAN target.\n` +
`- <user_reputation trust_score total_infractions clean_streak last_offense_days_ago repeat_offender>: histori moderasi (repeat_offender=true = pelanggaran ≤7 hari). <user_history>: kutipan pesan pernah di-flag — cari POLA berulang (spam link sama), BUKAN bukti pesan bersih.\n` +
`- Tidak ada data profil/reputasi per-user di context — nilai tiap pesan murni dari isinya + <conversation_context> + <web_searches> + <location_context>.\n` +
`- <web_searches>/<web_content>: bukti web (prioritas tertinggi). <term_glossary>: definisi kata/slang/jargon (SearXNG) — pakai pahami kata asing, JANGAN tebak arti.\n` +
`- <messages_to_analyze>: pesan TARGET yang WAJIB dinilai. Atribut <message>: id, user, time (ISO), repetitions (N = teks sama muncul N× di batch → sinyal spam), bot (true = bot), edited (true = hasil edit setelah posting → evasi potensial).`,
);
@@ -20,7 +20,6 @@ import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
import {
buildReferenceXml,
escapeXml,
formatReputationAttrs,
getAnalysisContent,
resolveDisplayName,
resolveIsBot,
@@ -37,7 +36,6 @@ import {
import { buildTermGlossaryBlock } from "./termGlossary.js";
import { getRecentCorrectedModerations } from "./textCacheStore.js";
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
import { initializeUserReputation } from "./userReputationStore.js";
import type { MessageImagePart } from "./visionAnalyzer.js";
const log = createChildLogger("textBatchProcessor");
@@ -201,24 +199,10 @@ export async function runTextOnlyBatch(
const batch = subBatches[i];
const targetIds = batch.map((t) => t.id);
// ── Per-user reputation context (fetched ONCE per unique user, in
// parallel). Personal profile descriptions are intentionally NOT
// injected — they bloat the prompt (less room per request) and add a
// per-user DB/Redis round-trip for little moderation signal. Only the
// behavioural <user_reputation> history is sent. ─────────────────────
const uniqueUserIds = [...new Set(batch.map((m) => m.user_id))];
const batchGuildId = batch[0]?.guild_id ?? "";
const userFetches = await Promise.all(
uniqueUserIds.map(async (uid) => {
const rep = await initializeUserReputation(uid, batchGuildId);
return { uid, rep };
}),
);
const userContexts = new Map<string, string>();
for (const { uid, rep } of userFetches) {
const repAttrs = formatReputationAttrs(rep);
userContexts.set(uid, `<user_reputation ${repAttrs}/>`);
}
// No per-user reputation/profile context is injected into the prompt —
// the user asked to keep the AI analysis context minimal (raw messages
// only). Trust/infraction state is still tracked in the DB for
// enforcement, just not shown to the LLM.
// ── URL images → multimodal vision evidence ─────────────────────────
// The text batch fetches inline URLs; whenever one resolved to an image
@@ -315,12 +299,11 @@ export async function runTextOnlyBatch(
const mediaEvidenceCtx = (batchImageEvidence.get(msg.id) ?? [])
.map((line) => `\n${line}`)
.join("");
const userCtx = userContexts.get(msg.user_id) ?? "";
const refXml = await buildReferenceXml(msg);
const repetitionCount = groupMapping.get(msg.id)?.length ?? 1;
const isBot = resolveIsBot(msg);
const isEdited = resolveIsEdited(msg);
return `<message id="${escapeXml(msg.id)}" user="${escapeXml(resolveDisplayName(msg))}" time="${new Date(msg.created_at).toISOString()}"${repetitionCount > 1 ? ` repetitions="${repetitionCount}"` : ""}${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${userCtx}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}${mediaEvidenceCtx}\n</message>`;
return `<message id="${escapeXml(msg.id)}" user="${escapeXml(resolveDisplayName(msg))}" time="${new Date(msg.created_at).toISOString()}"${repetitionCount > 1 ? ` repetitions="${repetitionCount}"` : ""}${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(content)}</content>${webContext}${mediaEvidenceCtx}\n</message>`;
}),
)
).join("\n");
@@ -64,7 +64,6 @@ import {
import {
buildReferenceXml,
escapeXml,
formatReputationAttrs,
getAnalysisContent,
resolveDisplayName,
resolveIsBot,
@@ -84,7 +83,6 @@ import {
} from "./searxngSearch.js";
import { buildTermGlossaryBlock } from "./termGlossary.js";
import { extractUrlsFromText } from "./urlFetcher.js";
import { initializeUserReputation } from "./userReputationStore.js";
// ---------------------------------------------------------------------------
// Types
@@ -398,15 +396,13 @@ export async function prepareMediaMessage(
.filter(Boolean)
.join(" ");
const rep = await initializeUserReputation(target.user_id, target.guild_id);
const refXml = await buildReferenceXml(target);
// Only the behavioural <user_reputation> history is injected; personal profile descriptions are omitted (see textBatchProcessor).
const repAttrs = formatReputationAttrs(rep);
const repXml = `<user_reputation ${repAttrs}/>`;
// No per-user reputation/profile context is injected into the prompt —
// keep the AI analysis context minimal (raw messages only). Trust state is
// still tracked in the DB for enforcement, just not shown to the LLM.
const isBot = resolveIsBot(target);
const isEdited = resolveIsEdited(target);
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}" time="${new Date(target.created_at).toISOString()}"${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${repXml}${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}${glossaryCtx}\n</message>`;
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}" time="${new Date(target.created_at).toISOString()}"${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}${glossaryCtx}\n</message>`;
return { targetId, messageBlock };
}