fix: parallelize text + media LLM analysis instead of sequential
- text-only and media analysis now run concurrently via Promise.all - text no longer blocks on media download + vision analysis - each path independently saves to DB when its own results are ready - same batch still uses single context fetch + attachment lookup
This commit is contained in:
@@ -180,65 +180,75 @@ async function processBatch(job: {
|
|||||||
|
|
||||||
const allRows: MessageRecord[] = [];
|
const allRows: MessageRecord[] = [];
|
||||||
|
|
||||||
// Phase 1: Text-only → save immediately (fast)
|
// ── Parallel: text-only + media analysis run concurrently ──────────
|
||||||
if (textOnly.length > 0) {
|
// Text-only → fast LLM call. Media → download + vision + LLM.
|
||||||
const textResult = await runModerationAnalysis({
|
// Running both in parallel means media downloads overlap with text LLM call.
|
||||||
targets: textOnly,
|
// Each path saves to DB as soon as its own results are ready.
|
||||||
contextText: contextLines.join("\n"),
|
// ────────────────────────────────────────────────────────────────────
|
||||||
attachments,
|
const textPromise = textOnly.length > 0
|
||||||
});
|
? runModerationAnalysis({
|
||||||
const textUpdates = textResult.results.map((analysisResult) => ({
|
targets: textOnly,
|
||||||
messageId: analysisResult.messageId,
|
contextText: contextLines.join("\n"),
|
||||||
result: {
|
attachments,
|
||||||
status: analysisResult.status,
|
}).then((result) => {
|
||||||
flags: JSON.stringify(analysisResult.flags),
|
const updates = result.results.map((analysisResult) => ({
|
||||||
score: analysisResult.score,
|
messageId: analysisResult.messageId,
|
||||||
analysis: analysisResult.analysis,
|
result: {
|
||||||
categories: analysisResult.categories,
|
status: analysisResult.status,
|
||||||
severity: analysisResult.severity,
|
flags: JSON.stringify(analysisResult.flags),
|
||||||
confidence: analysisResult.confidence,
|
score: analysisResult.score,
|
||||||
recommendedAction: analysisResult.recommendedAction,
|
analysis: analysisResult.analysis,
|
||||||
analyzedAt: Date.now(),
|
categories: analysisResult.categories,
|
||||||
error: null,
|
severity: analysisResult.severity,
|
||||||
},
|
confidence: analysisResult.confidence,
|
||||||
}));
|
recommendedAction: analysisResult.recommendedAction,
|
||||||
if (textUpdates.length > 0) {
|
analyzedAt: Date.now(),
|
||||||
const rows = await updateMessagesAIAnalysisBulk(textUpdates);
|
error: null,
|
||||||
allRows.push(...rows);
|
},
|
||||||
logger.info(
|
}));
|
||||||
{ count: textUpdates.length, conversationKey },
|
if (updates.length > 0) {
|
||||||
"Text-only batch saved — media analysis still in progress",
|
return updateMessagesAIAnalysisBulk(updates).then((rows) => {
|
||||||
);
|
allRows.push(...rows);
|
||||||
}
|
logger.info(
|
||||||
}
|
{ count: updates.length, conversationKey },
|
||||||
|
"Text-only batch saved — media analysis still in progress",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
// Phase 2: Media → save when done (slow: download + vision)
|
const mediaPromise = media.length > 0
|
||||||
if (media.length > 0) {
|
? runModerationAnalysis({
|
||||||
const mediaResult = await runModerationAnalysis({
|
targets: media,
|
||||||
targets: media,
|
contextText: contextLines.join("\n"),
|
||||||
contextText: contextLines.join("\n"),
|
attachments,
|
||||||
attachments,
|
}).then((result) => {
|
||||||
});
|
const updates = result.results.map((analysisResult) => ({
|
||||||
const mediaUpdates = mediaResult.results.map((analysisResult) => ({
|
messageId: analysisResult.messageId,
|
||||||
messageId: analysisResult.messageId,
|
result: {
|
||||||
result: {
|
status: analysisResult.status,
|
||||||
status: analysisResult.status,
|
flags: JSON.stringify(analysisResult.flags),
|
||||||
flags: JSON.stringify(analysisResult.flags),
|
score: analysisResult.score,
|
||||||
score: analysisResult.score,
|
analysis: analysisResult.analysis,
|
||||||
analysis: analysisResult.analysis,
|
categories: analysisResult.categories,
|
||||||
categories: analysisResult.categories,
|
severity: analysisResult.severity,
|
||||||
severity: analysisResult.severity,
|
confidence: analysisResult.confidence,
|
||||||
confidence: analysisResult.confidence,
|
recommendedAction: analysisResult.recommendedAction,
|
||||||
recommendedAction: analysisResult.recommendedAction,
|
analyzedAt: Date.now(),
|
||||||
analyzedAt: Date.now(),
|
error: null,
|
||||||
error: null,
|
},
|
||||||
},
|
}));
|
||||||
}));
|
if (updates.length > 0) {
|
||||||
if (mediaUpdates.length > 0) {
|
return updateMessagesAIAnalysisBulk(updates).then((rows) => {
|
||||||
const rows = await updateMessagesAIAnalysisBulk(mediaUpdates);
|
allRows.push(...rows);
|
||||||
allRows.push(...rows);
|
});
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
// Wait for both to complete
|
||||||
|
await Promise.all([textPromise, mediaPromise]);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ total: messages.length, textOnly: textOnly.length, media: media.length, saved: allRows.length },
|
{ total: messages.length, textOnly: textOnly.length, media: media.length, saved: allRows.length },
|
||||||
|
|||||||
@@ -707,12 +707,13 @@ async function runTextOnlyBatch(
|
|||||||
const maxBatchSize = config.AI_LLM_TEXT_BATCH_SIZE ?? 20;
|
const maxBatchSize = config.AI_LLM_TEXT_BATCH_SIZE ?? 20;
|
||||||
const timeoutMs = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
const timeoutMs = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
||||||
|
|
||||||
|
// ── Phase A: Prepare context in parallel ──────────────────
|
||||||
|
// URL fetching (web_content) and SearXNG (web_searches) are independent —
|
||||||
|
// both just enrich the LLM prompt. Run them concurrently so SearXNG
|
||||||
|
// doesn't block on slow URLs (or vice versa).
|
||||||
|
//
|
||||||
// ── Fetch web content from URLs in text-only messages ──
|
// ── Fetch web content from URLs in text-only messages ──
|
||||||
// Prevents LLM from guessing based on domain name alone (e.g., false "scam" flags).
|
const urlFetchPromise = (async () => {
|
||||||
// The fetched text content is injected into the message XML so the LLM can
|
|
||||||
// analyze the actual page rather than pattern-match the URL string.
|
|
||||||
const urlFetchMap = new Map<string, string>(); // url → fetched text content
|
|
||||||
{
|
|
||||||
const allUrls = new Set<string>();
|
const allUrls = new Set<string>();
|
||||||
for (const msg of targets) {
|
for (const msg of targets) {
|
||||||
const content = msg.edited_content ?? msg.content;
|
const content = msg.edited_content ?? msg.content;
|
||||||
@@ -720,7 +721,7 @@ async function runTextOnlyBatch(
|
|||||||
allUrls.add(url);
|
allUrls.add(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const urlArr = Array.from(allUrls).slice(0, 10); // cap to 10 fetches per batch
|
const urlArr = Array.from(allUrls).slice(0, 10);
|
||||||
if (urlArr.length > 0) {
|
if (urlArr.length > 0) {
|
||||||
log.debug(
|
log.debug(
|
||||||
{ urlCount: urlArr.length },
|
{ urlCount: urlArr.length },
|
||||||
@@ -729,6 +730,7 @@ async function runTextOnlyBatch(
|
|||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
urlArr.map((url) => fetchUrlSafely(url)),
|
urlArr.map((url) => fetchUrlSafely(url)),
|
||||||
);
|
);
|
||||||
|
const map = new Map<string, string>();
|
||||||
for (let i = 0; i < urlArr.length; i++) {
|
for (let i = 0; i < urlArr.length; i++) {
|
||||||
const r = results[i];
|
const r = results[i];
|
||||||
if (
|
if (
|
||||||
@@ -736,19 +738,16 @@ async function runTextOnlyBatch(
|
|||||||
r.value.type === "text" &&
|
r.value.type === "text" &&
|
||||||
r.value.textContent
|
r.value.textContent
|
||||||
) {
|
) {
|
||||||
urlFetchMap.set(urlArr[i], r.value.textContent);
|
map.set(urlArr[i], r.value.textContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
}
|
return new Map<string, string>();
|
||||||
|
})();
|
||||||
|
|
||||||
// ── SearXNG enrichment for suspicious/ambiguous content ──────────
|
// ── SearXNG enrichment ──
|
||||||
// Uses SearXNG to look up references mentioned in messages (e.g. anime
|
const searxngPromise = (async () => {
|
||||||
// titles, drug names). Results are injected as <web_search> XML tags
|
|
||||||
// so the LLM can make informed decisions instead of guessing.
|
|
||||||
// All messages are searched — Redis cache prevents redundant lookups.
|
|
||||||
const searxngResults = new Map<string, string>(); // query → formatted XML
|
|
||||||
{
|
|
||||||
const queries = new Set<string>();
|
const queries = new Set<string>();
|
||||||
for (const msg of targets) {
|
for (const msg of targets) {
|
||||||
const content = msg.edited_content ?? msg.content;
|
const content = msg.edited_content ?? msg.content;
|
||||||
@@ -757,7 +756,7 @@ async function runTextOnlyBatch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (queries.size > 0) {
|
if (queries.size > 0) {
|
||||||
const queryArr = Array.from(queries).slice(0, 3); // cap to 3 searches per batch
|
const queryArr = Array.from(queries).slice(0, 3);
|
||||||
log.debug(
|
log.debug(
|
||||||
{ searchQueries: queryArr },
|
{ searchQueries: queryArr },
|
||||||
"Running SearXNG enrichment for batch",
|
"Running SearXNG enrichment for batch",
|
||||||
@@ -765,14 +764,23 @@ async function runTextOnlyBatch(
|
|||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
queryArr.map((q) => searchSearxng(q)),
|
queryArr.map((q) => searchSearxng(q)),
|
||||||
);
|
);
|
||||||
|
const map = new Map<string, string>();
|
||||||
for (let i = 0; i < queryArr.length; i++) {
|
for (let i = 0; i < queryArr.length; i++) {
|
||||||
const r = results[i];
|
const r = results[i];
|
||||||
if (r.status === "fulfilled" && r.value.length > 0) {
|
if (r.status === "fulfilled" && r.value.length > 0) {
|
||||||
searxngResults.set(queryArr[i], formatSearchResults(r.value));
|
map.set(queryArr[i], formatSearchResults(r.value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
}
|
return new Map<string, string>();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Wait for BOTH concurrently
|
||||||
|
const [urlFetchMap, searxngResults] = await Promise.all([
|
||||||
|
urlFetchPromise,
|
||||||
|
searxngPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
// ── Group identical short messages (< 20 chars) to reduce redundant analysis ──
|
// ── Group identical short messages (< 20 chars) to reduce redundant analysis ──
|
||||||
// Messages with identical normalized content share a single representative.
|
// Messages with identical normalized content share a single representative.
|
||||||
|
|||||||
Reference in New Issue
Block a user