feat(moderation): enhance attachment handling and AI analysis integration
This commit is contained in:
@@ -48,7 +48,6 @@ AI_ANALYSIS_ENABLED=false
|
|||||||
AI_LLM_API_KEY=your_9router_key_here
|
AI_LLM_API_KEY=your_9router_key_here
|
||||||
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
||||||
AI_LLM_MODEL=free
|
AI_LLM_MODEL=free
|
||||||
AI_ANALYSIS_TIMEOUT_MS=30000
|
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_TYPE=sqlite
|
DATABASE_TYPE=sqlite
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { MessageRecord } from "../../types/messages";
|
import type { MessageRecord } from "../../types/messages";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
|
|
||||||
import { MessageFeed } from "../messages/MessageFeed";
|
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 {
|
export interface ReviewPanelProps {
|
||||||
messages: MessageRecord[];
|
messages: MessageRecord[];
|
||||||
@@ -8,21 +9,40 @@ export interface ReviewPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
|
export function ReviewPanel({ messages, onReanalyze }: ReviewPanelProps) {
|
||||||
const reviewItems = messages.filter(
|
const flaggedItems = messages.filter(
|
||||||
(message) =>
|
(message) => message.ai_status === "warn" || message.ai_status === "flagged",
|
||||||
message.ai_status === "warn" ||
|
|
||||||
message.ai_status === "flagged" ||
|
|
||||||
message.ai_status === "error",
|
|
||||||
);
|
);
|
||||||
|
const errorItems = messages.filter((message) => message.ai_status === "error");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Needs Review</CardTitle>
|
<CardTitle>Needs Review</CardTitle>
|
||||||
<CardDescription>{reviewItems.length} captured messages require attention.</CardDescription>
|
<CardDescription>
|
||||||
|
{flaggedItems.length} flagged messages, {errorItems.length} analysis errors.
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<MessageFeed messages={reviewItems} onReanalyze={onReanalyze} emptyText="No warned, flagged, or errored messages." />
|
<Tabs defaultValue="flags">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="flags">Flags ({flaggedItems.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="errors">Errors ({errorItems.length})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="flags">
|
||||||
|
<MessageFeed
|
||||||
|
messages={flaggedItems}
|
||||||
|
onReanalyze={onReanalyze}
|
||||||
|
emptyText="No warned or flagged messages."
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="errors">
|
||||||
|
<MessageFeed
|
||||||
|
messages={errorItems}
|
||||||
|
onReanalyze={onReanalyze}
|
||||||
|
emptyText="No analysis errors."
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"libsodium-wrappers": "^0.8.4",
|
"libsodium-wrappers": "^0.8.4",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
|
"openai": "^6.38.0",
|
||||||
"p-retry": "^8.0.0",
|
"p-retry": "^8.0.0",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"play-dl": "^1.9.7",
|
"play-dl": "^1.9.7",
|
||||||
|
|||||||
Generated
+20
@@ -59,6 +59,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.16.0
|
specifier: ^1.16.0
|
||||||
version: 1.16.0(react@19.2.6)
|
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:
|
p-retry:
|
||||||
specifier: ^8.0.0
|
specifier: ^8.0.0
|
||||||
version: 8.0.0
|
version: 8.0.0
|
||||||
@@ -3450,6 +3453,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
|
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
|
||||||
engines: {node: '>=8'}
|
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:
|
optionator@0.8.3:
|
||||||
resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==}
|
resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -7467,6 +7482,11 @@ snapshots:
|
|||||||
is-docker: 2.2.1
|
is-docker: 2.2.1
|
||||||
is-wsl: 2.2.0
|
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:
|
optionator@0.8.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
deep-is: 0.1.4
|
deep-is: 0.1.4
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ const configSchema = z
|
|||||||
.url()
|
.url()
|
||||||
.default("https://9router.asepharyana.tech/v1"),
|
.default("https://9router.asepharyana.tech/v1"),
|
||||||
AI_LLM_MODEL: z.string().default("free"),
|
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_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
||||||
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
|
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
|
||||||
.number()
|
.number()
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined {
|
|||||||
// Debounce state per conversation key
|
// Debounce state per conversation key
|
||||||
const conversationDebounceTimers = new Map<string, NodeJS.Timeout>();
|
const conversationDebounceTimers = new Map<string, NodeJS.Timeout>();
|
||||||
// Track conversations currently being processed
|
// Track conversations currently being processed
|
||||||
const conversationProcessing = new Set<string>();
|
const conversationProcessing = new Map<string, number>();
|
||||||
// Track conversations in error cooldown (failed recently)
|
// Track conversations in error cooldown (failed recently)
|
||||||
const conversationErrorCooldown = new Map<string, number>();
|
const conversationErrorCooldown = new Map<string, number>();
|
||||||
|
|
||||||
|
const AI_PROCESSING_OVERLAP_MS = 30000;
|
||||||
|
|
||||||
let activeRequests = 0;
|
let activeRequests = 0;
|
||||||
let lastError: string | null = null;
|
let lastError: string | null = null;
|
||||||
|
|
||||||
@@ -72,6 +74,13 @@ export function pickBatchWithinBudget(
|
|||||||
return batch;
|
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
|
* Processes a batch of messages for a conversation
|
||||||
*/
|
*/
|
||||||
@@ -82,7 +91,8 @@ async function processBatch(
|
|||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
activeRequests++;
|
activeRequests++;
|
||||||
conversationProcessing.add(conversationKey);
|
const processingStartedAt = Date.now();
|
||||||
|
conversationProcessing.set(conversationKey, processingStartedAt);
|
||||||
try {
|
try {
|
||||||
const result = await runAnalysisInWorker(conversationKey, messages);
|
const result = await runAnalysisInWorker(conversationKey, messages);
|
||||||
|
|
||||||
@@ -136,7 +146,9 @@ async function processBatch(
|
|||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
activeRequests--;
|
activeRequests--;
|
||||||
conversationProcessing.delete(conversationKey);
|
if (conversationProcessing.get(conversationKey) === processingStartedAt) {
|
||||||
|
conversationProcessing.delete(conversationKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +183,7 @@ async function runAnalysisInWorker(
|
|||||||
*/
|
*/
|
||||||
function scheduleConversationAnalysis(conversationKey: string): void {
|
function scheduleConversationAnalysis(conversationKey: string): void {
|
||||||
// Skip if already processing
|
// Skip if already processing
|
||||||
if (conversationProcessing.has(conversationKey)) {
|
if (isConversationProcessingLocked(conversationKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,7 +287,7 @@ export function startPendingAIAnalysisWorker(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip if currently processing
|
// Skip if currently processing
|
||||||
if (conversationProcessing.has(key)) {
|
if (isConversationProcessingLocked(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,34 @@ import { uploadToTele } from "../uploader/teleUpload";
|
|||||||
import {
|
import {
|
||||||
updateAttachmentAsFailedUpload,
|
updateAttachmentAsFailedUpload,
|
||||||
updateAttachmentAsUploaded,
|
updateAttachmentAsUploaded,
|
||||||
|
updateAttachmentDiscordUrl,
|
||||||
} from "./messageStore";
|
} from "./messageStore";
|
||||||
|
|
||||||
const logger = createChildLogger("attachment-uploader");
|
const logger = createChildLogger("attachment-uploader");
|
||||||
|
|
||||||
|
class AttachmentDownloadError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AttachmentDownloadError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RefreshDiscordAttachmentUrl = () => Promise<string | null>;
|
||||||
|
|
||||||
function toErrorMessage(error: unknown): string {
|
function toErrorMessage(error: unknown): string {
|
||||||
return error instanceof Error ? error.message : String(error);
|
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(
|
export async function uploadAttachmentToTele(
|
||||||
fileBuffer: Buffer,
|
fileBuffer: Buffer,
|
||||||
filename: string,
|
filename: string,
|
||||||
@@ -47,7 +67,10 @@ export async function downloadDiscordAttachment(url: string): Promise<Buffer> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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();
|
const buffer = await response.arrayBuffer();
|
||||||
@@ -65,9 +88,24 @@ export async function processAttachmentUpload(
|
|||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
discordUrl: string,
|
discordUrl: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
|
options: { refreshDiscordUrl?: RefreshDiscordAttachmentUrl } = {},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
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);
|
const sizeMb = buffer.length / (1024 * 1024);
|
||||||
if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) {
|
if (sizeMb > config.ATTACHMENT_MAX_SIZE_MB) {
|
||||||
|
|||||||
@@ -1,9 +1,30 @@
|
|||||||
|
import OpenAI from "openai";
|
||||||
import { config } from "../config.ts";
|
import { config } from "../config.ts";
|
||||||
import { createChildLogger } from "../logger.ts";
|
import { createChildLogger } from "../logger.ts";
|
||||||
import { retryWithBackoff } from "../retry.ts";
|
import { retryWithBackoff } from "../retry.ts";
|
||||||
import type { AnalysisResult, AttachmentRecord, MessageRecord } from "./types";
|
import type { AnalysisResult, AttachmentRecord, MessageRecord } from "./types";
|
||||||
|
|
||||||
const log = createChildLogger("llmModerationClient");
|
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 {
|
interface RawModerationResult {
|
||||||
message_id: string;
|
message_id: string;
|
||||||
@@ -17,48 +38,6 @@ interface RawModerationResponse {
|
|||||||
results: RawModerationResult[];
|
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.
|
* 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.
|
* 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[].
|
* Extracts JSON from surrounding text, validates structure, and transforms to AnalysisResult[].
|
||||||
* Scans from first '{' and attempts JSON.parse at each candidate closing brace.
|
* 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(
|
export function parseModerationResponse(
|
||||||
content: string,
|
content: string,
|
||||||
targetIds: string[],
|
targetIds: string[],
|
||||||
@@ -260,9 +282,11 @@ export function parseModerationResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!targetIdSet.has(finalId)) {
|
if (!targetIdSet.has(finalId)) {
|
||||||
throw new Error(
|
log.warn(
|
||||||
`Unknown message_id: ${finalId} (original: ${message_id})`,
|
{ unknownId: finalId, originalId: message_id, targetIds },
|
||||||
|
"Skipping moderation result for non-target message_id",
|
||||||
);
|
);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (foundIds.has(finalId)) {
|
if (foundIds.has(finalId)) {
|
||||||
@@ -322,12 +346,11 @@ export function parseModerationResponse(
|
|||||||
{ missingIds, foundCount: foundIds.size, totalCount: targetIds.length },
|
{ missingIds, foundCount: foundIds.size, totalCount: targetIds.length },
|
||||||
"Some target IDs missing in response - marking as incomplete",
|
"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) {
|
for (const missingId of missingIds) {
|
||||||
filteredResults.push({
|
filteredResults.push({
|
||||||
messageId: missingId,
|
messageId: missingId,
|
||||||
status: "clean",
|
status: "error",
|
||||||
flags: [],
|
flags: ["analysis_incomplete"],
|
||||||
score: 0,
|
score: 0,
|
||||||
analysis: "Analysis incomplete - LLM did not process this message",
|
analysis: "Analysis incomplete - LLM did not process this message",
|
||||||
});
|
});
|
||||||
@@ -391,7 +414,6 @@ ${messagesText}`;
|
|||||||
const targetIdSet = new Set(targets.map((t) => t.id));
|
const targetIdSet = new Set(targets.map((t) => t.id));
|
||||||
const getAttachmentImageUrl = (att: AttachmentRecord): string | null => {
|
const getAttachmentImageUrl = (att: AttachmentRecord): string | null => {
|
||||||
if (att.uploaded_url) return att.uploaded_url;
|
if (att.uploaded_url) return att.uploaded_url;
|
||||||
if (targetIdSet.has(att.message_id)) return att.discord_url;
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
const imageAttachments = (attachments || [])
|
const imageAttachments = (attachments || [])
|
||||||
@@ -471,79 +493,27 @@ ${messagesText}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await retryWithBackoff(
|
const result = await retryWithBackoff(
|
||||||
async () => {
|
() =>
|
||||||
const controller = new AbortController();
|
openai.chat.completions.create({
|
||||||
const timeoutId = setTimeout(
|
model: config.AI_LLM_MODEL,
|
||||||
() => controller.abort(),
|
messages: [
|
||||||
config.AI_ANALYSIS_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${config.AI_LLM_BASE_URL}/chat/completions`,
|
|
||||||
{
|
{
|
||||||
method: "POST",
|
role: "system",
|
||||||
headers: {
|
content: systemPrompt,
|
||||||
"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 },
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
);
|
{
|
||||||
// Read the response body once (either text() or json()), then reuse it.
|
role: "user",
|
||||||
let rawBody: string | undefined = undefined;
|
content: messageContent,
|
||||||
if (typeof response.text === "function") {
|
},
|
||||||
try {
|
],
|
||||||
rawBody = await response.text();
|
temperature: 0.2,
|
||||||
} catch {
|
top_p: 0.95,
|
||||||
rawBody = undefined;
|
max_tokens: 65536,
|
||||||
}
|
response_format: { type: "json_object" },
|
||||||
} else if (typeof response.json === "function") {
|
stream: false,
|
||||||
try {
|
chat_template_kwargs: { enable_thinking: false },
|
||||||
const j = await response.json();
|
reasoning_budget: 0,
|
||||||
rawBody = JSON.stringify(j);
|
} as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming),
|
||||||
} 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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
retries: 3,
|
retries: 3,
|
||||||
minTimeout: 1000,
|
minTimeout: 1000,
|
||||||
@@ -569,25 +539,54 @@ ${messagesText}`;
|
|||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
parseError instanceof Error ? parseError.message : String(parseError);
|
parseError instanceof Error ? parseError.message : String(parseError);
|
||||||
log.error(
|
const salvaged = salvageMalformedModerationResponse(content, targetIds);
|
||||||
{
|
if (salvaged) {
|
||||||
error: errorMsg,
|
log.warn(
|
||||||
contentLength: content.length,
|
{
|
||||||
contentPreview: content.substring(0, 500),
|
error: errorMsg,
|
||||||
fullContent: content,
|
contentLength: content.length,
|
||||||
targetIds,
|
contentPreview: content.substring(0, 500),
|
||||||
model: config.AI_LLM_MODEL,
|
targetIds,
|
||||||
timestamp: new Date().toISOString(),
|
recoveredIds: salvaged.map((result) => result.messageId),
|
||||||
},
|
model: config.AI_LLM_MODEL,
|
||||||
"Robust Fallback: Failed to parse moderation response. Defaulting all targets to clean.",
|
timestamp: new Date().toISOString(),
|
||||||
);
|
},
|
||||||
parsed = targetIds.map((id) => ({
|
"Recovered moderation response from malformed JSON",
|
||||||
messageId: id,
|
);
|
||||||
status: "clean",
|
const recoveredIds = new Set(salvaged.map((result) => result.messageId));
|
||||||
flags: [],
|
parsed = [
|
||||||
score: 0.1,
|
...salvaged,
|
||||||
analysis: `Parsing failed: ${errorMsg}. Defaulted to clean.`,
|
...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(
|
log.info(
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export async function captureMessage(
|
|||||||
broadcaster.messageCreated(messageRecord);
|
broadcaster.messageCreated(messageRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const attachmentUploadTasks: Promise<void>[] = [];
|
||||||
|
|
||||||
// Insert attachments before queuing analysis to avoid race condition
|
// Insert attachments before queuing analysis to avoid race condition
|
||||||
if (message.attachments.size > 0) {
|
if (message.attachments.size > 0) {
|
||||||
for (const [, attachment] of message.attachments) {
|
for (const [, attachment] of message.attachments) {
|
||||||
@@ -136,16 +138,29 @@ export async function captureMessage(
|
|||||||
|
|
||||||
// Initiate async upload (non-blocking, fire-and-forget)
|
// Initiate async upload (non-blocking, fire-and-forget)
|
||||||
if (!isBacklog) {
|
if (!isBacklog) {
|
||||||
processAttachmentUpload(
|
attachmentUploadTasks.push(
|
||||||
attachment.id,
|
processAttachmentUpload(
|
||||||
attachment.url,
|
attachment.id,
|
||||||
attachment.name || "unknown",
|
attachment.url,
|
||||||
).catch((err) => {
|
attachment.name || "unknown",
|
||||||
logger.error(
|
{
|
||||||
{ attachmentId: attachment.id, error: err },
|
refreshDiscordUrl: async () => {
|
||||||
"Failed to initiate attachment upload",
|
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) {
|
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) {
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,28 @@ export async function updateAttachmentAsUploaded(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateAttachmentDiscordUrl(
|
||||||
|
attachmentId: string,
|
||||||
|
discordUrl: string,
|
||||||
|
): Promise<void> {
|
||||||
|
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(
|
export async function updateAttachmentAsFailedUpload(
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
error: string,
|
error: string,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export interface PageResult<T> {
|
|||||||
|
|
||||||
export interface AnalysisResult {
|
export interface AnalysisResult {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
status: Exclude<AIStatus, "pending" | "error">;
|
status: Exclude<AIStatus, "pending">;
|
||||||
flags: string[];
|
flags: string[];
|
||||||
score: number;
|
score: number;
|
||||||
analysis: string;
|
analysis: string;
|
||||||
|
|||||||
@@ -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(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
process.env = {
|
process.env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
DISCORD_TOKEN: "test-token",
|
DISCORD_TOKEN: "test-token",
|
||||||
@@ -37,4 +59,60 @@ describe("attachmentUploader", () => {
|
|||||||
/download_url/,
|
/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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,28 +73,33 @@ describe("parseModerationResponse", () => {
|
|||||||
]);
|
]);
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].messageId).toBe("m1");
|
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].score).toBe(0);
|
||||||
expect(result[0].analysis).toContain("incomplete");
|
expect(result[0].analysis).toContain("incomplete");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unknown ids", () => {
|
it("skips unknown ids and fills missing targets", () => {
|
||||||
expect(() =>
|
const result = parseModerationResponse(
|
||||||
parseModerationResponse(
|
JSON.stringify({
|
||||||
JSON.stringify({
|
results: [
|
||||||
results: [
|
{
|
||||||
{
|
message_id: "m2",
|
||||||
message_id: "m2",
|
status: "clean",
|
||||||
status: "clean",
|
flags: [],
|
||||||
flags: [],
|
score: 0,
|
||||||
score: 0,
|
analysis: "OK",
|
||||||
analysis: "OK",
|
},
|
||||||
},
|
],
|
||||||
],
|
}),
|
||||||
}),
|
["m1"],
|
||||||
["m1"],
|
);
|
||||||
),
|
|
||||||
).toThrow(/unknown/i);
|
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", () => {
|
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);
|
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.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({
|
expect(requestBody.chat_template_kwargs).toEqual({
|
||||||
enable_thinking: false,
|
enable_thinking: false,
|
||||||
});
|
});
|
||||||
@@ -472,25 +478,26 @@ describe("runModerationAnalysis", () => {
|
|||||||
targets: [createMessageRecord()],
|
targets: [createMessageRecord()],
|
||||||
contextText: "test context",
|
contextText: "test context",
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(/LLM API error 500/);
|
).rejects.toThrow(/500/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("parses first JSON object when provider appends extra JSON", async () => {
|
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 = {
|
const mockResponse = {
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
message: {
|
message: {
|
||||||
content: JSON.stringify({
|
content: `${moderationJson}\nextra`,
|
||||||
results: [
|
|
||||||
{
|
|
||||||
message_id: "m1",
|
|
||||||
status: "clean",
|
|
||||||
flags: [],
|
|
||||||
score: 0.1,
|
|
||||||
analysis: "OK",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -498,8 +505,7 @@ describe("runModerationAnalysis", () => {
|
|||||||
|
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
text: async () =>
|
text: async () => JSON.stringify(mockResponse),
|
||||||
`${JSON.stringify(mockResponse)}\n{"usage":{"tokens":12}}`,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await runModerationAnalysis({
|
const result = await runModerationAnalysis({
|
||||||
@@ -852,7 +858,7 @@ describe("runModerationAnalysis", () => {
|
|||||||
expect(contentParts.at(-1).text).toContain("Sebelumnya user lain bilang");
|
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 = {
|
const mockResponse = {
|
||||||
choices: [
|
choices: [
|
||||||
{
|
{
|
||||||
@@ -922,9 +928,11 @@ describe("runModerationAnalysis", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((global.fetch as any).mock.calls[0][0]).toBe(
|
const requestBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
||||||
"https://httpbin.org/image/png",
|
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 () => {
|
it("keeps analyzing text when an image URL returns non-OK", async () => {
|
||||||
@@ -1396,23 +1404,25 @@ describe("runModerationAnalysis", () => {
|
|||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on mismatched message IDs", () => {
|
it("skips mismatched message IDs", () => {
|
||||||
expect(() =>
|
const result = parseModerationResponse(
|
||||||
parseModerationResponse(
|
JSON.stringify({
|
||||||
JSON.stringify({
|
results: [
|
||||||
results: [
|
{
|
||||||
{
|
message_id: "m999",
|
||||||
message_id: "m999",
|
status: "clean",
|
||||||
status: "clean",
|
flags: [],
|
||||||
flags: [],
|
score: 0.1,
|
||||||
score: 0.1,
|
analysis: "OK",
|
||||||
analysis: "OK",
|
},
|
||||||
},
|
],
|
||||||
],
|
}),
|
||||||
}),
|
["m1"],
|
||||||
["m1"],
|
);
|
||||||
),
|
|
||||||
).toThrow(/unknown.*message_id/i);
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].messageId).toBe("m1");
|
||||||
|
expect(result[0].analysis).toContain("incomplete");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on invalid status value", () => {
|
it("throws on invalid status value", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user