feat(config): add AI analysis tuning parameters and update related logic
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import { config } from "../config.ts";
|
||||
import { initializeDatabase } from "../database/drizzle.ts";
|
||||
import { buildConversationPromptMessages } from "./conversationContext.ts";
|
||||
import { runModerationAnalysis } from "./llmModerationClient.ts";
|
||||
@@ -9,8 +10,6 @@ import {
|
||||
} from "./messageStore.ts";
|
||||
import type { MessageRecord } from "./types";
|
||||
|
||||
const MAX_CONTEXT_TOKENS = 8000;
|
||||
|
||||
let dbInitialized = false;
|
||||
|
||||
interface AnalysisWorkerRequest {
|
||||
@@ -58,13 +57,13 @@ async function processAnalysisRequest({
|
||||
channelId: firstMessage.channel_id,
|
||||
threadId: firstMessage.thread_id,
|
||||
beforeCreatedAt: firstMessage.created_at,
|
||||
limit: 20,
|
||||
limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT,
|
||||
});
|
||||
|
||||
const promptMessages = buildConversationPromptMessages({
|
||||
contextBefore,
|
||||
targets: messages,
|
||||
maxTokens: MAX_CONTEXT_TOKENS,
|
||||
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
|
||||
});
|
||||
|
||||
const targetIds = messages.map((m) => m.id);
|
||||
|
||||
@@ -31,10 +31,6 @@ const conversationErrorCooldown = new Map<string, number>();
|
||||
|
||||
let activeRequests = 0;
|
||||
let lastError: string | null = null;
|
||||
const DEBOUNCE_MS = 1500;
|
||||
const RECOVERY_INTERVAL_MS = 15000;
|
||||
const ERROR_COOLDOWN_MS = 30000;
|
||||
const MAX_BATCH_SIZE = 25;
|
||||
|
||||
interface AnalysisWorkerResponse {
|
||||
ok: boolean;
|
||||
@@ -98,7 +94,7 @@ async function processBatch(
|
||||
lastError = result.error ?? "Analysis worker failed";
|
||||
conversationErrorCooldown.set(
|
||||
conversationKey,
|
||||
Date.now() + ERROR_COOLDOWN_MS,
|
||||
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
||||
);
|
||||
logger.error(
|
||||
{
|
||||
@@ -106,7 +102,9 @@ async function processBatch(
|
||||
error: lastError,
|
||||
messageCount: messages.length,
|
||||
messageIds: messages.map((m) => m.id),
|
||||
cooldownUntil: new Date(Date.now() + ERROR_COOLDOWN_MS).toISOString(),
|
||||
cooldownUntil: new Date(
|
||||
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
||||
).toISOString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"Batch analysis failed, will retry after cooldown",
|
||||
@@ -120,7 +118,7 @@ async function processBatch(
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
conversationErrorCooldown.set(
|
||||
conversationKey,
|
||||
Date.now() + ERROR_COOLDOWN_MS,
|
||||
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
||||
);
|
||||
logger.error(
|
||||
{
|
||||
@@ -129,7 +127,9 @@ async function processBatch(
|
||||
stack: errorStack,
|
||||
messageCount: messages.length,
|
||||
messageIds: messages.map((m) => m.id),
|
||||
cooldownUntil: new Date(Date.now() + ERROR_COOLDOWN_MS).toISOString(),
|
||||
cooldownUntil: new Date(
|
||||
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
||||
).toISOString(),
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
"Analysis worker failed, will retry after cooldown",
|
||||
@@ -188,7 +188,7 @@ function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
}
|
||||
|
||||
// Always use shorter debounce for immediate processing (no concurrency limit)
|
||||
const debounceTime = Math.min(DEBOUNCE_MS, 500);
|
||||
const debounceTime = config.AI_ANALYSIS_DEBOUNCE_MS;
|
||||
|
||||
// Set new debounced timer
|
||||
const timer = setTimeout(async () => {
|
||||
@@ -197,7 +197,7 @@ function scheduleConversationAnalysis(conversationKey: string): void {
|
||||
// Get pending messages for this conversation
|
||||
const messages = await getPendingMessagesByConversation(
|
||||
conversationKey,
|
||||
MAX_BATCH_SIZE,
|
||||
config.AI_ANALYSIS_MAX_BATCH_SIZE,
|
||||
);
|
||||
|
||||
if (messages.length > 0) {
|
||||
@@ -290,5 +290,5 @@ export function startPendingAIAnalysisWorker(): void {
|
||||
} catch (error) {
|
||||
logger.error({ error }, "Pending AI analysis recovery worker failed");
|
||||
}
|
||||
}, RECOVERY_INTERVAL_MS);
|
||||
}, config.AI_ANALYSIS_RECOVERY_INTERVAL_MS);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { config } from "../config";
|
||||
import { createChildLogger } from "../logger";
|
||||
import { retryWithBackoff } from "../retry";
|
||||
import { uploadToTele } from "../uploader/teleUpload";
|
||||
import {
|
||||
updateAttachmentAsFailedUpload,
|
||||
updateAttachmentAsUploaded,
|
||||
@@ -8,83 +8,26 @@ import {
|
||||
|
||||
const logger = createChildLogger("attachment-uploader");
|
||||
|
||||
const ATTACHMENT_UPLOAD_RETRY_OPTIONS = {
|
||||
retries: config.ATTACHMENT_RETRY_ATTEMPTS,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 5000,
|
||||
logger,
|
||||
} as const;
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export interface PicserUploadResponse {
|
||||
success: boolean;
|
||||
filename: string;
|
||||
urls: {
|
||||
raw_commit?: string;
|
||||
[key: string]: string | undefined;
|
||||
};
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ParsedUploadResponse {
|
||||
success: boolean;
|
||||
url: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function parseUploadResponse(
|
||||
response: PicserUploadResponse,
|
||||
): ParsedUploadResponse {
|
||||
if (!response.success) {
|
||||
throw new Error("Upload failed: success=false");
|
||||
}
|
||||
|
||||
const rawCommitUrl = response.urls.raw_commit;
|
||||
if (!rawCommitUrl) {
|
||||
throw new Error("Upload response missing raw_commit URL");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: rawCommitUrl,
|
||||
filename: response.filename,
|
||||
size: response.size,
|
||||
type: response.type,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadAttachmentToPicser(
|
||||
export async function uploadAttachmentToTele(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
): Promise<ParsedUploadResponse> {
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([new Uint8Array(fileBuffer)], {
|
||||
type: "application/octet-stream",
|
||||
});
|
||||
formData.append("file", blob, filename);
|
||||
|
||||
): Promise<string> {
|
||||
try {
|
||||
const response = await retryWithBackoff(async () => {
|
||||
const res = await fetch(config.PICSER_UPLOAD_URL, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(config.ATTACHMENT_UPLOAD_TIMEOUT_MS),
|
||||
});
|
||||
const result = await uploadToTele({
|
||||
buffer: fileBuffer,
|
||||
filename,
|
||||
contentType: "application/octet-stream",
|
||||
uploadUrl: config.TELE_UPLOAD_URL,
|
||||
timeoutMs: config.ATTACHMENT_UPLOAD_TIMEOUT_MS,
|
||||
retries: config.ATTACHMENT_RETRY_ATTEMPTS,
|
||||
logger,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Upload failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<PicserUploadResponse>;
|
||||
}, ATTACHMENT_UPLOAD_RETRY_OPTIONS);
|
||||
|
||||
return parseUploadResponse(response);
|
||||
return result.url;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
@@ -133,9 +76,9 @@ export async function processAttachmentUpload(
|
||||
);
|
||||
}
|
||||
|
||||
const result = await uploadAttachmentToPicser(buffer, filename);
|
||||
const uploadedUrl = await uploadAttachmentToTele(buffer, filename);
|
||||
|
||||
await updateAttachmentAsUploaded(attachmentId, result.url, Date.now());
|
||||
await updateAttachmentAsUploaded(attachmentId, uploadedUrl, Date.now());
|
||||
} catch (error) {
|
||||
const errorMsg = toErrorMessage(error);
|
||||
await updateAttachmentAsFailedUpload(attachmentId, errorMsg);
|
||||
|
||||
@@ -32,45 +32,39 @@ export function buildConversationPromptMessages(
|
||||
): string[] {
|
||||
const { contextBefore, targets, maxTokens } = input;
|
||||
|
||||
// Format all messages
|
||||
const formatMessage = (msg: MessageRecord, label: string): string => {
|
||||
const content = msg.edited_content ?? msg.content;
|
||||
const timestamp = formatTimestamp(msg.created_at);
|
||||
return `[${label}] id=${msg.id} time=${timestamp} user=${msg.username}: ${content}`;
|
||||
};
|
||||
|
||||
const targetLines = targets.map((msg) => formatMessage(msg, "target"));
|
||||
const contextLines = contextBefore.map((msg) =>
|
||||
formatMessage(msg, "context"),
|
||||
);
|
||||
const targetEntries = targets.map((msg) => ({
|
||||
msg,
|
||||
label: "target" as const,
|
||||
line: formatMessage(msg, "target"),
|
||||
}));
|
||||
|
||||
// Calculate tokens for targets (always include)
|
||||
let usedTokens = targetLines.reduce(
|
||||
(sum, line) => sum + estimateTokens(line),
|
||||
let usedTokens = targetEntries.reduce(
|
||||
(sum, entry) => sum + estimateTokens(entry.line),
|
||||
0,
|
||||
);
|
||||
|
||||
// Add context lines in reverse chronological order (most recent first)
|
||||
// until we hit the token budget
|
||||
const selectedContextLines: string[] = [];
|
||||
for (let i = contextLines.length - 1; i >= 0; i--) {
|
||||
const line = contextLines[i];
|
||||
const selectedContextEntries: Array<{
|
||||
msg: MessageRecord;
|
||||
label: "context";
|
||||
line: string;
|
||||
}> = [];
|
||||
for (let i = contextBefore.length - 1; i >= 0; i--) {
|
||||
const msg = contextBefore[i];
|
||||
const line = formatMessage(msg, "context");
|
||||
const lineTokens = estimateTokens(line);
|
||||
if (usedTokens + lineTokens <= maxTokens) {
|
||||
selectedContextLines.unshift(line); // prepend to maintain chronological order
|
||||
selectedContextEntries.push({ msg, label: "context", line });
|
||||
usedTokens += lineTokens;
|
||||
}
|
||||
}
|
||||
|
||||
// Combine: context (chronological) + targets (chronological)
|
||||
const allMessages = [...selectedContextLines, ...targetLines];
|
||||
|
||||
// Sort by timestamp to ensure chronological order
|
||||
allMessages.sort((a, b) => {
|
||||
const timeA = a.match(/time=([^\s]+)/)?.[1] ?? "";
|
||||
const timeB = b.match(/time=([^\s]+)/)?.[1] ?? "";
|
||||
return timeA.localeCompare(timeB);
|
||||
});
|
||||
|
||||
return allMessages;
|
||||
return [...selectedContextEntries, ...targetEntries]
|
||||
.sort((a, b) => a.msg.created_at - b.msg.created_at)
|
||||
.map((entry) => entry.line);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,48 @@ 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.
|
||||
@@ -321,17 +363,11 @@ export async function runModerationAnalysis(
|
||||
|
||||
const targetIds = targets.map((t) => t.id);
|
||||
|
||||
// Build prompt
|
||||
const messagesText = targets
|
||||
.map((msg) => `[${msg.id}] ${msg.username}: ${msg.content}`)
|
||||
.join("\n");
|
||||
|
||||
const prompt = `You are a content moderation assistant. Analyze the following messages for policy violations.
|
||||
|
||||
Context: ${contextText}
|
||||
|
||||
Messages to analyze:
|
||||
${messagesText}
|
||||
const systemPrompt = `You are a content moderation assistant. Analyze messages for policy violations.
|
||||
|
||||
For each message, respond with a JSON object containing a "results" array.
|
||||
CRITICAL: You MUST return the "message_id" EXACTLY as provided in the input, and it MUST be wrapped in double quotes as a STRING. Do not treat IDs as numbers.
|
||||
@@ -346,12 +382,21 @@ Each result must have:
|
||||
Do not include reasoning, analysis steps, markdown, prose, XML tags, or comments.
|
||||
Return ONLY valid JSON, no other text.`;
|
||||
|
||||
const userPrompt = `Context: ${contextText}
|
||||
|
||||
Messages to analyze:
|
||||
${messagesText}`;
|
||||
|
||||
// Check for image attachments to support multimodal analysis
|
||||
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 || [])
|
||||
.filter(
|
||||
(att) =>
|
||||
(att.uploaded_url || att.discord_url) && att.type.startsWith("image/"),
|
||||
(att) => getAttachmentImageUrl(att) && att.type.startsWith("image/"),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const aIsTarget = targetIdSet.has(a.message_id) ? 1 : 0;
|
||||
@@ -367,76 +412,62 @@ Return ONLY valid JSON, no other text.`;
|
||||
| string
|
||||
| Array<{ type: string; text?: string; image_url?: { url: string } }>;
|
||||
if (imageAttachments.length > 0) {
|
||||
const contentParts: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
image_url?: { url: string };
|
||||
}> = [];
|
||||
const imageParts = await Promise.all(
|
||||
imageAttachments.map(async (att) => {
|
||||
try {
|
||||
const urlToUse = getAttachmentImageUrl(att);
|
||||
if (!urlToUse) return [];
|
||||
log.info(
|
||||
{ attachmentId: att.id, url: urlToUse },
|
||||
"Downloading attachment for base64 encoding",
|
||||
);
|
||||
const res = await fetch(urlToUse);
|
||||
if (!res.ok) {
|
||||
log.warn(
|
||||
{ attachmentId: att.id, status: res.status },
|
||||
"Failed to fetch attachment image",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Download and convert all images to base64 data URLs
|
||||
for (const att of imageAttachments) {
|
||||
try {
|
||||
const urlToUse = att.uploaded_url || att.discord_url;
|
||||
log.info(
|
||||
{ attachmentId: att.id, url: urlToUse },
|
||||
"Downloading attachment for base64 encoding",
|
||||
);
|
||||
const res = await fetch(urlToUse);
|
||||
if (res.ok) {
|
||||
const buffer = await res.arrayBuffer();
|
||||
const base64Str = Buffer.from(buffer).toString("base64");
|
||||
const dataUrl = `data:${att.type};base64,${base64Str}`;
|
||||
|
||||
contentParts.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: dataUrl,
|
||||
return [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: dataUrl,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: `\n[Image Attachment for Message ID: ${att.message_id}, Filename: ${att.filename}]`,
|
||||
});
|
||||
} else {
|
||||
{
|
||||
type: "text",
|
||||
text: `\n[Image Attachment for Message ID: ${att.message_id}, Filename: ${att.filename}]`,
|
||||
},
|
||||
];
|
||||
} catch (err) {
|
||||
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 [];
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
{
|
||||
attachmentId: att.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
"Error base64 encoding attachment",
|
||||
);
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
contentParts.push({
|
||||
type: "text",
|
||||
text: prompt,
|
||||
});
|
||||
|
||||
messageContent = contentParts;
|
||||
} else {
|
||||
// If no image is present, send a transparent 1x1 dummy PNG to satisfy multimodal omni requirements
|
||||
const dummyPng =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||
messageContent = [
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: dummyPng,
|
||||
},
|
||||
},
|
||||
...imageParts.flat(),
|
||||
{
|
||||
type: "text",
|
||||
text: prompt,
|
||||
text: userPrompt,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
messageContent = userPrompt;
|
||||
}
|
||||
|
||||
const result = await retryWithBackoff(
|
||||
@@ -460,6 +491,10 @@ Return ONLY valid JSON, no other text.`;
|
||||
body: JSON.stringify({
|
||||
model: config.AI_LLM_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: systemPrompt,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: messageContent,
|
||||
@@ -500,16 +535,10 @@ Return ONLY valid JSON, no other text.`;
|
||||
throw new Error("Empty LLM response");
|
||||
}
|
||||
|
||||
// Try to parse the body as JSON, with fallback to scanning for an object
|
||||
try {
|
||||
return JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
const start = rawBody.indexOf("{");
|
||||
const end = rawBody.lastIndexOf("}");
|
||||
if (start !== -1 && end !== -1 && end > start) {
|
||||
return JSON.parse(rawBody.substring(start, end + 1));
|
||||
}
|
||||
throw e;
|
||||
} catch {
|
||||
return parseFirstJsonObject(rawBody);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -134,7 +134,7 @@ export async function captureMessage(
|
||||
|
||||
await insertAttachment(attachmentRecord);
|
||||
|
||||
// Initiate async upload to Picser (non-blocking, fire-and-forget)
|
||||
// Initiate async upload (non-blocking, fire-and-forget)
|
||||
if (!isBacklog) {
|
||||
processAttachmentUpload(
|
||||
attachment.id,
|
||||
|
||||
Reference in New Issue
Block a user