diff --git a/.env.example b/.env.example index 7f47c3f..f96d35c 100644 --- a/.env.example +++ b/.env.example @@ -48,7 +48,6 @@ AI_ANALYSIS_ENABLED=false AI_LLM_API_KEY=your_9router_key_here AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1 AI_LLM_MODEL=free -AI_ANALYSIS_TIMEOUT_MS=30000 # Database Configuration DATABASE_TYPE=sqlite diff --git a/frontend/src/components/review/ReviewPanel.tsx b/frontend/src/components/review/ReviewPanel.tsx index 6ac0682..5ecaad3 100644 --- a/frontend/src/components/review/ReviewPanel.tsx +++ b/frontend/src/components/review/ReviewPanel.tsx @@ -1,6 +1,7 @@ import type { MessageRecord } from "../../types/messages"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; import { MessageFeed } from "../messages/MessageFeed"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; export interface ReviewPanelProps { messages: MessageRecord[]; @@ -8,21 +9,40 @@ export interface ReviewPanelProps { } export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) { - const reviewItems = messages.filter( - (message) => - message.ai_status === "warn" || - message.ai_status === "flagged" || - message.ai_status === "error", + const flaggedItems = messages.filter( + (message) => message.ai_status === "warn" || message.ai_status === "flagged", ); + const errorItems = messages.filter((message) => message.ai_status === "error"); return ( Needs Review - {reviewItems.length} captured messages require attention. + + {flaggedItems.length} flagged messages, {errorItems.length} analysis errors. + - + + + Flags ({flaggedItems.length}) + Errors ({errorItems.length}) + + + + + + + + ); diff --git a/package.json b/package.json index d38a2f6..3ff5655 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "helmet": "^8.1.0", "libsodium-wrappers": "^0.8.4", "lucide-react": "^1.16.0", + "openai": "^6.38.0", "p-retry": "^8.0.0", "pg": "^8.21.0", "play-dl": "^1.9.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ad39b6..5ebdadd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: lucide-react: specifier: ^1.16.0 version: 1.16.0(react@19.2.6) + openai: + specifier: ^6.38.0 + version: 6.38.0(ws@8.20.1)(zod@4.4.3) p-retry: specifier: ^8.0.0 version: 8.0.0 @@ -3450,6 +3453,18 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + openai@6.38.0: + resolution: {integrity: sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + optionator@0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} engines: {node: '>= 0.8.0'} @@ -7467,6 +7482,11 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@6.38.0(ws@8.20.1)(zod@4.4.3): + optionalDependencies: + ws: 8.20.1 + zod: 4.4.3 + optionator@0.8.3: dependencies: deep-is: 0.1.4 diff --git a/src/config.ts b/src/config.ts index 771ae27..8f132c5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -68,7 +68,6 @@ const configSchema = z .url() .default("https://9router.asepharyana.tech/v1"), AI_LLM_MODEL: z.string().default("free"), - AI_ANALYSIS_TIMEOUT_MS: z.coerce.number().positive().default(30000), AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500), AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce .number() diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index eeb5cdc..e2c58db 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -25,10 +25,12 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined { // Debounce state per conversation key const conversationDebounceTimers = new Map(); // Track conversations currently being processed -const conversationProcessing = new Set(); +const conversationProcessing = new Map(); // Track conversations in error cooldown (failed recently) const conversationErrorCooldown = new Map(); +const AI_PROCESSING_OVERLAP_MS = 30000; + let activeRequests = 0; let lastError: string | null = null; @@ -72,6 +74,13 @@ export function pickBatchWithinBudget( return batch; } +function isConversationProcessingLocked(conversationKey: string): boolean { + const startedAt = conversationProcessing.get(conversationKey); + return Boolean( + startedAt && Date.now() - startedAt < AI_PROCESSING_OVERLAP_MS, + ); +} + /** * Processes a batch of messages for a conversation */ @@ -82,7 +91,8 @@ async function processBatch( if (messages.length === 0) return; activeRequests++; - conversationProcessing.add(conversationKey); + const processingStartedAt = Date.now(); + conversationProcessing.set(conversationKey, processingStartedAt); try { const result = await runAnalysisInWorker(conversationKey, messages); @@ -136,7 +146,9 @@ async function processBatch( ); } finally { activeRequests--; - conversationProcessing.delete(conversationKey); + if (conversationProcessing.get(conversationKey) === processingStartedAt) { + conversationProcessing.delete(conversationKey); + } } } @@ -171,7 +183,7 @@ async function runAnalysisInWorker( */ function scheduleConversationAnalysis(conversationKey: string): void { // Skip if already processing - if (conversationProcessing.has(conversationKey)) { + if (isConversationProcessingLocked(conversationKey)) { return; } @@ -275,7 +287,7 @@ export function startPendingAIAnalysisWorker(): void { } // Skip if currently processing - if (conversationProcessing.has(key)) { + if (isConversationProcessingLocked(key)) { continue; } diff --git a/src/moderation/attachmentUploader.ts b/src/moderation/attachmentUploader.ts index 7bb0d61..25c390e 100644 --- a/src/moderation/attachmentUploader.ts +++ b/src/moderation/attachmentUploader.ts @@ -4,14 +4,34 @@ import { uploadToTele } from "../uploader/teleUpload"; import { updateAttachmentAsFailedUpload, updateAttachmentAsUploaded, + updateAttachmentDiscordUrl, } from "./messageStore"; const logger = createChildLogger("attachment-uploader"); +class AttachmentDownloadError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "AttachmentDownloadError"; + } +} + +export type RefreshDiscordAttachmentUrl = () => Promise; + function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function shouldRefreshDiscordUrl(error: unknown): boolean { + return ( + error instanceof AttachmentDownloadError && + (error.status === 403 || error.status === 404) + ); +} + export async function uploadAttachmentToTele( fileBuffer: Buffer, filename: string, @@ -47,7 +67,10 @@ export async function downloadDiscordAttachment(url: string): Promise { }); if (!response.ok) { - throw new Error(`Download failed with status ${response.status}`); + throw new AttachmentDownloadError( + `Download failed with status ${response.status}`, + response.status, + ); } const buffer = await response.arrayBuffer(); @@ -65,9 +88,24 @@ export async function processAttachmentUpload( attachmentId: string, discordUrl: string, filename: string, + options: { refreshDiscordUrl?: RefreshDiscordAttachmentUrl } = {}, ): Promise { try { - const buffer = await downloadDiscordAttachment(discordUrl); + let currentDiscordUrl = discordUrl; + let buffer: Buffer; + try { + buffer = await downloadDiscordAttachment(currentDiscordUrl); + } catch (error) { + if (!options.refreshDiscordUrl || !shouldRefreshDiscordUrl(error)) { + throw error; + } + + const freshUrl = await options.refreshDiscordUrl(); + if (!freshUrl) throw error; + currentDiscordUrl = freshUrl; + await updateAttachmentDiscordUrl(attachmentId, freshUrl); + buffer = await downloadDiscordAttachment(currentDiscordUrl); + } const sizeMb = buffer.length / (1024 * 1024); if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) { diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 3688d30..542c41d 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -1,9 +1,30 @@ +import OpenAI from "openai"; import { config } from "../config.ts"; import { createChildLogger } from "../logger.ts"; import { retryWithBackoff } from "../retry.ts"; import type { AnalysisResult, AttachmentRecord, MessageRecord } from "./types"; const log = createChildLogger("llmModerationClient"); +const openai = new OpenAI({ + apiKey: config.AI_LLM_API_KEY, + baseURL: config.AI_LLM_BASE_URL, + maxRetries: 0, + timeout: 2_147_483_647, + fetch: async (url, init) => { + const response = await globalThis.fetch(url, init); + if (response.headers) return response; + + const body = + typeof response.text === "function" + ? await response.text() + : JSON.stringify(await response.json()); + + return new Response(body, { + status: response.status ?? 200, + headers: { "Content-Type": "application/json" }, + }); + }, +}); interface RawModerationResult { message_id: string; @@ -17,48 +38,6 @@ interface RawModerationResponse { results: RawModerationResult[]; } -function parseFirstJsonObject(content: string): unknown { - for ( - let start = content.indexOf("{"); - start !== -1; - start = content.indexOf("{", start + 1) - ) { - let depth = 0; - let inString = false; - let escaped = false; - - for (let index = start; index < content.length; index++) { - const char = content[index]; - - if (escaped) { - escaped = false; - continue; - } - - if (char === "\\") { - escaped = inString; - continue; - } - - if (char === '"') { - inString = !inString; - continue; - } - - if (inString) continue; - - if (char === "{") depth++; - if (char === "}") depth--; - - if (depth === 0) { - return JSON.parse(content.slice(start, index + 1)); - } - } - } - - throw new Error("No JSON object found in response body"); -} - /** * Helper to extract a JSON object from a potentially conversational or markdown-wrapped string. * It first scans for markdown json code blocks, then falls back to trying all start/end brace pairs from largest to smallest. @@ -113,6 +92,49 @@ export function extractJson(content: string): any { * Extracts JSON from surrounding text, validates structure, and transforms to AnalysisResult[]. * Scans from first '{' and attempts JSON.parse at each candidate closing brace. */ +function salvageMalformedModerationResponse( + content: string, + targetIds: string[], +): AnalysisResult[] | null { + const idMatches = content.match(/\d{10,22}/g) ?? []; + let matchedId: string | null = null; + + for (const targetId of targetIds) { + if (content.includes(targetId)) { + matchedId = targetId; + break; + } + } + + if (!matchedId) { + for (const candidate of idMatches) { + matchedId = + targetIds.find( + (targetId) => + targetId.startsWith(candidate) || candidate.startsWith(targetId), + ) ?? null; + if (matchedId) break; + } + } + + if (!matchedId) return null; + + const statusMatch = content.match(/"status"\s*:\s*"(clean|warn|flagged)"/); + const scoreMatch = content.match(/"score"\s*:\s*(\d+(?:\.\d+)?)/); + const analysisMatch = content.match(/"analysis"\s*:\s*"([^"]*)"/); + + return [ + { + messageId: matchedId, + status: (statusMatch?.[1] as "clean" | "warn" | "flagged") ?? "clean", + flags: [], + score: scoreMatch ? Math.max(0, Math.min(1, Number(scoreMatch[1]))) : 0, + analysis: + analysisMatch?.[1] ?? "Recovered from malformed moderation response", + }, + ]; +} + export function parseModerationResponse( content: string, targetIds: string[], @@ -260,9 +282,11 @@ export function parseModerationResponse( } if (!targetIdSet.has(finalId)) { - throw new Error( - `Unknown message_id: ${finalId} (original: ${message_id})`, + log.warn( + { unknownId: finalId, originalId: message_id, targetIds }, + "Skipping moderation result for non-target message_id", ); + return null; } if (foundIds.has(finalId)) { @@ -322,12 +346,11 @@ export function parseModerationResponse( { missingIds, foundCount: foundIds.size, totalCount: targetIds.length }, "Some target IDs missing in response - marking as incomplete", ); - // Add clean results for missing IDs instead of failing the batch for (const missingId of missingIds) { filteredResults.push({ messageId: missingId, - status: "clean", - flags: [], + status: "error", + flags: ["analysis_incomplete"], score: 0, analysis: "Analysis incomplete - LLM did not process this message", }); @@ -391,7 +414,6 @@ ${messagesText}`; const targetIdSet = new Set(targets.map((t) => t.id)); const getAttachmentImageUrl = (att: AttachmentRecord): string | null => { if (att.uploaded_url) return att.uploaded_url; - if (targetIdSet.has(att.message_id)) return att.discord_url; return null; }; const imageAttachments = (attachments || []) @@ -471,79 +493,27 @@ ${messagesText}`; } const result = await retryWithBackoff( - async () => { - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(), - config.AI_ANALYSIS_TIMEOUT_MS, - ); - - try { - const response = await fetch( - `${config.AI_LLM_BASE_URL}/chat/completions`, + () => + openai.chat.completions.create({ + model: config.AI_LLM_MODEL, + messages: [ { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${config.AI_LLM_API_KEY}`, - }, - signal: controller.signal, - body: JSON.stringify({ - model: config.AI_LLM_MODEL, - messages: [ - { - role: "system", - content: systemPrompt, - }, - { - role: "user", - content: messageContent, - }, - ], - temperature: 0, - top_p: 1, - max_tokens: 8192, - response_format: { type: "json_object" }, - chat_template_kwargs: { enable_thinking: false }, - }), + role: "system", + content: systemPrompt, }, - ); - // Read the response body once (either text() or json()), then reuse it. - let rawBody: string | undefined = undefined; - if (typeof response.text === "function") { - try { - rawBody = await response.text(); - } catch { - rawBody = undefined; - } - } else if (typeof response.json === "function") { - try { - const j = await response.json(); - rawBody = JSON.stringify(j); - } catch { - rawBody = undefined; - } - } - - if (!response.ok) { - throw new Error( - `LLM API error ${response.status}: ${rawBody ?? "(no body)"}`, - ); - } - - if (!rawBody) { - throw new Error("Empty LLM response"); - } - - try { - return JSON.parse(rawBody); - } catch { - return parseFirstJsonObject(rawBody); - } - } finally { - clearTimeout(timeoutId); - } - }, + { + role: "user", + content: messageContent, + }, + ], + temperature: 0.2, + top_p: 0.95, + max_tokens: 65536, + response_format: { type: "json_object" }, + stream: false, + chat_template_kwargs: { enable_thinking: false }, + reasoning_budget: 0, + } as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming), { retries: 3, minTimeout: 1000, @@ -569,25 +539,54 @@ ${messagesText}`; } catch (parseError) { const errorMsg = parseError instanceof Error ? parseError.message : String(parseError); - log.error( - { - error: errorMsg, - contentLength: content.length, - contentPreview: content.substring(0, 500), - fullContent: content, - targetIds, - model: config.AI_LLM_MODEL, - timestamp: new Date().toISOString(), - }, - "Robust Fallback: Failed to parse moderation response. Defaulting all targets to clean.", - ); - parsed = targetIds.map((id) => ({ - messageId: id, - status: "clean", - flags: [], - score: 0.1, - analysis: `Parsing failed: ${errorMsg}. Defaulted to clean.`, - })); + const salvaged = salvageMalformedModerationResponse(content, targetIds); + if (salvaged) { + log.warn( + { + error: errorMsg, + contentLength: content.length, + contentPreview: content.substring(0, 500), + targetIds, + recoveredIds: salvaged.map((result) => result.messageId), + model: config.AI_LLM_MODEL, + timestamp: new Date().toISOString(), + }, + "Recovered moderation response from malformed JSON", + ); + const recoveredIds = new Set(salvaged.map((result) => result.messageId)); + parsed = [ + ...salvaged, + ...targetIds + .filter((id) => !recoveredIds.has(id)) + .map((id) => ({ + messageId: id, + status: "error" as const, + flags: ["analysis_incomplete"], + score: 0, + analysis: "Analysis incomplete - malformed LLM response", + })), + ]; + } else { + log.error( + { + error: errorMsg, + contentLength: content.length, + contentPreview: content.substring(0, 500), + fullContent: content, + targetIds, + model: config.AI_LLM_MODEL, + timestamp: new Date().toISOString(), + }, + "Robust Fallback: Failed to parse moderation response. Defaulting all targets to clean.", + ); + parsed = targetIds.map((id) => ({ + messageId: id, + status: "error", + flags: ["analysis_parse_failed"], + score: 0, + analysis: `Parsing failed: ${errorMsg}.`, + })); + } } log.info( diff --git a/src/moderation/messageCapture.ts b/src/moderation/messageCapture.ts index 67957dc..c1209c8 100644 --- a/src/moderation/messageCapture.ts +++ b/src/moderation/messageCapture.ts @@ -121,6 +121,8 @@ export async function captureMessage( broadcaster.messageCreated(messageRecord); } + const attachmentUploadTasks: Promise[] = []; + // Insert attachments before queuing analysis to avoid race condition if (message.attachments.size > 0) { for (const [, attachment] of message.attachments) { @@ -136,16 +138,29 @@ export async function captureMessage( // Initiate async upload (non-blocking, fire-and-forget) if (!isBacklog) { - processAttachmentUpload( - attachment.id, - attachment.url, - attachment.name || "unknown", - ).catch((err) => { - logger.error( - { attachmentId: attachment.id, error: err }, - "Failed to initiate attachment upload", - ); - }); + attachmentUploadTasks.push( + processAttachmentUpload( + attachment.id, + attachment.url, + attachment.name || "unknown", + { + refreshDiscordUrl: async () => { + const freshMessage = await message.channel.messages.fetch( + message.id, + ); + const freshAttachment = freshMessage.attachments.get( + attachment.id, + ); + return freshAttachment?.url ?? null; + }, + }, + ).catch((err) => { + logger.error( + { attachmentId: attachment.id, error: err }, + "Failed to initiate attachment upload", + ); + }), + ); } if (broadcaster) { @@ -154,9 +169,20 @@ export async function captureMessage( } } - // Queue analysis after attachments are inserted + // Queue analysis after attachment uploads settle so AI uses stable tele URLs. if (!isBacklog) { - queueMessageAnalysis(message.id); + if (attachmentUploadTasks.length > 0) { + Promise.allSettled(attachmentUploadTasks) + .then(() => queueMessageAnalysis(message.id)) + .catch((err) => { + logger.error( + { messageId: message.id, error: err }, + "Failed to queue message analysis after attachment upload", + ); + }); + } else { + queueMessageAnalysis(message.id); + } } } diff --git a/src/moderation/messageStore.ts b/src/moderation/messageStore.ts index 5910ffe..7e7eaea 100644 --- a/src/moderation/messageStore.ts +++ b/src/moderation/messageStore.ts @@ -325,6 +325,28 @@ export async function updateAttachmentAsUploaded( } } +export async function updateAttachmentDiscordUrl( + attachmentId: string, + discordUrl: string, +): Promise { + try { + const database = db(); + await database + .update(attachmentsTable) + .set({ discord_url: discordUrl }) + .where(eq(attachmentsTable.id, attachmentId)); + } catch (error) { + logger.error( + { + attachmentId, + error: error instanceof Error ? error.message : String(error), + }, + "Failed to update attachment Discord URL", + ); + throw error; + } +} + export async function updateAttachmentAsFailedUpload( attachmentId: string, error: string, diff --git a/src/moderation/types.ts b/src/moderation/types.ts index 5eb1566..2084477 100644 --- a/src/moderation/types.ts +++ b/src/moderation/types.ts @@ -86,7 +86,7 @@ export interface PageResult { export interface AnalysisResult { messageId: string; - status: Exclude; + status: Exclude; flags: string[]; score: number; analysis: string; diff --git a/tests/moderation/attachmentUploader.test.ts b/tests/moderation/attachmentUploader.test.ts index 9986769..2f3ebee 100644 --- a/tests/moderation/attachmentUploader.test.ts +++ b/tests/moderation/attachmentUploader.test.ts @@ -1,6 +1,28 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const updateAttachmentAsFailedUpload = vi.fn(); +const updateAttachmentAsUploaded = vi.fn(); +const updateAttachmentDiscordUrl = vi.fn(); +const uploadToTele = vi.fn(); + +vi.mock("../../src/moderation/messageStore", () => ({ + updateAttachmentAsFailedUpload, + updateAttachmentAsUploaded, + updateAttachmentDiscordUrl, +})); + +vi.mock("../../src/uploader/teleUpload", async () => { + const actual = await vi.importActual< + typeof import("../../src/uploader/teleUpload") + >("../../src/uploader/teleUpload"); + return { + ...actual, + uploadToTele, + }; +}); beforeEach(() => { + vi.clearAllMocks(); process.env = { ...process.env, DISCORD_TOKEN: "test-token", @@ -37,4 +59,60 @@ describe("attachmentUploader", () => { /download_url/, ); }); + + it("refreshes Discord URL after expired CDN response", async () => { + const { processAttachmentUpload } = await import( + "../../src/moderation/attachmentUploader" + ); + const oldBytes = Buffer.from("old"); + const freshBytes = Buffer.from("fresh"); + + global.fetch = vi.fn().mockImplementation((url: string) => { + if (url === "https://cdn.discordapp.com/old.png") { + return Promise.resolve({ ok: false, status: 404 }); + } + if (url === "https://cdn.discordapp.com/fresh.png") { + return Promise.resolve({ + ok: true, + arrayBuffer: async () => + freshBytes.buffer.slice( + freshBytes.byteOffset, + freshBytes.byteOffset + freshBytes.byteLength, + ), + }); + } + return Promise.resolve({ + ok: true, + arrayBuffer: async () => + oldBytes.buffer.slice( + oldBytes.byteOffset, + oldBytes.byteOffset + oldBytes.byteLength, + ), + }); + }); + uploadToTele.mockResolvedValue({ url: "https://upload.example/fresh.png" }); + + await processAttachmentUpload( + "att-1", + "https://cdn.discordapp.com/old.png", + "image.png", + { + refreshDiscordUrl: async () => "https://cdn.discordapp.com/fresh.png", + }, + ); + + expect(updateAttachmentDiscordUrl).toHaveBeenCalledWith( + "att-1", + "https://cdn.discordapp.com/fresh.png", + ); + expect(uploadToTele).toHaveBeenCalledWith( + expect.objectContaining({ buffer: freshBytes, filename: "image.png" }), + ); + expect(updateAttachmentAsUploaded).toHaveBeenCalledWith( + "att-1", + "https://upload.example/fresh.png", + expect.any(Number), + ); + expect(updateAttachmentAsFailedUpload).not.toHaveBeenCalled(); + }); }); diff --git a/tests/moderation/llmModerationClient.test.ts b/tests/moderation/llmModerationClient.test.ts index 0184d51..909fb68 100644 --- a/tests/moderation/llmModerationClient.test.ts +++ b/tests/moderation/llmModerationClient.test.ts @@ -73,28 +73,33 @@ describe("parseModerationResponse", () => { ]); expect(result).toHaveLength(1); expect(result[0].messageId).toBe("m1"); - expect(result[0].status).toBe("clean"); + 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"); }); - it("rejects unknown ids", () => { - expect(() => - parseModerationResponse( - JSON.stringify({ - results: [ - { - message_id: "m2", - status: "clean", - flags: [], - score: 0, - analysis: "OK", - }, - ], - }), - ["m1"], - ), - ).toThrow(/unknown/i); + it("skips unknown ids and fills missing targets", () => { + const result = parseModerationResponse( + JSON.stringify({ + results: [ + { + message_id: "m2", + status: "clean", + flags: [], + score: 0, + analysis: "OK", + }, + ], + }), + ["m1"], + ); + + expect(result).toHaveLength(1); + 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"); }); it("handles surrounding text around JSON", () => { @@ -412,9 +417,10 @@ describe("runModerationAnalysis", () => { }); const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body); - expect(requestBody.temperature).toBe(0); + expect(requestBody.temperature).toBe(0.2); expect(requestBody.response_format).toEqual({ type: "json_object" }); - expect(requestBody.reasoning_budget).toBeUndefined(); + expect(requestBody.stream).toBe(false); + expect(requestBody.reasoning_budget).toBe(0); expect(requestBody.chat_template_kwargs).toEqual({ enable_thinking: false, }); @@ -472,25 +478,26 @@ describe("runModerationAnalysis", () => { targets: [createMessageRecord()], contextText: "test context", }), - ).rejects.toThrow(/LLM API error 500/); + ).rejects.toThrow(/500/); }); it("parses first JSON object when provider appends extra JSON", async () => { + const moderationJson = JSON.stringify({ + results: [ + { + message_id: "m1", + status: "clean", + flags: [], + score: 0.1, + analysis: "OK", + }, + ], + }); const mockResponse = { choices: [ { message: { - content: JSON.stringify({ - results: [ - { - message_id: "m1", - status: "clean", - flags: [], - score: 0.1, - analysis: "OK", - }, - ], - }), + content: `${moderationJson}\nextra`, }, }, ], @@ -498,8 +505,7 @@ describe("runModerationAnalysis", () => { global.fetch = vi.fn().mockResolvedValue({ ok: true, - text: async () => - `${JSON.stringify(mockResponse)}\n{"usage":{"tokens":12}}`, + text: async () => JSON.stringify(mockResponse), }); const result = await runModerationAnalysis({ @@ -852,7 +858,7 @@ describe("runModerationAnalysis", () => { expect(contentParts.at(-1).text).toContain("Sebelumnya user lain bilang"); }); - it("falls back to discord_url when uploaded_url is not ready", async () => { + it("skips pending discord-only images until tele upload is ready", async () => { const mockResponse = { choices: [ { @@ -922,9 +928,11 @@ describe("runModerationAnalysis", () => { ], }); - expect((global.fetch as any).mock.calls[0][0]).toBe( - "https://httpbin.org/image/png", + const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body); + expect((global.fetch as any).mock.calls[0][0]).toContain( + "/chat/completions", ); + expect(typeof requestBody.messages[1].content).toBe("string"); }); it("keeps analyzing text when an image URL returns non-OK", async () => { @@ -1396,23 +1404,25 @@ describe("runModerationAnalysis", () => { ).toThrow(); }); - it("throws on mismatched message IDs", () => { - expect(() => - parseModerationResponse( - JSON.stringify({ - results: [ - { - message_id: "m999", - status: "clean", - flags: [], - score: 0.1, - analysis: "OK", - }, - ], - }), - ["m1"], - ), - ).toThrow(/unknown.*message_id/i); + it("skips mismatched message IDs", () => { + const result = parseModerationResponse( + JSON.stringify({ + results: [ + { + message_id: "m999", + status: "clean", + flags: [], + score: 0.1, + analysis: "OK", + }, + ], + }), + ["m1"], + ); + + expect(result).toHaveLength(1); + expect(result[0].messageId).toBe("m1"); + expect(result[0].analysis).toContain("incomplete"); }); it("throws on invalid status value", () => {