fix(automod): flow real LLM analysis + descriptive fallback
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 3m2s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 2m25s
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 2m40s

Root cause: ai-analysis-worker read llmResult.explanation and
llmResult.toxicityScore — fields the LLM pipeline never produces
(canonical AnalysisResult uses analysis/score). Every message fell back
to the bare template "Tidak ada indikasi pelanggaran." and the stored
score was always 0.

- Map analysis/score correctly; fallback now quotes the message content
- Prompt: ban generic analysis phrasing, require reply context
- LLM context: include replied-to message content (metadata.reference)
  so the model can explain what the user is replying to
- Frontend: show thread/channel names from metadata instead of raw IDs
  (message card, detail views, search overlay); detail panel now
  displays the ai_analysis text
- Auto-delete log/DM include the descriptive analysis as the reason
This commit is contained in:
Developer
2026-07-31 19:11:13 +07:00
parent 0bd4369ae9
commit 60084b3cc3
11 changed files with 142 additions and 34 deletions
@@ -58,11 +58,8 @@ export interface AnalysisResult {
| "review" | "review"
| "delete" | "delete"
| "escalate"; | "escalate";
toxicityScore: number; score: number;
harmScore: number; analysis: string;
jailbreakScore: number;
safetyScore: number;
explanation: string;
correctedFlags?: string[]; correctedFlags?: string[];
} }
@@ -182,7 +179,7 @@ export default async function workerRouter(
* Runs the LLM moderation analysis on a single message. * Runs the LLM moderation analysis on a single message.
* *
* The LLM verdict IS the result — confidence, severity, flags and * The LLM verdict IS the result — confidence, severity, flags and
* explanation all come from the model. On failure the message is marked * analysis all come from the model. On failure the message is marked
* "error" (explicit, retryable) instead of receiving a heuristic verdict. * "error" (explicit, retryable) instead of receiving a heuristic verdict.
*/ */
async function runLLMAnalysis( async function runLLMAnalysis(
@@ -214,15 +211,10 @@ async function runLLMAnalysis(
severity: llmResult.severity ?? "none", severity: llmResult.severity ?? "none",
confidence: normalizeConfidence(llmResult.confidence), confidence: normalizeConfidence(llmResult.confidence),
recommendedAction: llmResult.recommendedAction ?? "none", recommendedAction: llmResult.recommendedAction ?? "none",
toxicityScore: llmResult.toxicityScore ?? 0, score: llmResult.score ?? 0,
harmScore: llmResult.harmScore ?? 0, analysis:
jailbreakScore: llmResult.jailbreakScore ?? 0, llmResult.analysis?.trim() ||
safetyScore: llmResult.safetyScore ?? 0, buildFallbackAnalysis(message, llmResult.status ?? "clean"),
explanation:
llmResult.explanation?.trim() ||
(llmResult.status === "clean"
? "Tidak ada indikasi pelanggaran."
: "Pesan terindikasi melanggar kebijakan (analisis AI)."),
}; };
} catch (error) { } catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error); const errorMsg = error instanceof Error ? error.message : String(error);
@@ -242,6 +234,28 @@ function normalizeConfidence(raw: number | undefined | null): number {
return 0.7; return 0.7;
} }
/**
* Content-aware fallback when the LLM returns an empty `analysis` field.
* Quotes the actual message content so the log still explains WHAT was said
* instead of a bare template like "Tidak ada indikasi pelanggaran."
*/
function buildFallbackAnalysis(
message: MessageRecord,
status: string,
): string {
const raw = (message.edited_content ?? message.content ?? "").trim();
const snippet = raw.length > 120 ? `${raw.slice(0, 120).trimEnd()}` : raw;
if (status === "clean") {
return snippet
? `Tidak ada indikasi pelanggaran. Pesan: "${snippet}" dinilai wajar dalam konteks percakapan.`
: "Tidak ada indikasi pelanggaran. Isi pesan dinilai wajar dalam konteks percakapan.";
}
return snippet
? `Pesan terindikasi melanggar kebijakan: "${snippet}".`
: "Pesan terindikasi melanggar kebijakan (analisis AI).";
}
function buildFallbackResult( function buildFallbackResult(
messageId: string, messageId: string,
reason: string, reason: string,
@@ -254,11 +268,8 @@ function buildFallbackResult(
severity: "none", severity: "none",
confidence: 0, confidence: 0,
recommendedAction: "review", recommendedAction: "review",
toxicityScore: 0, score: 0,
harmScore: 0, analysis: reason,
jailbreakScore: 0,
safetyScore: 0,
explanation: reason,
}; };
} }
@@ -319,14 +330,14 @@ async function processBatch(job: {
result: { result: {
status: result.status, status: result.status,
flags: JSON.stringify(result.flags), flags: JSON.stringify(result.flags),
score: result.toxicityScore, score: result.score,
analysis: result.explanation, analysis: result.analysis,
categories: result.categories, categories: result.categories,
severity: result.severity, severity: result.severity,
confidence: result.confidence, confidence: result.confidence,
recommendedAction: result.recommendedAction, recommendedAction: result.recommendedAction,
analyzedAt: Date.now(), analyzedAt: Date.now(),
error: result.status === "error" ? result.explanation : null, error: result.status === "error" ? result.analysis : null,
}, },
})); }));
@@ -33,6 +33,7 @@ export async function logDeletionToChannel(
const severity = message.ai_severity ?? "none"; const severity = message.ai_severity ?? "none";
const categories = const categories =
message.ai_categories ?? message.ai_moderation_flags ?? "—"; message.ai_categories ?? message.ai_moderation_flags ?? "—";
const reason = message.ai_analysis ?? "—";
const snippet = (message.edited_content ?? message.content).substring( const snippet = (message.edited_content ?? message.content).substring(
0, 0,
200, 200,
@@ -42,6 +43,7 @@ export async function logDeletionToChannel(
`**Status:** ${message.ai_status}\n` + `**Status:** ${message.ai_status}\n` +
`**Severitas:** ${severity}\n` + `**Severitas:** ${severity}\n` +
`**Kategori:** ${categories}\n` + `**Kategori:** ${categories}\n` +
`**Alasan:** ${reason}\n` +
`**Isi:** ${snippet}\n` + `**Isi:** ${snippet}\n` +
`**Waktu:** <t:${Math.floor(Date.now() / 1000)}:R>`, `**Waktu:** <t:${Math.floor(Date.now() / 1000)}:R>`,
); );
@@ -20,8 +20,14 @@ export async function sendDeletionNotification(
try { try {
const targetUser = await client.users.fetch(message.user_id); const targetUser = await client.users.fetch(message.user_id);
if (targetUser) { if (targetUser) {
// Prefer the descriptive LLM analysis so the user understands WHY;
// fall back to category/flag labels when it is unavailable.
const analysis = (message.ai_analysis ?? "").trim();
const reason: string = const reason: string =
message.ai_categories ?? message.ai_moderation_flags ?? "(unknown)"; (analysis.length > 240 ? `${analysis.slice(0, 240)}` : analysis) ||
message.ai_categories ??
message.ai_moderation_flags ??
"(unknown)";
await targetUser.send( await targetUser.send(
`Pesan Anda di **${guildName}** telah dihapus oleh sistem moderasi otomatis.\n` + `Pesan Anda di **${guildName}** telah dihapus oleh sistem moderasi otomatis.\n` +
`Alasan: ${reason}\n` + `Alasan: ${reason}\n` +
@@ -42,12 +42,44 @@ export function estimateTokens(text: string): number {
} }
/** /**
* Formats reference info for a message (reply/forward/crosspost) * Formats reference info for a message (reply/forward/crosspost).
*
* The replied-to content is stored in the message metadata
* (`metadata.reference.content` / `repliedUsername`) at capture time, so
* include it here — the LLM can then explain WHAT the user is replying to
* instead of only seeing a raw message ID it cannot resolve.
*/ */
function formatReferenceInfo(msg: MessageRecord): string { function formatReferenceInfo(msg: MessageRecord): string {
const parts: string[] = []; const parts: string[] = [];
let repliedContent: string | null = null;
let repliedUsername: string | null = null;
try {
const meta = JSON.parse(msg.metadata ?? "") as {
reference?: {
content?: string | null;
repliedUsername?: string | null;
} | null;
};
repliedContent = meta?.reference?.content ?? null;
repliedUsername = meta?.reference?.repliedUsername ?? null;
} catch {
// metadata malformed — fall back to ID-only reference
}
const repliedText = (repliedContent ?? "").trim();
const repliedSnippet = repliedText
? sanitizeDiscordTokens(
repliedText.length > 200
? `${repliedText.slice(0, 200)}`
: repliedText,
)
: null;
if (msg.is_reply && msg.reference_message_id) { if (msg.is_reply && msg.reference_message_id) {
parts.push(`[reply_to: ${msg.reference_message_id}]`); const who = repliedUsername ? ` oleh ${repliedUsername}` : "";
const what = repliedSnippet ? `: "${repliedSnippet}"` : "";
parts.push(`[reply_to: ${msg.reference_message_id}${who}${what}]`);
if (msg.reference_channel_id) { if (msg.reference_channel_id) {
parts.push(`(reply_channel: ${msg.reference_channel_id})`); parts.push(`(reply_channel: ${msg.reference_channel_id})`);
} }
@@ -153,9 +153,11 @@ Contoh buruk: "Pengirim bercanda tentang agama." (JANGAN menggunakan kata "berca
CRITICAL: CRITICAL:
- JANGAN PERNAH menulis "Pesan hanya berisi..." atau "Pesan tidak mengandung..." sebagai analysis. - JANGAN PERNAH menulis "Pesan hanya berisi..." atau "Pesan tidak mengandung..." sebagai analysis.
- JANGAN PERNAH menulis "Tidak ada indikasi pelanggaran" atau frasa generik serupa sebagai analysis — wajib sebutkan TOPIK/ISI pesan secara spesifik apa yang sedang dibicarakan pengirim.
- JANGAN PERNAH menulis template generik seperti "Pengirim mengirimkan sebuah file GIF tanpa pelanggaran". Kamu WAJIB mendeskripsikan isi visualnya secara spesifik berdasarkan Media analysis. - JANGAN PERNAH menulis template generik seperti "Pengirim mengirimkan sebuah file GIF tanpa pelanggaran". Kamu WAJIB mendeskripsikan isi visualnya secara spesifik berdasarkan Media analysis.
- JANGAN PERNAH menyebutkan nama / username pengguna secara langsung. Selalu gunakan kata "Pengirim" atau "Pengguna". - JANGAN PERNAH menyebutkan nama / username pengguna secara langsung. Selalu gunakan kata "Pengirim" atau "Pengguna".
- Selalu sebutkan ISI KONTEN secara spesifik — apa yang dibicarakan, apa yang terlihat di gambar. - Selalu sebutkan ISI KONTEN secara spesifik — apa yang dibicarakan, apa yang terlihat di gambar.
- 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> untuk personalisasi analysis — jadikan analysis terasa seperti sistem "mengenal" pengguna.
@@ -11,6 +11,7 @@ interface AiAnalysisPanelProps {
categories?: string[] | string | null; categories?: string[] | string | null;
action?: string | null; action?: string | null;
score?: number | null; score?: number | null;
analysis?: string | null;
} }
const severityColor: Record<string, string> = { const severityColor: Record<string, string> = {
@@ -29,6 +30,7 @@ export function AiAnalysisPanel({
categories, categories,
action, action,
score, score,
analysis,
}: AiAnalysisPanelProps) { }: AiAnalysisPanelProps) {
if (!status || status === "pending") { if (!status || status === "pending") {
return ( return (
@@ -93,6 +95,12 @@ export function AiAnalysisPanel({
</div> </div>
)} )}
{analysis && (
<p className="text-xs leading-relaxed text-text-secondary/90 border-l-2 border-glass-border pl-2">
{analysis}
</p>
)}
{action && action !== "none" && ( {action && action !== "none" && (
<div className="text-xs"> <div className="text-xs">
<span className="text-text-secondary/60">Recommended: </span> <span className="text-text-secondary/60">Recommended: </span>
@@ -6,7 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { safeParseJsonArray } from "@/lib/format"; import { safeParseJsonArray, getMessageChannelLabel } from "@/lib/format";
import type { MessageRecord } from "@/lib/types"; import type { MessageRecord } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AiStatusBadge } from "./ai-status-badge"; import { AiStatusBadge } from "./ai-status-badge";
@@ -52,9 +52,16 @@ export function MessageCard({
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{new Date(msg.created_at).toLocaleString()} {new Date(msg.created_at).toLocaleString()}
</span> </span>
<span className="text-xs text-muted-foreground"> <span
className="text-xs text-muted-foreground"
title={
msg.thread_id
? `Thread ${getMessageChannelLabel(msg)} (${msg.thread_id.slice(0, 8)})`
: undefined
}
>
<Hash className="size-3 inline mr-0.5" /> <Hash className="size-3 inline mr-0.5" />
{msg.channel_id.slice(0, 8)} {getMessageChannelLabel(msg)}
</span> </span>
<AiStatusBadge status={msg.ai_status} /> <AiStatusBadge status={msg.ai_status} />
{msg.ai_severity && msg.ai_severity !== "none" && ( {msg.ai_severity && msg.ai_severity !== "none" && (
@@ -1,9 +1,10 @@
"use client"; "use client";
import { ArrowLeft, MessageSquare } from "lucide-react"; import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react";
import { GlassCard } from "@/components/glass/card"; import { GlassCard } from "@/components/glass/card";
import { AttachmentsGrid } from "./attachments-grid"; import { AttachmentsGrid } from "./attachments-grid";
import { AiAnalysisPanel } from "./ai-analysis-panel"; import { AiAnalysisPanel } from "./ai-analysis-panel";
import { getMessageChannelLabel } from "@/lib/format";
import type { AttachmentRecord, MessageRecord } from "@/lib/types"; import type { AttachmentRecord, MessageRecord } from "@/lib/types";
interface MessageDetailViewProps { interface MessageDetailViewProps {
@@ -25,7 +26,10 @@ export function MessageDetailView({ message, attachments, onBack }: MessageDetai
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<MessageSquare className="size-4 text-primary" /> <MessageSquare className="size-4 text-primary" />
<span className="font-semibold text-sm text-text-primary">{message.username}</span> <span className="font-semibold text-sm text-text-primary">{message.username}</span>
<span className="text-[10px] text-text-secondary/40 font-mono">{message.channel_id?.slice(0, 8)}</span> <span className="text-[10px] text-text-secondary/40 font-mono inline-flex items-center gap-1">
{message.thread_id && <MessagesSquare className="size-3" />}
{getMessageChannelLabel(message)}
</span>
</div> </div>
{/* Content */} {/* Content */}
@@ -49,6 +53,7 @@ export function MessageDetailView({ message, attachments, onBack }: MessageDetai
categories={message.ai_categories} categories={message.ai_categories}
action={message.ai_recommended_action} action={message.ai_recommended_action}
score={message.ai_moderation_score} score={message.ai_moderation_score}
analysis={message.ai_analysis}
/> />
</GlassCard> </GlassCard>
); );
@@ -1,9 +1,10 @@
"use client"; "use client";
import { ArrowLeft, MessageSquare } from "lucide-react"; import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react";
import { GlassCard } from "@/components/glass/card"; import { GlassCard } from "@/components/glass/card";
import { AttachmentsGrid } from "./attachments-grid"; import { AttachmentsGrid } from "./attachments-grid";
import { AiAnalysisPanel } from "./ai-analysis-panel"; import { AiAnalysisPanel } from "./ai-analysis-panel";
import { getMessageChannelLabel } from "@/lib/format";
import type { AttachmentRecord, MessageRecord } from "@/lib/types"; import type { AttachmentRecord, MessageRecord } from "@/lib/types";
interface MessageDetailProps { interface MessageDetailProps {
@@ -25,7 +26,10 @@ export function MessageDetail({ message, attachments, onBack }: MessageDetailPro
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<MessageSquare className="size-4 text-primary" /> <MessageSquare className="size-4 text-primary" />
<span className="font-semibold text-sm text-text-primary">{message.username}</span> <span className="font-semibold text-sm text-text-primary">{message.username}</span>
<span className="text-[10px] text-text-secondary/40 font-mono">{message.channel_id?.slice(0, 8)}</span> <span className="text-[10px] text-text-secondary/40 font-mono inline-flex items-center gap-1">
{message.thread_id && <MessagesSquare className="size-3" />}
{getMessageChannelLabel(message)}
</span>
</div> </div>
{/* Content */} {/* Content */}
@@ -49,6 +53,7 @@ export function MessageDetail({ message, attachments, onBack }: MessageDetailPro
categories={message.ai_categories} categories={message.ai_categories}
action={message.ai_recommended_action} action={message.ai_recommended_action}
score={message.ai_moderation_score} score={message.ai_moderation_score}
analysis={message.ai_analysis}
/> />
</GlassCard> </GlassCard>
); );
@@ -4,6 +4,7 @@ import { Search, X } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { messagesApi } from "@/lib/api"; import { messagesApi } from "@/lib/api";
import { getMessageChannelLabel } from "@/lib/format";
import type { MessageRecord } from "@/lib/types"; import type { MessageRecord } from "@/lib/types";
interface SearchOverlayProps { interface SearchOverlayProps {
@@ -83,7 +84,7 @@ export function SearchOverlay({ open, onClose, onSelect }: SearchOverlayProps) {
> >
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="font-medium text-text-primary">{msg.username}</span> <span className="font-medium text-text-primary">{msg.username}</span>
<span className="text-text-secondary/40">{msg.channel_id?.slice(0, 8)}</span> <span className="text-text-secondary/40">{getMessageChannelLabel(msg)}</span>
</div> </div>
<p className="text-xs text-text-secondary/80 line-clamp-1 mt-0.5">{msg.content}</p> <p className="text-xs text-text-secondary/80 line-clamp-1 mt-0.5">{msg.content}</p>
</button> </button>
+29
View File
@@ -43,3 +43,32 @@ export function safeParseJsonObject(
return {}; return {};
} }
} }
/**
* Resolve a human-readable channel/thread label for a message.
*
* The channel name (and thread name, when the message lives in a thread)
* is captured by the gateway into the message metadata JSON under
* `metadata.channel.{channelName,threadName}`. Prefer names over raw IDs:
* a thread message shows its thread name, otherwise the channel name,
* falling back to a truncated channel ID only when names are unavailable.
*/
export function getMessageChannelLabel(msg: {
channel_id?: string;
metadata?: string | null;
}): string {
let channelName: string | undefined;
let threadName: string | undefined;
try {
const m = JSON.parse(msg.metadata ?? "");
const ch = m?.channel;
channelName =
typeof ch?.channelName === "string" ? ch.channelName : undefined;
threadName = typeof ch?.threadName === "string" ? ch.threadName : undefined;
} catch {
// metadata malformed — fall through to ID fallback
}
if (threadName) return threadName;
if (channelName) return channelName;
return msg.channel_id?.slice(0, 8) ?? "";
}