fix(ai-moderation): implement exponential backoff for LLM and vision API retries
This commit is contained in:
@@ -107,8 +107,8 @@ export async function llmChat(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
retries,
|
retries,
|
||||||
minTimeout: 0,
|
minTimeout: 500,
|
||||||
maxTimeout: 0,
|
maxTimeout: 4_000,
|
||||||
factor: 2,
|
factor: 2,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import { retryWithBackoff } from "@bete/shared/utils";
|
import { delay, retryWithBackoff } from "@bete/shared/utils";
|
||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import type { ChatCompletion } from "openai/resources/chat/completions";
|
import type { ChatCompletion } from "openai/resources/chat/completions";
|
||||||
import { AbortError } from "p-retry";
|
import { AbortError } from "p-retry";
|
||||||
@@ -161,7 +161,6 @@ function deriveRecommendedAction(
|
|||||||
return "review";
|
return "review";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper to extract JSON from a potentially conversational or markdown-wrapped string.
|
* Helper to extract JSON from a potentially conversational or markdown-wrapped string.
|
||||||
*/
|
*/
|
||||||
@@ -531,12 +530,15 @@ const visionLruCache = new LRUCache<string, string>({
|
|||||||
* share the same promise, eliminating the race condition between cache
|
* share the same promise, eliminating the race condition between cache
|
||||||
* check and cache write.
|
* check and cache write.
|
||||||
*/
|
*/
|
||||||
const inFlightVisionCalls = new Map<string, Promise<string | null>>();
|
const inFlightVisionCalls = new Map<string, Promise<string>>();
|
||||||
|
|
||||||
|
const FAILED_ANALYSIS_PREFIX =
|
||||||
|
"GAGAL DIANALISIS — gambar tidak dapat diunduh atau vision API gagal setelah 3x percobaan. JANGAN mengasumsikan gambar aman hanya karena gagal dianalisis. Gunakan metadata URL/nama file saja sebagai petunjuk.";
|
||||||
|
|
||||||
const analyzeSingleMediaImage = async (
|
const analyzeSingleMediaImage = async (
|
||||||
messageId: string,
|
messageId: string,
|
||||||
image: MessageImagePart,
|
image: MessageImagePart,
|
||||||
): Promise<string | null> => {
|
): Promise<string> => {
|
||||||
const cacheKey = image.customEmojiId
|
const cacheKey = image.customEmojiId
|
||||||
? makeCustomEmojiCacheKey(image.customEmojiId)
|
? makeCustomEmojiCacheKey(image.customEmojiId)
|
||||||
: image.stickerName
|
: image.stickerName
|
||||||
@@ -567,7 +569,7 @@ const analyzeSingleMediaImage = async (
|
|||||||
"Media analysis in-flight dedupe — waiting for existing call",
|
"Media analysis in-flight dedupe — waiting for existing call",
|
||||||
);
|
);
|
||||||
const result = await existing;
|
const result = await existing;
|
||||||
if (!result) return null;
|
// result is never null — the promise always returns a descriptive string
|
||||||
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${result}`;
|
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${result}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +579,7 @@ const analyzeSingleMediaImage = async (
|
|||||||
? buildCustomEmojiVisionPrompt(image.customEmojiName, messageId)
|
? buildCustomEmojiVisionPrompt(image.customEmojiName, messageId)
|
||||||
: buildGeneralImageVisionPrompt(image.sourceLabel, messageId);
|
: buildGeneralImageVisionPrompt(image.sourceLabel, messageId);
|
||||||
|
|
||||||
const visionPromise = (async (): Promise<string | null> => {
|
const visionPromise = (async (): Promise<string> => {
|
||||||
// Layer 2: Perceptual hash pre-check (before expensive vision API call)
|
// Layer 2: Perceptual hash pre-check (before expensive vision API call)
|
||||||
let phash: string | null = null;
|
let phash: string | null = null;
|
||||||
if (image.image_url.url.startsWith("data:")) {
|
if (image.image_url.url.startsWith("data:")) {
|
||||||
@@ -610,17 +612,23 @@ const analyzeSingleMediaImage = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Vision API call with EXTERNAL exponential backoff ──
|
||||||
|
// Uses retryWithBackoff directly so each retry has proper backoff delay.
|
||||||
|
// llmVision calls llmChat which also has retryWithBackoff, but its
|
||||||
|
// inner backoff has minTimeout=0 (instant). Our outer backoff ensures
|
||||||
|
// meaningful delay between full attempts.
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
const content = await llmVision(promptText, image.image_url);
|
const content = await llmVision(promptText, image.image_url);
|
||||||
if (!content) return null;
|
if (content) {
|
||||||
|
// Success — persist to all cache layers
|
||||||
await upsertCachedMediaAnalysis(
|
await upsertCachedMediaAnalysis(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
content,
|
content,
|
||||||
"vision_llm",
|
"vision_llm",
|
||||||
Date.now() + 24 * 60 * 60 * 1000,
|
Date.now() + 24 * 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
// Populate in-memory LRU and phash cache
|
|
||||||
visionLruCache.set(cacheKey, content);
|
visionLruCache.set(cacheKey, content);
|
||||||
if (phash) {
|
if (phash) {
|
||||||
upsertCachedMediaByPhash(
|
upsertCachedMediaByPhash(
|
||||||
@@ -630,29 +638,52 @@ const analyzeSingleMediaImage = async (
|
|||||||
Date.now() + 7 * 24 * 60 * 60 * 1000,
|
Date.now() + 7 * 24 * 60 * 60 * 1000,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
} catch (error) {
|
}
|
||||||
|
// llmVision returned null (no API key / client unavailable) — no point retrying
|
||||||
|
log.warn(
|
||||||
|
{ messageId },
|
||||||
|
"Vision API client unavailable (null response) — skipping retry",
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err : new Error(String(err));
|
||||||
|
if (attempt < 2) {
|
||||||
|
const backoffMs = Math.min(
|
||||||
|
1_000 * 2 ** attempt + Math.random() * 200,
|
||||||
|
8_000,
|
||||||
|
);
|
||||||
log.warn(
|
log.warn(
|
||||||
{
|
{
|
||||||
messageId,
|
messageId,
|
||||||
error: error instanceof Error ? error.message : String(error),
|
attempt: attempt + 1,
|
||||||
|
backoffMs,
|
||||||
|
error: lastError.message,
|
||||||
},
|
},
|
||||||
"Vision analysis failed after retries — image not analyzed",
|
"Vision API attempt failed — backing off before retry",
|
||||||
);
|
);
|
||||||
// Return a clear signal that vision analysis FAILED — so batch LLM knows
|
await delay(backoffMs);
|
||||||
// the image could not be described and must NOT assume it's clean.
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All attempts exhausted — return descriptive failure text.
|
||||||
|
// NOT null: the caller must always have a descriptive string to
|
||||||
|
// inject into the prompt so the LLM knows the image was skipped.
|
||||||
|
log.warn(
|
||||||
|
{
|
||||||
|
messageId,
|
||||||
|
lastError: lastError?.message ?? "null response",
|
||||||
|
},
|
||||||
|
"Vision analysis failed after all retry attempts",
|
||||||
|
);
|
||||||
|
return FAILED_ANALYSIS_PREFIX;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
inFlightVisionCalls.set(cacheKey, visionPromise);
|
inFlightVisionCalls.set(cacheKey, visionPromise);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await visionPromise;
|
const content = await visionPromise;
|
||||||
if (!content) {
|
|
||||||
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: GAGAL DIANALISIS — gambar tidak dapat diunduh atau vision API gagal setelah 3x percobaan. JANGAN mengasumsikan gambar aman hanya karena gagal dianalisis. Gunakan metadata URL/nama file saja sebagai petunjuk.`;
|
|
||||||
}
|
|
||||||
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${content}`;
|
return `[Media analysis for message ${messageId}] ${image.sourceLabel}: ${content}`;
|
||||||
} finally {
|
} finally {
|
||||||
inFlightVisionCalls.delete(cacheKey);
|
inFlightVisionCalls.delete(cacheKey);
|
||||||
@@ -936,7 +967,10 @@ async function runTextOnlyBatch(
|
|||||||
for (const [, members] of shortContentGroups) {
|
for (const [, members] of shortContentGroups) {
|
||||||
if (members.length > 1) {
|
if (members.length > 1) {
|
||||||
const rep = members[0];
|
const rep = members[0];
|
||||||
groupMapping.set(rep.id, members.map((m) => m.id));
|
groupMapping.set(
|
||||||
|
rep.id,
|
||||||
|
members.map((m) => m.id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,11 +1067,15 @@ async function runTextOnlyBatch(
|
|||||||
}
|
}
|
||||||
)?.usage;
|
)?.usage;
|
||||||
// Fan-out results from representative messages to all group members
|
// Fan-out results from representative messages to all group members
|
||||||
const fannedOutResults = groupMapping.size > 0
|
const fannedOutResults =
|
||||||
|
groupMapping.size > 0
|
||||||
? batchResult.results.flatMap((result) => {
|
? batchResult.results.flatMap((result) => {
|
||||||
const members = groupMapping.get(result.messageId);
|
const members = groupMapping.get(result.messageId);
|
||||||
if (members) {
|
if (members) {
|
||||||
return members.map((memberId) => ({ ...result, messageId: memberId }));
|
return members.map((memberId) => ({
|
||||||
|
...result,
|
||||||
|
messageId: memberId,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
return [result];
|
return [result];
|
||||||
})
|
})
|
||||||
@@ -1429,7 +1467,7 @@ async function _runSingleMediaAnalysis(
|
|||||||
Array.from(imageMap.entries()).flatMap(([msgId, images]) =>
|
Array.from(imageMap.entries()).flatMap(([msgId, images]) =>
|
||||||
images.map(async (image) => {
|
images.map(async (image) => {
|
||||||
const summary = await analyzeSingleMediaImage(msgId, image);
|
const summary = await analyzeSingleMediaImage(msgId, image);
|
||||||
if (!summary) return;
|
// summary is never null — always returns either the analysis or a failure description
|
||||||
const existing = mediaAnalysisMap.get(msgId) ?? [];
|
const existing = mediaAnalysisMap.get(msgId) ?? [];
|
||||||
existing.push(summary);
|
existing.push(summary);
|
||||||
mediaAnalysisMap.set(msgId, existing);
|
mediaAnalysisMap.set(msgId, existing);
|
||||||
|
|||||||
Reference in New Issue
Block a user