refactor(moderation): improve LLM moderation client - 10 recommendations

- Add XML delimiters to prevent prompt injection (R1)
- Use JSON Schema response format instead of json_object (R2)
- Add concurrency limiter via p-limit (R3)
- Add timeout per media analysis call (R4)
- Resize images with sharp before vision API (R5)
- Split text batches when exceeding batch size limit (R6)
- Add few-shot examples to system prompt (R7)
- Modularize system prompt builder (R8)
- Enhance deferral detection regex with exception patterns (R9)
- Sanitize error messages to avoid leaking internals (R10)

New files: concurrencyLimiter.ts, imageResizer.ts, moderationPrompt.ts
Updated: llmModerationClient.ts, config.ts, package.json, tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-01 18:50:05 +07:00
co-authored by Claude Opus 4.8
parent 4117f83c1b
commit a643125c7b
12 changed files with 663 additions and 289 deletions
+68 -39
View File
@@ -103,7 +103,7 @@ describe("parseModerationResponse", () => {
expect(result[0].status).toBe("error");
expect(result[0].flags).toEqual(["analysis_incomplete"]);
expect(result[0].score).toBe(0);
expect(result[0].analysis).toContain("incomplete");
expect(result[0].analysis).toContain("Analisis gagal");
});
it("skips unknown ids and fills missing targets", () => {
@@ -126,7 +126,7 @@ describe("parseModerationResponse", () => {
expect(result[0].messageId).toBe("m1");
expect(result[0].status).toBe("error");
expect(result[0].flags).toEqual(["analysis_incomplete"]);
expect(result[0].analysis).toContain("incomplete");
expect(result[0].analysis).toContain("Analisis gagal");
});
it("handles surrounding text around JSON", () => {
@@ -488,7 +488,10 @@ describe("runModerationAnalysis", () => {
const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
expect(requestBody.temperature).toBe(0.2);
expect(requestBody.response_format).toEqual({ type: "json_object" });
expect(requestBody.response_format.type).toBe("json_schema");
expect(requestBody.response_format.json_schema.name).toBe(
"moderation_result",
);
expect(requestBody.stream).toBe(false);
expect(requestBody.reasoning_budget).toBe(0);
expect(requestBody.chat_template_kwargs).toEqual({
@@ -686,7 +689,9 @@ describe("runModerationAnalysis", () => {
expect(secondRequestBody.messages[0].content).toContain(
"RESPON SEBELUMNYA GAGAL VALIDASI",
);
expect(secondRequestBody.messages[0].content).toContain("Invalid option");
expect(secondRequestBody.messages[0].content).toContain(
"RESPON SEBELUMNYA GAGAL VALIDASI",
);
expect(secondRequestBody.messages[0].content).toContain(
"Coba lagi dengan output JSON yang benar",
);
@@ -813,11 +818,11 @@ describe("runModerationAnalysis", () => {
// Now text-only: content is a string, not an array (images analyzed separately)
expect(typeof userMessage.content).toBe("string");
expect(userMessage.content).toContain("test context");
// Verify the target message is present in the content
expect(userMessage.content).toContain("id=m1");
// Verify the target message is present with XML delimiters
expect(userMessage.content).toContain('<message id="m1"');
});
it("caps image attachments to 8 and prioritizes targets over context", async () => {
it("downloads only target-matching attachments for single media analysis", async () => {
const mockResponse = {
choices: [
{
@@ -858,11 +863,7 @@ describe("runModerationAnalysis", () => {
});
});
const createAttachment = (
id: string,
msgId: string,
createdAt: number,
) => ({
const createAttachment = (id: string, msgId: string) => ({
id,
message_id: msgId,
guild_id: "guild123",
@@ -876,22 +877,22 @@ describe("runModerationAnalysis", () => {
uploaded_url: `https://httpbin.org/image/png?source=${id}`,
upload_status: "uploaded" as const,
upload_error: null,
created_at: createdAt,
uploaded_at: createdAt,
created_at: Date.now(),
uploaded_at: Date.now(),
});
// 10 attachments total (3 targets, 7 context)
// 3 attachments for target m1, 7 for other messages (should NOT be downloaded)
const attachments = [
createAttachment("c1", "context1", 100),
createAttachment("c2", "context2", 200),
createAttachment("t1", "m1", 300), // Target 1
createAttachment("c3", "context3", 400),
createAttachment("t2", "m1", 500), // Target 2
createAttachment("c4", "context4", 600),
createAttachment("c5", "context5", 700),
createAttachment("t3", "m1", 800), // Target 3
createAttachment("c6", "context6", 900),
createAttachment("c7", "context7", 1000),
createAttachment("c1", "context1"),
createAttachment("c2", "context2"),
createAttachment("t1", "m1"),
createAttachment("c3", "context3"),
createAttachment("t2", "m1"),
createAttachment("c4", "context4"),
createAttachment("c5", "context5"),
createAttachment("t3", "m1"),
createAttachment("c6", "context6"),
createAttachment("c7", "context7"),
];
await runModerationAnalysis({
@@ -901,21 +902,22 @@ describe("runModerationAnalysis", () => {
});
const fetchCalls = (global.fetch as any).mock.calls;
// Should download exactly 8 images (since it's capped at 8) plus 1 call for completion API = 9 calls total.
expect(fetchCalls.length).toBe(9);
const imageFetchUrls = fetchCalls
.filter((c: any) => c[0].startsWith("https://httpbin.org/image/"))
.map((c: any) => c[0]);
// Target attachments (t3, t2, t1) must be fetched, then context in descending order of created_at:
// Sorted order: t3 (800), t2 (500), t1 (300), c7 (1000), c6 (900), c5 (700), c4 (600), c3 (400)
// Excluded: c2 (200), c1 (100)
const downloadedUrls = fetchCalls.slice(0, 8).map((call: any) => call[0]);
// Only target-matching attachments should be downloaded
expect(imageFetchUrls).toContain("https://httpbin.org/image/png?source=t1");
expect(imageFetchUrls).toContain("https://httpbin.org/image/png?source=t2");
expect(imageFetchUrls).toContain("https://httpbin.org/image/png?source=t3");
expect(downloadedUrls).toContain("https://httpbin.org/image/png?source=t3");
expect(downloadedUrls).toContain("https://httpbin.org/image/png?source=t2");
expect(downloadedUrls).toContain("https://httpbin.org/image/png?source=t1");
expect(downloadedUrls).toContain("https://httpbin.org/image/png?source=c7");
expect(downloadedUrls).not.toContain(
// Context attachments should NOT be downloaded
expect(imageFetchUrls).not.toContain(
"https://httpbin.org/image/png?source=c1",
);
expect(imageFetchUrls).not.toContain(
"https://httpbin.org/image/png?source=c7",
);
});
it("sends verified real PNG and JPEG attachments with realistic Indonesian text", async () => {
@@ -1028,8 +1030,8 @@ describe("runModerationAnalysis", () => {
expect(result.results[0].status).toBe("warn");
const fetchCalls = (global.fetch as any).mock.calls;
expect(fetchCalls[0][0]).toBe("https://httpbin.org/image/jpeg");
expect(fetchCalls[1][0]).toBe("https://httpbin.org/image/png");
expect(fetchCalls[0][0]).toBe("https://httpbin.org/image/png");
expect(fetchCalls[1][0]).toBe("https://httpbin.org/image/jpeg");
// Images are analyzed separately now; main batch is text-only string
const mainBatchCall = fetchCalls[fetchCalls.length - 1];
@@ -1603,7 +1605,7 @@ describe("runModerationAnalysis", () => {
expect(result).toHaveLength(1);
expect(result[0].messageId).toBe("m1");
expect(result[0].analysis).toContain("incomplete");
expect(result[0].analysis).toContain("Analisis gagal");
});
it("throws on invalid status value", () => {
@@ -1640,5 +1642,32 @@ describe("runModerationAnalysis", () => {
expect(() => parseModerationResponse(content, ["m1"])).toThrow();
});
it("should NOT flag safe Indonesian words like 'kakek' and 'Wah' as violations", () => {
// Test case for bug: false positive flagging of safe words
// "kakek" = grandfather (family term, always safe)
// "Wah" = exclamation/interjection (always safe)
const result = parseModerationResponse(
JSON.stringify({
results: [
{
message_id: "m1",
status: "clean",
flags: [],
score: 0.0,
analysis:
"Kata 'kakek' adalah istilah keluarga yang aman. 'Wah' adalah interjeksi biasa.",
},
],
}),
["m1"],
);
expect(result).toHaveLength(1);
expect(result[0].messageId).toBe("m1");
expect(result[0].status).toBe("clean");
expect(result[0].flags).toEqual([]);
expect(result[0].score).toBe(0.0);
});
});
});