2026-05-14 02:31:16 +07:00
|
|
|
import { config } from "../config";
|
|
|
|
|
import { createChildLogger } from "../logger";
|
2026-05-14 21:16:03 +07:00
|
|
|
import { buildConversationPromptMessages } from "./conversationContext";
|
|
|
|
|
import { runModerationAnalysis } from "./llmModerationClient";
|
2026-05-14 15:02:23 +07:00
|
|
|
import {
|
2026-05-14 19:32:44 +07:00
|
|
|
getConversationContextBefore,
|
2026-05-14 19:39:25 +07:00
|
|
|
getMessageById,
|
2026-05-14 19:32:44 +07:00
|
|
|
getPendingConversationKeys,
|
|
|
|
|
getPendingMessagesByConversation,
|
2026-05-14 15:02:23 +07:00
|
|
|
updateMessageAIAnalysis,
|
|
|
|
|
} from "./messageStore";
|
2026-05-14 19:32:44 +07:00
|
|
|
import type { AnalysisQueueStatus, MessageRecord } from "./types";
|
2026-05-14 02:31:16 +07:00
|
|
|
|
|
|
|
|
const logger = createChildLogger("ai-analyzer");
|
2026-05-14 19:32:44 +07:00
|
|
|
|
|
|
|
|
// Debounce state per conversation key
|
|
|
|
|
const conversationDebounceTimers = new Map<string, NodeJS.Timeout>();
|
2026-05-14 19:39:25 +07:00
|
|
|
// Track conversations currently being processed
|
|
|
|
|
const conversationProcessing = new Set<string>();
|
|
|
|
|
// Track conversations in error cooldown (failed recently)
|
|
|
|
|
const conversationErrorCooldown = new Map<string, number>();
|
2026-05-14 19:32:44 +07:00
|
|
|
|
2026-05-14 03:54:12 +07:00
|
|
|
let activeRequests = 0;
|
2026-05-14 19:32:44 +07:00
|
|
|
let lastError: string | null = null;
|
|
|
|
|
const MAX_ACTIVE_REQUESTS = 1;
|
|
|
|
|
const DEBOUNCE_MS = 1500;
|
|
|
|
|
const RECOVERY_INTERVAL_MS = 15000;
|
2026-05-14 19:39:25 +07:00
|
|
|
const ERROR_COOLDOWN_MS = 30000;
|
2026-05-14 19:32:44 +07:00
|
|
|
const MAX_CONTEXT_TOKENS = 8000;
|
|
|
|
|
const MAX_BATCH_SIZE = 25;
|
2026-05-14 02:31:16 +07:00
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
/**
|
|
|
|
|
* Gets the conversation key for a message (thread_id or channel_id)
|
|
|
|
|
*/
|
|
|
|
|
export function getConversationKey(message: MessageRecord): string {
|
|
|
|
|
return message.thread_id || message.channel_id;
|
2026-05-14 02:31:16 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
/**
|
|
|
|
|
* Picks a batch of messages within token budget
|
|
|
|
|
*/
|
|
|
|
|
export function pickBatchWithinBudget(
|
2026-05-14 15:02:23 +07:00
|
|
|
messages: MessageRecord[],
|
2026-05-14 19:32:44 +07:00
|
|
|
maxTokens: number,
|
|
|
|
|
tokensPerMessage: number,
|
|
|
|
|
): MessageRecord[] {
|
|
|
|
|
const batch: MessageRecord[] = [];
|
|
|
|
|
let usedTokens = 0;
|
2026-05-14 04:23:11 +07:00
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
for (const msg of messages) {
|
|
|
|
|
// Estimate tokens based on actual content length
|
|
|
|
|
const content = msg.edited_content ?? msg.content;
|
|
|
|
|
const contentTokens = Math.ceil(content.length / 4);
|
|
|
|
|
const msgTokens = contentTokens + tokensPerMessage;
|
2026-05-14 04:24:19 +07:00
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
if (usedTokens + msgTokens <= maxTokens) {
|
|
|
|
|
batch.push(msg);
|
|
|
|
|
usedTokens += msgTokens;
|
2026-05-14 04:08:41 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
return batch;
|
2026-05-14 02:31:16 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
/**
|
|
|
|
|
* Processes a batch of messages for a conversation
|
|
|
|
|
*/
|
|
|
|
|
async function processBatch(
|
|
|
|
|
conversationKey: string,
|
|
|
|
|
messages: MessageRecord[],
|
|
|
|
|
): Promise<void> {
|
2026-05-14 04:08:41 +07:00
|
|
|
if (messages.length === 0) return;
|
|
|
|
|
|
2026-05-14 03:54:12 +07:00
|
|
|
activeRequests++;
|
2026-05-14 19:39:25 +07:00
|
|
|
conversationProcessing.add(conversationKey);
|
2026-05-14 02:31:16 +07:00
|
|
|
try {
|
2026-05-14 19:32:44 +07:00
|
|
|
// Get context before the first message
|
|
|
|
|
const firstMessage = messages[0];
|
|
|
|
|
const contextBefore = await getConversationContextBefore({
|
|
|
|
|
channelId: firstMessage.channel_id,
|
|
|
|
|
threadId: firstMessage.thread_id,
|
|
|
|
|
beforeCreatedAt: firstMessage.created_at,
|
|
|
|
|
limit: 20,
|
|
|
|
|
});
|
2026-05-14 04:08:41 +07:00
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
// Build prompt with context
|
|
|
|
|
const promptMessages = buildConversationPromptMessages({
|
|
|
|
|
contextBefore,
|
|
|
|
|
targets: messages,
|
|
|
|
|
maxTokens: MAX_CONTEXT_TOKENS,
|
|
|
|
|
});
|
2026-05-14 04:08:41 +07:00
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
const contextText = promptMessages.join("\n");
|
|
|
|
|
|
|
|
|
|
// Run moderation analysis
|
|
|
|
|
const result = await runModerationAnalysis({
|
|
|
|
|
targets: messages,
|
|
|
|
|
contextText,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Store results
|
|
|
|
|
const analyzedRows: MessageRecord[] = [];
|
|
|
|
|
for (const analysisResult of result.results) {
|
|
|
|
|
const row = await updateMessageAIAnalysis(analysisResult.messageId, {
|
|
|
|
|
status: analysisResult.status,
|
|
|
|
|
flags: JSON.stringify(analysisResult.flags),
|
|
|
|
|
score: analysisResult.score,
|
|
|
|
|
raw: JSON.stringify(result.raw),
|
|
|
|
|
analysis: analysisResult.analysis,
|
2026-05-14 04:08:41 +07:00
|
|
|
analyzedAt: Date.now(),
|
|
|
|
|
error: null,
|
|
|
|
|
});
|
2026-05-14 19:32:44 +07:00
|
|
|
if (row) {
|
|
|
|
|
analyzedRows.push(row);
|
|
|
|
|
}
|
2026-05-14 04:48:20 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
// Broadcast analyzed messages
|
|
|
|
|
for (const row of analyzedRows) {
|
2026-05-14 20:03:02 +07:00
|
|
|
(globalThis as any).moderationBroadcaster?.messageAnalyzed(row);
|
2026-05-14 19:32:44 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:39:25 +07:00
|
|
|
// Clear error cooldown on success
|
|
|
|
|
conversationErrorCooldown.delete(conversationKey);
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
logger.info(
|
|
|
|
|
{ conversationKey, count: messages.length },
|
|
|
|
|
"Batch analysis complete",
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error instanceof Error ? error.message : String(error);
|
|
|
|
|
|
|
|
|
|
logger.error(
|
|
|
|
|
{ conversationKey, error: lastError },
|
|
|
|
|
"Batch analysis failed",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Mark all messages in batch as error
|
|
|
|
|
for (const msg of messages) {
|
|
|
|
|
const row = await updateMessageAIAnalysis(msg.id, {
|
2026-05-14 04:08:41 +07:00
|
|
|
status: "error",
|
|
|
|
|
flags: null,
|
|
|
|
|
score: null,
|
|
|
|
|
raw: null,
|
|
|
|
|
analysis: null,
|
|
|
|
|
analyzedAt: Date.now(),
|
2026-05-14 19:32:44 +07:00
|
|
|
error: lastError,
|
2026-05-14 04:08:41 +07:00
|
|
|
});
|
2026-05-14 19:32:44 +07:00
|
|
|
if (row) {
|
2026-05-14 20:03:02 +07:00
|
|
|
(globalThis as any).moderationBroadcaster?.messageAnalyzed(row);
|
2026-05-14 19:32:44 +07:00
|
|
|
}
|
2026-05-14 04:08:41 +07:00
|
|
|
}
|
2026-05-14 19:39:25 +07:00
|
|
|
|
|
|
|
|
// Set error cooldown for this conversation
|
|
|
|
|
conversationErrorCooldown.set(
|
|
|
|
|
conversationKey,
|
|
|
|
|
Date.now() + ERROR_COOLDOWN_MS,
|
|
|
|
|
);
|
2026-05-14 03:54:12 +07:00
|
|
|
} finally {
|
|
|
|
|
activeRequests--;
|
2026-05-14 19:39:25 +07:00
|
|
|
conversationProcessing.delete(conversationKey);
|
2026-05-14 02:31:16 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
/**
|
|
|
|
|
* Debounced analysis trigger for a conversation
|
|
|
|
|
*/
|
|
|
|
|
function scheduleConversationAnalysis(conversationKey: string): void {
|
2026-05-14 19:39:25 +07:00
|
|
|
// Skip if already processing
|
|
|
|
|
if (conversationProcessing.has(conversationKey)) {
|
|
|
|
|
logger.debug(
|
|
|
|
|
{ conversationKey },
|
|
|
|
|
"Conversation already processing, skipping schedule",
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip if in error cooldown
|
|
|
|
|
const cooldownUntil = conversationErrorCooldown.get(conversationKey);
|
|
|
|
|
if (cooldownUntil && Date.now() < cooldownUntil) {
|
|
|
|
|
logger.debug(
|
|
|
|
|
{ conversationKey, cooldownMs: cooldownUntil - Date.now() },
|
|
|
|
|
"Conversation in error cooldown, skipping schedule",
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
// Clear existing timer
|
|
|
|
|
const existingTimer = conversationDebounceTimers.get(conversationKey);
|
|
|
|
|
if (existingTimer) {
|
|
|
|
|
clearTimeout(existingTimer);
|
2026-05-14 02:31:16 +07:00
|
|
|
}
|
2026-05-14 19:32:44 +07:00
|
|
|
|
|
|
|
|
// Set new debounced timer
|
|
|
|
|
const timer = setTimeout(async () => {
|
|
|
|
|
conversationDebounceTimers.delete(conversationKey);
|
|
|
|
|
|
2026-05-14 19:39:25 +07:00
|
|
|
// If activeRequests >= MAX_ACTIVE_REQUESTS, requeue instead of waiting
|
|
|
|
|
if (activeRequests >= MAX_ACTIVE_REQUESTS) {
|
|
|
|
|
logger.debug(
|
|
|
|
|
{ conversationKey, activeRequests },
|
|
|
|
|
"Max active requests reached, requeuing conversation",
|
|
|
|
|
);
|
|
|
|
|
scheduleConversationAnalysis(conversationKey);
|
|
|
|
|
return;
|
2026-05-14 19:32:44 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get pending messages for this conversation
|
|
|
|
|
const messages = await getPendingMessagesByConversation(
|
|
|
|
|
conversationKey,
|
|
|
|
|
MAX_BATCH_SIZE,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (messages.length > 0) {
|
|
|
|
|
await processBatch(conversationKey, messages);
|
|
|
|
|
}
|
|
|
|
|
}, DEBOUNCE_MS);
|
|
|
|
|
|
|
|
|
|
conversationDebounceTimers.set(conversationKey, timer);
|
2026-05-14 02:31:16 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
/**
|
|
|
|
|
* Queues a message for analysis (debounced by conversation)
|
|
|
|
|
*/
|
2026-05-14 19:39:25 +07:00
|
|
|
export async function queueMessageAnalysis(messageId: string): Promise<void> {
|
2026-05-14 02:31:16 +07:00
|
|
|
if (!config.AI_ANALYSIS_ENABLED) return;
|
2026-05-14 19:32:44 +07:00
|
|
|
|
|
|
|
|
logger.debug({ messageId }, "Queueing message for analysis");
|
|
|
|
|
|
2026-05-14 19:39:25 +07:00
|
|
|
try {
|
|
|
|
|
// Look up the message to get its conversation key
|
|
|
|
|
const message = await getMessageById(messageId);
|
|
|
|
|
if (!message) {
|
|
|
|
|
logger.warn({ messageId }, "Message not found for analysis queue");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Schedule its conversation for analysis
|
|
|
|
|
const conversationKey = getConversationKey(message);
|
|
|
|
|
queueConversationAnalysis(conversationKey);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
messageId,
|
|
|
|
|
error: error instanceof Error ? error.message : String(error),
|
|
|
|
|
},
|
|
|
|
|
"Failed to queue message for analysis",
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-14 02:31:16 +07:00
|
|
|
}
|
2026-05-14 02:44:26 +07:00
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
/**
|
|
|
|
|
* Queues a conversation for analysis (debounced)
|
|
|
|
|
*/
|
|
|
|
|
export function queueConversationAnalysis(conversationKey: string): void {
|
|
|
|
|
if (!config.AI_ANALYSIS_ENABLED) return;
|
|
|
|
|
|
|
|
|
|
logger.debug({ conversationKey }, "Queueing conversation for analysis");
|
|
|
|
|
|
|
|
|
|
// Schedule debounced analysis
|
|
|
|
|
scheduleConversationAnalysis(conversationKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Gets current analysis queue status
|
|
|
|
|
*/
|
|
|
|
|
export function getAnalysisQueueStatus(): AnalysisQueueStatus {
|
|
|
|
|
return {
|
|
|
|
|
queuedConversations: conversationDebounceTimers.size,
|
|
|
|
|
activeRequests,
|
|
|
|
|
lastError,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Starts the pending AI analysis recovery worker
|
|
|
|
|
*/
|
2026-05-14 15:41:11 +07:00
|
|
|
export function startPendingAIAnalysisWorker(): void {
|
2026-05-14 02:44:26 +07:00
|
|
|
if (!config.AI_ANALYSIS_ENABLED) {
|
|
|
|
|
logger.info("AI analysis disabled");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info("AI analysis worker started");
|
2026-05-14 19:32:44 +07:00
|
|
|
|
2026-05-14 15:41:11 +07:00
|
|
|
setInterval(async () => {
|
2026-05-14 19:32:44 +07:00
|
|
|
try {
|
|
|
|
|
// Get pending conversation keys
|
|
|
|
|
const conversationKeys = await getPendingConversationKeys(100);
|
|
|
|
|
|
|
|
|
|
for (const key of conversationKeys) {
|
2026-05-14 19:39:25 +07:00
|
|
|
// Skip if already scheduled
|
|
|
|
|
if (conversationDebounceTimers.has(key)) {
|
|
|
|
|
continue;
|
2026-05-14 19:32:44 +07:00
|
|
|
}
|
2026-05-14 19:39:25 +07:00
|
|
|
|
|
|
|
|
// Skip if currently processing
|
|
|
|
|
if (conversationProcessing.has(key)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip if in error cooldown
|
|
|
|
|
const cooldownUntil = conversationErrorCooldown.get(key);
|
|
|
|
|
if (cooldownUntil && Date.now() < cooldownUntil) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
{ conversationKey: key },
|
|
|
|
|
"Recovering pending conversation",
|
|
|
|
|
);
|
|
|
|
|
scheduleConversationAnalysis(key);
|
2026-05-14 19:32:44 +07:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
logger.error({ error }, "Pending AI analysis recovery worker failed");
|
2026-05-14 02:44:26 +07:00
|
|
|
}
|
2026-05-14 19:32:44 +07:00
|
|
|
}, RECOVERY_INTERVAL_MS);
|
2026-05-14 02:44:26 +07:00
|
|
|
}
|