2026-05-21 12:27:07 +00:00
|
|
|
import { existsSync } from "node:fs";
|
|
|
|
|
import { fileURLToPath } from "node:url";
|
2026-05-25 22:14:05 +07:00
|
|
|
import { Piscina } from "piscina";
|
2026-05-21 12:03:31 +00:00
|
|
|
import { config } from "../config.js";
|
|
|
|
|
import { createChildLogger } from "../logger.js";
|
2026-05-27 23:25:37 +07:00
|
|
|
import { retryWithBackoff } from "../retry.js";
|
2026-05-25 23:23:12 +07:00
|
|
|
import {
|
2026-05-27 23:25:37 +07:00
|
|
|
buildConversationContext,
|
2026-05-25 23:23:12 +07:00
|
|
|
estimateTokens,
|
|
|
|
|
formatMessageForPrompt,
|
|
|
|
|
} from "./conversationContext.js";
|
2026-05-27 23:25:37 +07:00
|
|
|
import { runModerationAnalysis } from "./llmModerationClient.js";
|
2026-05-14 15:02:23 +07:00
|
|
|
import {
|
2026-05-27 23:25:37 +07:00
|
|
|
getAttachmentsForMessages,
|
|
|
|
|
getConversationContextBefore,
|
2026-05-14 19:39:25 +07:00
|
|
|
getMessageById,
|
2026-05-14 19:32:44 +07:00
|
|
|
getPendingConversationKeys,
|
|
|
|
|
getPendingMessagesByConversation,
|
2026-05-27 23:25:37 +07:00
|
|
|
updateMessagesAIAnalysisBulk,
|
2026-05-21 12:03:31 +00:00
|
|
|
} from "./messageStore.js";
|
2026-05-15 07:13:37 +07:00
|
|
|
import type {
|
|
|
|
|
AnalysisQueueStatus,
|
|
|
|
|
MessageRecord,
|
|
|
|
|
ModerationBroadcaster,
|
2026-05-21 12:03:31 +00:00
|
|
|
} from "./types.js";
|
2026-05-14 02:31:16 +07:00
|
|
|
|
|
|
|
|
const logger = createChildLogger("ai-analyzer");
|
2026-05-14 19:32:44 +07:00
|
|
|
|
2026-05-15 07:13:37 +07:00
|
|
|
type ModerationGlobal = typeof globalThis & {
|
|
|
|
|
moderationBroadcaster?: ModerationBroadcaster;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function getModerationBroadcaster(): ModerationBroadcaster | undefined {
|
|
|
|
|
return (globalThis as ModerationGlobal).moderationBroadcaster;
|
|
|
|
|
}
|
|
|
|
|
|
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
|
2026-05-21 02:39:28 +07:00
|
|
|
const conversationProcessing = new Map<string, number>();
|
2026-05-14 19:39:25 +07:00
|
|
|
// Track conversations in error cooldown (failed recently)
|
|
|
|
|
const conversationErrorCooldown = new Map<string, number>();
|
2026-05-14 19:32:44 +07:00
|
|
|
|
2026-05-21 02:39:28 +07:00
|
|
|
const AI_PROCESSING_OVERLAP_MS = 30000;
|
|
|
|
|
|
2026-05-14 03:54:12 +07:00
|
|
|
let activeRequests = 0;
|
2026-05-14 19:32:44 +07:00
|
|
|
let lastError: string | null = null;
|
2026-05-14 02:31:16 +07:00
|
|
|
|
2026-05-25 22:14:05 +07:00
|
|
|
// Global circuit breaker state
|
|
|
|
|
let consecutiveErrors = 0;
|
|
|
|
|
const MAX_CONSECUTIVE_ERRORS = 5;
|
|
|
|
|
let globalCooldownUntil = 0;
|
|
|
|
|
|
2026-05-27 23:25:37 +07:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Individual fallback queue — runs PARALLEL to the batch pipeline.
|
|
|
|
|
//
|
|
|
|
|
// When a batch LLM call returns but some message IDs are absent from the
|
|
|
|
|
// response (analysis_incomplete), those IDs are enqueued here. Each message
|
|
|
|
|
// is processed independently and concurrently: there is no serialisation
|
|
|
|
|
// per-conversation, and a dedup Set prevents the same ID being in-flight twice.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
/** IDs currently being processed one-by-one (in-flight or waiting to start). */
|
|
|
|
|
const individualInFlight = new Set<string>();
|
|
|
|
|
|
|
|
|
|
/** Counter for observability (mirrors activeRequests but for individual path). */
|
|
|
|
|
let activeIndividualRequests = 0;
|
|
|
|
|
|
2026-05-25 22:14:05 +07:00
|
|
|
function getAnalysisWorkerUrl(): URL {
|
|
|
|
|
const candidates = [
|
|
|
|
|
new URL("./aiAnalysisWorker.js", import.meta.url),
|
|
|
|
|
new URL("../aiAnalysisWorker.js", import.meta.url),
|
|
|
|
|
new URL("./aiAnalysisWorker.ts", import.meta.url),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
if (existsSync(fileURLToPath(candidate))) {
|
|
|
|
|
return candidate;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return candidates[2];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workerPool = new Piscina({
|
|
|
|
|
filename: fileURLToPath(getAnalysisWorkerUrl()),
|
|
|
|
|
execArgv: process.execArgv,
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 21:40:20 +07:00
|
|
|
interface AnalysisWorkerResponse {
|
|
|
|
|
ok: boolean;
|
|
|
|
|
conversationKey: string;
|
|
|
|
|
rows: MessageRecord[];
|
|
|
|
|
error?: string;
|
|
|
|
|
}
|
|
|
|
|
|
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) {
|
2026-05-25 23:23:12 +07:00
|
|
|
const formatted = formatMessageForPrompt(msg, "target");
|
|
|
|
|
const msgTokens = estimateTokens(formatted) + 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-21 02:39:28 +07:00
|
|
|
function isConversationProcessingLocked(conversationKey: string): boolean {
|
|
|
|
|
const startedAt = conversationProcessing.get(conversationKey);
|
|
|
|
|
return Boolean(
|
|
|
|
|
startedAt && Date.now() - startedAt < AI_PROCESSING_OVERLAP_MS,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
/**
|
|
|
|
|
* Processes a batch of messages for a conversation
|
|
|
|
|
*/
|
2026-05-27 23:25:37 +07:00
|
|
|
/**
|
|
|
|
|
* Processes a single message through the LLM moderation pipeline directly
|
|
|
|
|
* (no worker pool — avoids IPC overhead for a single-item call). Called from
|
|
|
|
|
* the individual fallback queue; never from the batch path.
|
|
|
|
|
*/
|
|
|
|
|
async function processIndividualFallback(
|
|
|
|
|
message: MessageRecord,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const { id: messageId } = message;
|
|
|
|
|
activeIndividualRequests++;
|
|
|
|
|
try {
|
|
|
|
|
const contextBefore = await getConversationContextBefore({
|
|
|
|
|
channelId: message.channel_id,
|
|
|
|
|
threadId: message.thread_id,
|
|
|
|
|
beforeCreatedAt: message.created_at,
|
|
|
|
|
limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const contextLines = buildConversationContext({
|
|
|
|
|
contextBefore,
|
|
|
|
|
targets: [message],
|
|
|
|
|
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const contextIds = contextBefore.map((m) => m.id);
|
|
|
|
|
const attachments = await getAttachmentsForMessages([
|
|
|
|
|
messageId,
|
|
|
|
|
...contextIds,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const analysisResult = await retryWithBackoff(
|
|
|
|
|
() =>
|
|
|
|
|
runModerationAnalysis({
|
|
|
|
|
targets: [message],
|
|
|
|
|
contextText: contextLines.join("\n"),
|
|
|
|
|
attachments,
|
|
|
|
|
}),
|
|
|
|
|
{
|
|
|
|
|
retries: 2,
|
|
|
|
|
minTimeout: 2000,
|
|
|
|
|
maxTimeout: 15000,
|
|
|
|
|
logger,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const updates = analysisResult.results.map((r) => ({
|
|
|
|
|
messageId: r.messageId,
|
|
|
|
|
result: {
|
|
|
|
|
status: r.status,
|
|
|
|
|
flags: JSON.stringify(r.flags),
|
|
|
|
|
score: r.score,
|
|
|
|
|
raw: JSON.stringify(analysisResult.raw),
|
|
|
|
|
analysis: r.analysis,
|
|
|
|
|
analyzedAt: Date.now(),
|
|
|
|
|
error: null,
|
|
|
|
|
},
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
const rows = await updateMessagesAIAnalysisBulk(updates);
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
getModerationBroadcaster()?.messageAnalyzed(row);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
{ messageId, status: analysisResult.results[0]?.status },
|
|
|
|
|
"Individual fallback analysis complete",
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
lastError = error instanceof Error ? error.message : String(error);
|
|
|
|
|
logger.error(
|
|
|
|
|
{
|
|
|
|
|
messageId,
|
|
|
|
|
error: lastError,
|
|
|
|
|
stack: error instanceof Error ? error.stack : undefined,
|
|
|
|
|
},
|
|
|
|
|
"Individual fallback analysis failed",
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
activeIndividualRequests--;
|
|
|
|
|
individualInFlight.delete(messageId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fans out a list of message records to the individual fallback queue.
|
|
|
|
|
* Each message starts processing concurrently (fire-and-forget per message).
|
|
|
|
|
* De-duplicated by message ID so no double-processing even if called repeatedly.
|
|
|
|
|
*/
|
|
|
|
|
function enqueueIndividualFallbacks(messages: MessageRecord[]): void {
|
|
|
|
|
const newMessages = messages.filter((m) => !individualInFlight.has(m.id));
|
|
|
|
|
if (newMessages.length === 0) return;
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
{
|
|
|
|
|
count: newMessages.length,
|
|
|
|
|
messageIds: newMessages.map((m) => m.id),
|
|
|
|
|
},
|
|
|
|
|
"Enqueueing individual fallback analysis for batch-incomplete messages",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const msg of newMessages) {
|
|
|
|
|
individualInFlight.add(msg.id);
|
|
|
|
|
// Fire-and-forget: each message runs concurrently, errors are handled inside.
|
|
|
|
|
processIndividualFallback(msg).catch((err) => {
|
|
|
|
|
// Belt-and-suspenders: processIndividualFallback catches internally,
|
|
|
|
|
// but guard against any uncaught rejection bubbling here.
|
|
|
|
|
logger.error(
|
|
|
|
|
{ messageId: msg.id, error: String(err) },
|
|
|
|
|
"Unexpected error in individual fallback promise",
|
|
|
|
|
);
|
|
|
|
|
individualInFlight.delete(msg.id);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
async function processBatch(
|
|
|
|
|
conversationKey: string,
|
|
|
|
|
messages: MessageRecord[],
|
|
|
|
|
): Promise<void> {
|
2026-05-14 04:08:41 +07:00
|
|
|
if (messages.length === 0) return;
|
2026-05-25 22:14:05 +07:00
|
|
|
if (Date.now() < globalCooldownUntil) {
|
2026-05-25 23:23:12 +07:00
|
|
|
// Should not normally hit here due to checks in scheduleConversationAnalysis, but just in case
|
|
|
|
|
return;
|
2026-05-25 22:14:05 +07:00
|
|
|
}
|
2026-05-14 04:08:41 +07:00
|
|
|
|
2026-05-14 03:54:12 +07:00
|
|
|
activeRequests++;
|
2026-05-21 02:56:41 +07:00
|
|
|
let shouldScheduleNext = false;
|
2026-05-21 02:39:28 +07:00
|
|
|
const processingStartedAt = Date.now();
|
|
|
|
|
conversationProcessing.set(conversationKey, processingStartedAt);
|
2026-05-14 02:31:16 +07:00
|
|
|
try {
|
2026-05-25 23:23:12 +07:00
|
|
|
const result = (await workerPool.run({
|
|
|
|
|
conversationKey,
|
|
|
|
|
messages,
|
|
|
|
|
})) as AnalysisWorkerResponse;
|
2026-05-14 04:08:41 +07:00
|
|
|
|
2026-05-15 21:40:20 +07:00
|
|
|
for (const row of result.rows) {
|
2026-05-15 07:13:37 +07:00
|
|
|
getModerationBroadcaster()?.messageAnalyzed(row);
|
2026-05-14 19:32:44 +07:00
|
|
|
}
|
|
|
|
|
|
2026-05-15 21:40:20 +07:00
|
|
|
if (!result.ok) {
|
2026-05-25 22:14:05 +07:00
|
|
|
consecutiveErrors++;
|
|
|
|
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
|
|
|
globalCooldownUntil = Date.now() + 60000;
|
2026-05-25 23:23:12 +07:00
|
|
|
logger.warn(
|
|
|
|
|
"Global circuit breaker triggered due to consecutive errors",
|
|
|
|
|
);
|
2026-05-25 22:14:05 +07:00
|
|
|
}
|
2026-05-25 23:23:12 +07:00
|
|
|
|
2026-05-27 23:25:37 +07:00
|
|
|
// Batch failed entirely — fall back all messages to individual queue
|
|
|
|
|
// so no message is permanently lost behind a cooldown.
|
|
|
|
|
logger.warn(
|
|
|
|
|
{
|
|
|
|
|
conversationKey,
|
|
|
|
|
messageCount: messages.length,
|
|
|
|
|
error: result.error,
|
|
|
|
|
},
|
|
|
|
|
"Batch failed entirely — routing all messages to individual fallback queue",
|
|
|
|
|
);
|
|
|
|
|
enqueueIndividualFallbacks(messages);
|
|
|
|
|
|
2026-05-15 21:40:20 +07:00
|
|
|
lastError = result.error ?? "Analysis worker failed";
|
|
|
|
|
conversationErrorCooldown.set(
|
|
|
|
|
conversationKey,
|
2026-05-21 01:55:50 +07:00
|
|
|
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
2026-05-15 21:40:20 +07:00
|
|
|
);
|
|
|
|
|
logger.error(
|
2026-05-18 23:48:02 +07:00
|
|
|
{
|
|
|
|
|
conversationKey,
|
|
|
|
|
error: lastError,
|
|
|
|
|
messageCount: messages.length,
|
|
|
|
|
messageIds: messages.map((m) => m.id),
|
2026-05-21 01:55:50 +07:00
|
|
|
cooldownUntil: new Date(
|
|
|
|
|
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
|
|
|
|
).toISOString(),
|
2026-05-18 23:48:02 +07:00
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
},
|
2026-05-18 23:46:51 +07:00
|
|
|
"Batch analysis failed, will retry after cooldown",
|
2026-05-15 21:40:20 +07:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 23:25:37 +07:00
|
|
|
// Batch succeeded — but check for messages the LLM silently dropped.
|
|
|
|
|
// Rows with flag "analysis_incomplete" were produced by parseModerationResponse
|
|
|
|
|
// as synthetic errors; they must be re-processed individually.
|
|
|
|
|
const incompleteMessages = messages.filter((msg) => {
|
|
|
|
|
const row = result.rows.find((r) => r.id === msg.id);
|
|
|
|
|
if (!row) {
|
|
|
|
|
// The DB update row is missing entirely — treat as incomplete.
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
const flags: string[] = (() => {
|
|
|
|
|
try {
|
|
|
|
|
return JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
return row.ai_status === "error" && flags.includes("analysis_incomplete");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (incompleteMessages.length > 0) {
|
|
|
|
|
logger.warn(
|
|
|
|
|
{
|
|
|
|
|
conversationKey,
|
|
|
|
|
incompleteCount: incompleteMessages.length,
|
|
|
|
|
incompleteIds: incompleteMessages.map((m) => m.id),
|
|
|
|
|
totalBatchSize: messages.length,
|
|
|
|
|
},
|
|
|
|
|
"Batch returned incomplete results — fanning out to individual fallback queue",
|
|
|
|
|
);
|
|
|
|
|
enqueueIndividualFallbacks(incompleteMessages);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 22:14:05 +07:00
|
|
|
consecutiveErrors = 0; // Reset circuit breaker
|
2026-05-14 19:39:25 +07:00
|
|
|
conversationErrorCooldown.delete(conversationKey);
|
2026-05-21 02:56:41 +07:00
|
|
|
shouldScheduleNext = true;
|
2026-05-14 19:32:44 +07:00
|
|
|
} catch (error) {
|
2026-05-25 22:14:05 +07:00
|
|
|
consecutiveErrors++;
|
|
|
|
|
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
|
|
|
globalCooldownUntil = Date.now() + 60000;
|
|
|
|
|
logger.warn("Global circuit breaker triggered due to consecutive errors");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 23:25:37 +07:00
|
|
|
// Unhandled exception — route everything to individual fallback.
|
|
|
|
|
logger.warn(
|
|
|
|
|
{ conversationKey, messageCount: messages.length },
|
|
|
|
|
"Batch threw exception — routing all messages to individual fallback queue",
|
|
|
|
|
);
|
|
|
|
|
enqueueIndividualFallbacks(messages);
|
|
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
lastError = error instanceof Error ? error.message : String(error);
|
2026-05-18 23:48:02 +07:00
|
|
|
const errorStack = error instanceof Error ? error.stack : undefined;
|
2026-05-15 21:40:20 +07:00
|
|
|
conversationErrorCooldown.set(
|
|
|
|
|
conversationKey,
|
2026-05-21 01:55:50 +07:00
|
|
|
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
2026-05-15 21:40:20 +07:00
|
|
|
);
|
2026-05-14 19:32:44 +07:00
|
|
|
logger.error(
|
2026-05-18 23:48:02 +07:00
|
|
|
{
|
|
|
|
|
conversationKey,
|
|
|
|
|
error: lastError,
|
|
|
|
|
stack: errorStack,
|
|
|
|
|
messageCount: messages.length,
|
|
|
|
|
messageIds: messages.map((m) => m.id),
|
2026-05-21 01:55:50 +07:00
|
|
|
cooldownUntil: new Date(
|
|
|
|
|
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
|
|
|
|
|
).toISOString(),
|
2026-05-18 23:48:02 +07:00
|
|
|
timestamp: new Date().toISOString(),
|
|
|
|
|
},
|
2026-05-18 23:46:51 +07:00
|
|
|
"Analysis worker failed, will retry after cooldown",
|
2026-05-14 19:32:44 +07:00
|
|
|
);
|
2026-05-14 03:54:12 +07:00
|
|
|
} finally {
|
|
|
|
|
activeRequests--;
|
2026-05-21 02:39:28 +07:00
|
|
|
if (conversationProcessing.get(conversationKey) === processingStartedAt) {
|
|
|
|
|
conversationProcessing.delete(conversationKey);
|
|
|
|
|
}
|
2026-05-21 02:56:41 +07:00
|
|
|
if (shouldScheduleNext) {
|
|
|
|
|
setImmediate(() => scheduleConversationAnalysis(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
|
2026-05-21 02:39:28 +07:00
|
|
|
if (isConversationProcessingLocked(conversationKey)) {
|
2026-05-14 19:39:25 +07:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 23:23:12 +07:00
|
|
|
// Check cooldowns
|
|
|
|
|
const convoCooldown = conversationErrorCooldown.get(conversationKey) || 0;
|
|
|
|
|
const activeCooldown = Math.max(convoCooldown, globalCooldownUntil);
|
|
|
|
|
|
|
|
|
|
if (activeCooldown && Date.now() < activeCooldown) {
|
|
|
|
|
// Instead of dropping, re-schedule for after cooldown if not already scheduled
|
|
|
|
|
if (!conversationDebounceTimers.has(conversationKey)) {
|
|
|
|
|
const remaining = activeCooldown - Date.now();
|
|
|
|
|
const timer = setTimeout(() => {
|
|
|
|
|
conversationDebounceTimers.delete(conversationKey);
|
|
|
|
|
scheduleConversationAnalysis(conversationKey);
|
|
|
|
|
}, remaining + 500); // 500ms buffer after cooldown
|
|
|
|
|
conversationDebounceTimers.set(conversationKey, timer);
|
|
|
|
|
}
|
2026-05-14 19:39:25 +07:00
|
|
|
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
|
|
|
|
2026-05-18 23:46:51 +07:00
|
|
|
// Always use shorter debounce for immediate processing (no concurrency limit)
|
2026-05-21 01:55:50 +07:00
|
|
|
const debounceTime = config.AI_ANALYSIS_DEBOUNCE_MS;
|
2026-05-18 06:04:30 +07:00
|
|
|
|
2026-05-14 19:32:44 +07:00
|
|
|
// Set new debounced timer
|
|
|
|
|
const timer = setTimeout(async () => {
|
|
|
|
|
conversationDebounceTimers.delete(conversationKey);
|
|
|
|
|
|
|
|
|
|
// Get pending messages for this conversation
|
|
|
|
|
const messages = await getPendingMessagesByConversation(
|
|
|
|
|
conversationKey,
|
2026-05-21 01:55:50 +07:00
|
|
|
config.AI_ANALYSIS_MAX_BATCH_SIZE,
|
2026-05-14 19:32:44 +07:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (messages.length > 0) {
|
|
|
|
|
await processBatch(conversationKey, messages);
|
|
|
|
|
}
|
2026-05-18 06:04:30 +07:00
|
|
|
}, debounceTime);
|
2026-05-14 19:32:44 +07:00
|
|
|
|
|
|
|
|
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
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
// Schedule debounced analysis
|
|
|
|
|
scheduleConversationAnalysis(conversationKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Gets current analysis queue status
|
|
|
|
|
*/
|
|
|
|
|
export function getAnalysisQueueStatus(): AnalysisQueueStatus {
|
|
|
|
|
return {
|
|
|
|
|
queuedConversations: conversationDebounceTimers.size,
|
|
|
|
|
activeRequests,
|
2026-05-27 23:25:37 +07:00
|
|
|
activeIndividualRequests,
|
|
|
|
|
individualInFlightCount: individualInFlight.size,
|
2026-05-14 19:32:44 +07:00
|
|
|
lastError,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Starts the pending AI analysis recovery worker
|
|
|
|
|
*/
|
2026-05-14 15:41:11 +07:00
|
|
|
export function startPendingAIAnalysisWorker(): void {
|
2026-05-15 22:23:29 +07:00
|
|
|
if (!config.AI_ANALYSIS_ENABLED) return;
|
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
|
2026-05-21 02:39:28 +07:00
|
|
|
if (isConversationProcessingLocked(key)) {
|
2026-05-14 19:39:25 +07:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Skip if in error cooldown
|
|
|
|
|
const cooldownUntil = conversationErrorCooldown.get(key);
|
|
|
|
|
if (cooldownUntil && Date.now() < cooldownUntil) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-21 01:55:50 +07:00
|
|
|
}, config.AI_ANALYSIS_RECOVERY_INTERVAL_MS);
|
2026-05-14 02:44:26 +07:00
|
|
|
}
|