feat(config): add AI analysis tuning parameters and update related logic

This commit is contained in:
MythEclipse
2026-05-21 01:55:50 +07:00
parent 5fb7b2ce24
commit 3d64228d6a
19 changed files with 644 additions and 293 deletions
+3 -4
View File
@@ -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);
+11 -11
View File
@@ -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);
}
+15 -72
View File
@@ -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);
+19 -25
View File
@@ -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);
}
+101 -72
View File
@@ -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);
+1 -1
View File
@@ -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,