feat: add multimodal analysis support to LLM moderation client by processing image attachments
This commit is contained in:
@@ -225,7 +225,10 @@ describe("MediaController", () => {
|
||||
expect(screenController.start).toHaveBeenCalledWith(
|
||||
"https://youtu.be/video",
|
||||
);
|
||||
expect(resolveMediaSource).toHaveBeenCalledWith("https://youtu.be/video", "screen");
|
||||
expect(resolveMediaSource).toHaveBeenCalledWith(
|
||||
"https://youtu.be/video",
|
||||
"screen",
|
||||
);
|
||||
expect(state).toMatchObject({ playing: true, activeMode: "screen" });
|
||||
});
|
||||
|
||||
|
||||
@@ -71,10 +71,14 @@ describe("createMusicPlayer", () => {
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout, "music", {
|
||||
inputType: StreamType.Raw,
|
||||
inlineVolume: true,
|
||||
});
|
||||
expect(discordPlayer.playStream).toHaveBeenCalledWith(
|
||||
proc.stdout,
|
||||
"music",
|
||||
{
|
||||
inputType: StreamType.Raw,
|
||||
inlineVolume: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects playback when Discord is not connected", () => {
|
||||
|
||||
@@ -336,4 +336,100 @@ describe("runModerationAnalysis", () => {
|
||||
}),
|
||||
).rejects.toThrow(/No content in LLM response/);
|
||||
});
|
||||
|
||||
it("sends multimodal payload when image attachments are present", async () => {
|
||||
const mockResponse = {
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
message_id: "m1",
|
||||
status: "clean",
|
||||
flags: [],
|
||||
score: 0.1,
|
||||
analysis: "OK",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes("picser.tech") || url.includes("discord.com")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
arrayBuffer: async () => {
|
||||
const buffer = Buffer.from("fake-image-bytes");
|
||||
return buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: async () => JSON.stringify(mockResponse),
|
||||
json: async () => mockResponse,
|
||||
});
|
||||
});
|
||||
|
||||
const mockAttachment = {
|
||||
id: "a1",
|
||||
message_id: "m1",
|
||||
guild_id: "guild123",
|
||||
channel_id: "channel123",
|
||||
thread_id: null,
|
||||
user_id: "user123",
|
||||
filename: "test.png",
|
||||
size: 500,
|
||||
type: "image/png",
|
||||
discord_url: "https://discord.com/attachment.png",
|
||||
uploaded_url: "https://picser.tech/test.png",
|
||||
upload_status: "uploaded" as const,
|
||||
upload_error: null,
|
||||
created_at: Date.now(),
|
||||
uploaded_at: Date.now(),
|
||||
};
|
||||
|
||||
const result = await runModerationAnalysis({
|
||||
targets: [createMessageRecord()],
|
||||
contextText: "test context",
|
||||
attachments: [mockAttachment],
|
||||
});
|
||||
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
|
||||
const fetchCalls = (global.fetch as any).mock.calls;
|
||||
// Should be called twice: 1st for image download, 2nd for API completions
|
||||
expect(fetchCalls.length).toBe(2);
|
||||
|
||||
// Verify 1st call (image download)
|
||||
expect(fetchCalls[0][0]).toBe("https://picser.tech/test.png");
|
||||
|
||||
// Verify 2nd call (chat completions API)
|
||||
const [, completionsOptions] = fetchCalls[1];
|
||||
const body = JSON.parse(completionsOptions.body);
|
||||
|
||||
const userMessage = body.messages[0];
|
||||
expect(userMessage.role).toBe("user");
|
||||
expect(Array.isArray(userMessage.content)).toBe(true);
|
||||
expect(userMessage.content[0].type).toBe("image_url");
|
||||
expect(userMessage.content[0].image_url.url).toContain(
|
||||
"data:image/png;base64,",
|
||||
);
|
||||
expect(userMessage.content[1].type).toBe("text");
|
||||
expect(userMessage.content[1].text).toContain(
|
||||
"Image Attachment for Message ID: m1",
|
||||
);
|
||||
expect(userMessage.content[2].type).toBe("text");
|
||||
expect(userMessage.content[2].text).toContain(
|
||||
"You are a content moderation assistant.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,9 @@ import { createChildLogger } from "../../src/logger";
|
||||
import {
|
||||
decodeCursor,
|
||||
encodeCursor,
|
||||
getAttachmentsForMessages,
|
||||
getMessageById,
|
||||
insertAttachment,
|
||||
insertMessage,
|
||||
listMessages,
|
||||
listReviewMessages,
|
||||
@@ -72,21 +74,40 @@ describe("message query integration tests", () => {
|
||||
"ai_error" text
|
||||
)
|
||||
`);
|
||||
|
||||
// Create attachments table
|
||||
await db.run(`
|
||||
CREATE TABLE IF NOT EXISTS "attachments" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"message_id" text NOT NULL,
|
||||
"guild_id" text NOT NULL,
|
||||
"channel_id" text NOT NULL,
|
||||
"thread_id" text,
|
||||
"user_id" text NOT NULL,
|
||||
"filename" text NOT NULL,
|
||||
"size" integer NOT NULL,
|
||||
"type" text NOT NULL,
|
||||
"discord_url" text NOT NULL,
|
||||
"uploaded_url" text,
|
||||
"upload_status" text DEFAULT 'pending' NOT NULL,
|
||||
"upload_error" text,
|
||||
"created_at" integer NOT NULL,
|
||||
"uploaded_at" integer
|
||||
)
|
||||
`);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
{ error },
|
||||
"Messages table already exists or error creating it",
|
||||
);
|
||||
logger.debug({ error }, "Tables already exist or error creating them");
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear messages table before each test
|
||||
// Clear tables before each test
|
||||
try {
|
||||
const db = getTestDatabase();
|
||||
await db.run(`DELETE FROM "messages"`);
|
||||
await db.run(`DELETE FROM "attachments"`);
|
||||
} catch (error) {
|
||||
logger.debug({ error }, "Could not clear messages table");
|
||||
logger.debug({ error }, "Could not clear tables");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -579,4 +600,58 @@ describe("message query integration tests", () => {
|
||||
expect(retrieved?.ai_error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttachmentsForMessages", () => {
|
||||
it("returns attachments matching given message IDs", async () => {
|
||||
const msgId1 = "msg-att-1";
|
||||
const msgId2 = "msg-att-2";
|
||||
|
||||
const attachment1 = {
|
||||
id: "att-1",
|
||||
message_id: msgId1,
|
||||
guild_id: "guild-123",
|
||||
channel_id: "channel-456",
|
||||
thread_id: null,
|
||||
user_id: "user-789",
|
||||
filename: "test1.png",
|
||||
size: 1024,
|
||||
type: "image/png",
|
||||
discord_url: "https://discord.com/test1.png",
|
||||
uploaded_url: "https://picser.tech/test1.png",
|
||||
upload_status: "uploaded" as const,
|
||||
upload_error: null,
|
||||
created_at: Date.now(),
|
||||
uploaded_at: Date.now(),
|
||||
};
|
||||
|
||||
const attachment2 = {
|
||||
id: "att-2",
|
||||
message_id: msgId2,
|
||||
guild_id: "guild-123",
|
||||
channel_id: "channel-456",
|
||||
thread_id: null,
|
||||
user_id: "user-789",
|
||||
filename: "test2.png",
|
||||
size: 2048,
|
||||
type: "image/png",
|
||||
discord_url: "https://discord.com/test2.png",
|
||||
uploaded_url: "https://picser.tech/test2.png",
|
||||
upload_status: "uploaded" as const,
|
||||
upload_error: null,
|
||||
created_at: Date.now(),
|
||||
uploaded_at: Date.now(),
|
||||
};
|
||||
|
||||
await insertAttachment(attachment1);
|
||||
await insertAttachment(attachment2);
|
||||
|
||||
const result = await getAttachmentsForMessages([msgId1, msgId2]);
|
||||
expect(result).toHaveLength(2);
|
||||
const ids = result.map((r) => r.id).sort();
|
||||
expect(ids).toEqual(["att-1", "att-2"].sort());
|
||||
|
||||
const emptyResult = await getAttachmentsForMessages([]);
|
||||
expect(emptyResult).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,8 @@ describe("playTranscodedPreparedStream", () => {
|
||||
it("pipes transcoder output to session and broadcasts to web", async () => {
|
||||
// mock global broadcast
|
||||
const broadcasts: Buffer[] = [];
|
||||
(globalThis as any).broadcastVideoToWeb = (chunk: Buffer) => broadcasts.push(Buffer.from(chunk));
|
||||
(globalThis as any).broadcastVideoToWeb = (chunk: Buffer) =>
|
||||
broadcasts.push(Buffer.from(chunk));
|
||||
|
||||
const session = {
|
||||
connection: { channel: { id: "c" } },
|
||||
@@ -52,7 +53,9 @@ describe("playTranscodedPreparedStream", () => {
|
||||
stop: vi.fn(),
|
||||
} as any;
|
||||
|
||||
await playTranscodedPreparedStream("http://example.test/stream", session, { fps: 30 });
|
||||
await playTranscodedPreparedStream("http://example.test/stream", session, {
|
||||
fps: 30,
|
||||
});
|
||||
expect(session.play).toHaveBeenCalled();
|
||||
expect(broadcasts.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
@@ -39,7 +39,10 @@ import { prepareTranscoder } from "../../src/streaming/transcoder";
|
||||
|
||||
describe("Transcoder", () => {
|
||||
it("starts ffmpeg and returns output stream and command", () => {
|
||||
const { transcoder, command, output } = prepareTranscoder("http://example.test/video", { fps: 24 });
|
||||
const { transcoder, command, output } = prepareTranscoder(
|
||||
"http://example.test/video",
|
||||
{ fps: 24 },
|
||||
);
|
||||
expect(transcoder).toBeTruthy();
|
||||
expect(command).toBeTruthy();
|
||||
expect(output).toBeTruthy();
|
||||
|
||||
Reference in New Issue
Block a user