feat(moderation): enhance JSON extraction and validation in moderation analysis
This commit is contained in:
@@ -6,12 +6,18 @@
|
|||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"packageManager": "pnpm@11.1.3",
|
"packageManager": "pnpm@11.1.3",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prepare:vendor": "pnpm --filter './vendor/*' --filter '!discord.js-selfbot-v13' --if-present run build",
|
||||||
|
"predev": "pnpm run prepare:vendor",
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"predev:server": "pnpm run prepare:vendor",
|
||||||
"dev:server": "tsx watch src/index.ts",
|
"dev:server": "tsx watch src/index.ts",
|
||||||
"dev:web": "vite --host 0.0.0.0 frontend",
|
"dev:web": "vite --host 0.0.0.0 frontend",
|
||||||
|
"prestart": "pnpm run prepare:vendor",
|
||||||
"start": "tsx src/index.ts",
|
"start": "tsx src/index.ts",
|
||||||
|
"prebuild": "pnpm run prepare:vendor",
|
||||||
"build": "pnpm run build:web && tsc --outDir dist",
|
"build": "pnpm run build:web && tsc --outDir dist",
|
||||||
"build:web": "vite build frontend --outDir ../public/app --emptyOutDir",
|
"build:web": "vite build frontend --outDir ../public/app --emptyOutDir",
|
||||||
|
"pretypecheck": "pnpm run prepare:vendor",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "biome check --diagnostic-level=error .",
|
"lint": "biome check --diagnostic-level=error .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
|
|||||||
@@ -55,11 +55,9 @@ interface RawModerationResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to extract a JSON object from a potentially conversational or markdown-wrapped string.
|
* Helper to extract JSON 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.
|
|
||||||
*/
|
*/
|
||||||
export function extractJson(content: string): any {
|
export function extractJson(content: string): any {
|
||||||
// 1. Try to find markdown json code blocks: ```json ... ``` or ``` ... ```
|
|
||||||
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/g;
|
const codeBlockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/g;
|
||||||
const matches = content.matchAll(codeBlockRegex);
|
const matches = content.matchAll(codeBlockRegex);
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
@@ -69,32 +67,53 @@ export function extractJson(content: string): any {
|
|||||||
if (parsed && typeof parsed === "object") {
|
if (parsed && typeof parsed === "object") {
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_) {}
|
||||||
// Continue to next code block
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. If no code blocks parse successfully, try scanning for {...} pairs
|
for (let start = 0; start < content.length; start++) {
|
||||||
const openBraces: number[] = [];
|
const firstChar = content[start];
|
||||||
const closeBraces: number[] = [];
|
if (firstChar !== "{" && firstChar !== "[") continue;
|
||||||
for (let i = 0; i < content.length; i++) {
|
|
||||||
if (content[i] === "{") openBraces.push(i);
|
|
||||||
if (content[i] === "}") closeBraces.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try pairs from largest span to smallest
|
const stack = [firstChar];
|
||||||
for (const start of openBraces) {
|
let inString = false;
|
||||||
for (let j = closeBraces.length - 1; j >= 0; j--) {
|
let escaped = false;
|
||||||
const end = closeBraces[j];
|
|
||||||
if (end > start) {
|
for (let i = start + 1; i < content.length; i++) {
|
||||||
const candidate = content.substring(start, end + 1);
|
const char = content[i];
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(candidate);
|
if (inString) {
|
||||||
if (parsed && typeof parsed === "object") {
|
if (escaped) {
|
||||||
return parsed;
|
escaped = false;
|
||||||
}
|
} else if (char === "\\") {
|
||||||
} catch (e) {
|
escaped = true;
|
||||||
// ignore and try next
|
} else if (char === '"') {
|
||||||
|
inString = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inString = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (char === "{" || char === "[") {
|
||||||
|
stack.push(char);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const last = stack[stack.length - 1];
|
||||||
|
if ((char === "}" && last === "{") || (char === "]" && last === "[")) {
|
||||||
|
stack.pop();
|
||||||
|
if (stack.length === 0) {
|
||||||
|
const candidate = content.slice(start, i + 1);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(candidate);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,111 +466,152 @@ Return ONLY valid JSON, no other text.`;
|
|||||||
})
|
})
|
||||||
.slice(0, 8); // Cap at 8 to prevent LLM API limits (e.g. Nemotron/Omni models 8-image limit)
|
.slice(0, 8); // Cap at 8 to prevent LLM API limits (e.g. Nemotron/Omni models 8-image limit)
|
||||||
|
|
||||||
let messageContent:
|
type MessageContent =
|
||||||
| string
|
| string
|
||||||
| Array<{ type: string; text?: string; image_url?: { url: string } }>;
|
| Array<{ type: string; text?: string; image_url?: { url: string } }>;
|
||||||
|
|
||||||
|
let imageParts: Array<{
|
||||||
|
type: string;
|
||||||
|
text?: string;
|
||||||
|
image_url?: { url: string };
|
||||||
|
}> = [];
|
||||||
if (imageAttachments.length > 0) {
|
if (imageAttachments.length > 0) {
|
||||||
const imageParts = await Promise.all(
|
imageParts = (
|
||||||
imageAttachments.map(async (att) => {
|
await Promise.all(
|
||||||
try {
|
imageAttachments.map(async (att) => {
|
||||||
const urlToUse = getAttachmentImageUrl(att);
|
try {
|
||||||
if (!urlToUse) return [];
|
const urlToUse = getAttachmentImageUrl(att);
|
||||||
log.info(
|
if (!urlToUse) return [];
|
||||||
{ attachmentId: att.id, url: urlToUse },
|
log.info(
|
||||||
"Downloading attachment for base64 encoding",
|
{ attachmentId: att.id, url: urlToUse },
|
||||||
);
|
"Downloading attachment for base64 encoding",
|
||||||
const res = await fetch(urlToUse);
|
);
|
||||||
if (!res.ok) {
|
const res = await fetch(urlToUse);
|
||||||
|
if (!res.ok) {
|
||||||
|
log.warn(
|
||||||
|
{ attachmentId: att.id, status: res.status },
|
||||||
|
"Failed to fetch attachment image",
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
const base64Str = Buffer.from(buffer).toString("base64");
|
||||||
|
const dataUrl = `data:${att.type};base64,${base64Str}`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "image_url",
|
||||||
|
image_url: {
|
||||||
|
url: dataUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: `\n[Image Attachment for Message ID: ${att.message_id}, Filename: ${att.filename}]`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} catch (err) {
|
||||||
log.warn(
|
log.warn(
|
||||||
{ attachmentId: att.id, status: res.status },
|
{
|
||||||
"Failed to fetch attachment image",
|
attachmentId: att.id,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
},
|
||||||
|
"Error base64 encoding attachment",
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
const buffer = await res.arrayBuffer();
|
)
|
||||||
const base64Str = Buffer.from(buffer).toString("base64");
|
).flat();
|
||||||
const dataUrl = `data:${att.type};base64,${base64Str}`;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
type: "image_url",
|
|
||||||
image_url: {
|
|
||||||
url: dataUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `\n[Image Attachment for Message ID: ${att.message_id}, Filename: ${att.filename}]`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(
|
|
||||||
{
|
|
||||||
attachmentId: att.id,
|
|
||||||
error: err instanceof Error ? err.message : String(err),
|
|
||||||
},
|
|
||||||
"Error base64 encoding attachment",
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
messageContent = [
|
|
||||||
...imageParts.flat(),
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: moderationPrompt,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
messageContent = moderationPrompt;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await retryWithBackoff(
|
let lastParseError: string | null = null;
|
||||||
() =>
|
let lastInvalidContent: string | null = null;
|
||||||
openai.chat.completions.create({
|
const buildMessageContent = (): MessageContent => {
|
||||||
model: config.AI_LLM_MODEL,
|
const correctionPrompt = lastParseError
|
||||||
messages: [
|
? `${moderationPrompt}\n\nPrevious response failed validation. Error: ${lastParseError}\nInvalid response preview:\n${lastInvalidContent?.slice(0, 1000) ?? "<empty>"}\n\nRetry with corrected output. Return ONLY one valid JSON object matching the required schema.`
|
||||||
{
|
: moderationPrompt;
|
||||||
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,
|
|
||||||
maxTimeout: 10000,
|
|
||||||
logger: log,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Extract content from response
|
if (imageParts.length > 0) {
|
||||||
if (!result.choices || !Array.isArray(result.choices) || !result.choices[0]) {
|
return [
|
||||||
throw new Error("Invalid LLM response structure");
|
...imageParts,
|
||||||
}
|
{
|
||||||
|
type: "text",
|
||||||
|
text: correctionPrompt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
const content = result.choices[0].message?.content;
|
return correctionPrompt;
|
||||||
if (!content) {
|
};
|
||||||
throw new Error("No content in LLM response");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and validate
|
|
||||||
let parsed: AnalysisResult[];
|
let parsed: AnalysisResult[];
|
||||||
|
let result: OpenAI.Chat.Completions.ChatCompletion | null = null;
|
||||||
try {
|
try {
|
||||||
parsed = parseModerationResponse(content, targetIds);
|
const analysis = await retryWithBackoff(
|
||||||
|
async () => {
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
model: config.AI_LLM_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: buildMessageContent(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!completion.choices ||
|
||||||
|
!Array.isArray(completion.choices) ||
|
||||||
|
!completion.choices[0]
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid LLM response structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = completion.choices[0].message?.content;
|
||||||
|
if (!content) {
|
||||||
|
throw new Error("No content in LLM response");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
parsed: parseModerationResponse(content, targetIds),
|
||||||
|
result: completion,
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
lastParseError =
|
||||||
|
parseError instanceof Error
|
||||||
|
? parseError.message
|
||||||
|
: String(parseError);
|
||||||
|
lastInvalidContent = content;
|
||||||
|
throw parseError;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retries: 3,
|
||||||
|
minTimeout: 1000,
|
||||||
|
maxTimeout: 10000,
|
||||||
|
logger: log,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
parsed = analysis.parsed;
|
||||||
|
result = analysis.result;
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
if (!lastInvalidContent) {
|
||||||
|
throw parseError;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
parseError instanceof Error ? parseError.message : String(parseError);
|
parseError instanceof Error ? parseError.message : String(parseError);
|
||||||
|
const content: string = lastInvalidContent;
|
||||||
const salvaged = salvageMalformedModerationResponse(content, targetIds);
|
const salvaged = salvageMalformedModerationResponse(content, targetIds);
|
||||||
if (salvaged) {
|
if (salvaged) {
|
||||||
log.warn(
|
log.warn(
|
||||||
|
|||||||
@@ -6,7 +6,17 @@ import {
|
|||||||
import type { MessageRecord } from "../../src/moderation/types";
|
import type { MessageRecord } from "../../src/moderation/types";
|
||||||
|
|
||||||
vi.mock("../../src/retry", () => ({
|
vi.mock("../../src/retry", () => ({
|
||||||
retryWithBackoff: vi.fn((fn) => fn()),
|
retryWithBackoff: vi.fn(async (fn) => {
|
||||||
|
let lastError: unknown;
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError;
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,6 +132,49 @@ describe("parseModerationResponse", () => {
|
|||||||
expect(result[0].messageId).toBe("m1");
|
expect(result[0].messageId).toBe("m1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles trailing JSON after first moderation object", () => {
|
||||||
|
const moderationJson = JSON.stringify({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
message_id: "m1",
|
||||||
|
status: "clean",
|
||||||
|
flags: [],
|
||||||
|
score: 0.1,
|
||||||
|
analysis: "OK",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const trailingLogJson = JSON.stringify({ msg: "Retry attempt" });
|
||||||
|
|
||||||
|
const result = parseModerationResponse(
|
||||||
|
`${moderationJson}\n${trailingLogJson}`,
|
||||||
|
["m1"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].messageId).toBe("m1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles braces inside string values", () => {
|
||||||
|
const result = parseModerationResponse(
|
||||||
|
JSON.stringify({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
message_id: "m1",
|
||||||
|
status: "clean",
|
||||||
|
flags: [],
|
||||||
|
score: 0.1,
|
||||||
|
analysis: "Contains literal braces: {not json}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
["m1"],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].analysis).toBe("Contains literal braces: {not json}");
|
||||||
|
});
|
||||||
|
|
||||||
it("handles nested fields in results", () => {
|
it("handles nested fields in results", () => {
|
||||||
const content = JSON.stringify({
|
const content = JSON.stringify({
|
||||||
results: [
|
results: [
|
||||||
@@ -554,6 +607,77 @@ describe("runModerationAnalysis", () => {
|
|||||||
expect(result.results[0].messageId).toBe("m1");
|
expect(result.results[0].messageId).toBe("m1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes previous validation error in retry prompt", async () => {
|
||||||
|
const invalidResponse = {
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
message_id: "m1",
|
||||||
|
status: "bad",
|
||||||
|
flags: [],
|
||||||
|
score: 0.1,
|
||||||
|
analysis: "Invalid",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const validResponse = {
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
content: JSON.stringify({
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
message_id: "m1",
|
||||||
|
status: "clean",
|
||||||
|
flags: [],
|
||||||
|
score: 0.1,
|
||||||
|
analysis: "OK",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
global.fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: async () => JSON.stringify(invalidResponse),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: async () => JSON.stringify(validResponse),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await runModerationAnalysis({
|
||||||
|
targets: [createMessageRecord()],
|
||||||
|
contextText: "test context",
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondRequestBody = JSON.parse(
|
||||||
|
(global.fetch as any).mock.calls[1][1].body,
|
||||||
|
);
|
||||||
|
expect(secondRequestBody.messages[0].content).toContain(
|
||||||
|
"Previous response failed validation",
|
||||||
|
);
|
||||||
|
expect(secondRequestBody.messages[0].content).toContain(
|
||||||
|
"Invalid status: bad",
|
||||||
|
);
|
||||||
|
expect(secondRequestBody.messages[0].content).toContain(
|
||||||
|
"Retry with corrected output",
|
||||||
|
);
|
||||||
|
expect(result.results[0].status).toBe("clean");
|
||||||
|
});
|
||||||
|
|
||||||
it("throws on missing choices in response", async () => {
|
it("throws on missing choices in response", async () => {
|
||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -1039,8 +1163,7 @@ describe("runModerationAnalysis", () => {
|
|||||||
expect(result.results[0].status).toBe("warn");
|
expect(result.results[0].status).toBe("warn");
|
||||||
|
|
||||||
const requestBody = JSON.parse((global.fetch as any).mock.calls[1][1].body);
|
const requestBody = JSON.parse((global.fetch as any).mock.calls[1][1].body);
|
||||||
expect(requestBody.messages[0].content).toHaveLength(1);
|
expect(requestBody.messages[0].content).toContain(
|
||||||
expect(requestBody.messages[0].content[0].text).toContain(
|
|
||||||
"https://example.invalid/claim",
|
"https://example.invalid/claim",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user