fix(moderation): revert API failures to pending instead of sticking in error state

This commit is contained in:
MythEclipse
2026-06-04 18:52:37 +07:00
parent 946343ec8c
commit 367d8696c8
@@ -381,11 +381,11 @@ async function processIndividualFallback(
try { try {
// ── Run the LLM-heavy work in the worker thread ── // ── Run the LLM-heavy work in the worker thread ──
// Try normal analysis first. The worker handles retries internally. // Try normal analysis first. The worker handles retries internally.
const workerResult = await workerPool.run({ const workerResult = (await workerPool.run({
type: "individual", type: "individual",
message, message,
skipNormalAnalysis: false, skipNormalAnalysis: false,
} as any) as } as any)) as
| { ok: true; results: AnalysisResult[] } | { ok: true; results: AnalysisResult[] }
| { ok: false; results: AnalysisResult[]; error: string }; | { ok: false; results: AnalysisResult[]; error: string };
@@ -411,11 +411,11 @@ async function processIndividualFallback(
"Normal analysis failed (or incomplete) — trying simple text fallback via worker", "Normal analysis failed (or incomplete) — trying simple text fallback via worker",
); );
const simpleResult = await workerPool.run({ const simpleResult = (await workerPool.run({
type: "individual", type: "individual",
message, message,
skipNormalAnalysis: true, skipNormalAnalysis: true,
} as any) as } as any)) as
| { ok: true; results: AnalysisResult[] } | { ok: true; results: AnalysisResult[] }
| { ok: false; results: AnalysisResult[]; error: string }; | { ok: false; results: AnalysisResult[]; error: string };
@@ -465,18 +465,13 @@ async function processIndividualFallback(
} }
const resultSummary = analysisResult.results[0]; const resultSummary = analysisResult.results[0];
logModerationError( logModerationError([messageId], config.AI_LLM_MODEL, new Error("Success"), {
[messageId], phase: "individual_fallback",
config.AI_LLM_MODEL, status: resultSummary?.status,
new Error("Success"), flags: resultSummary?.flags,
{ severity: resultSummary?.severity,
phase: "individual_fallback", confidence: resultSummary?.confidence,
status: resultSummary?.status, });
flags: resultSummary?.flags,
severity: resultSummary?.severity,
confidence: resultSummary?.confidence,
},
);
individualConsecutiveErrors = 0; individualConsecutiveErrors = 0;
@@ -659,9 +654,21 @@ async function processBatch(
messages, messages,
})) as AnalysisWorkerResponse; })) as AnalysisWorkerResponse;
// Do not broadcast or auto-delete if it's an API failure that will be reverted.
// We check the flags to see if it's an API failure.
for (const row of result.rows) { for (const row of result.rows) {
broadcastAnalysisCompleted(row); let isApiFailure = false;
scheduleAutoDelete(row); if (row.ai_status === "error") {
try {
const flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
isApiFailure = flags.includes("analysis_api_failed");
} catch {}
}
if (!isApiFailure) {
broadcastAnalysisCompleted(row);
scheduleAutoDelete(row);
}
} }
if (!result.ok) { if (!result.ok) {
@@ -700,40 +707,105 @@ async function processBatch(
return; return;
} }
// Batch succeeded — but check for messages the LLM silently dropped. // Batch succeeded — but check for messages the LLM silently dropped or failed to parse/API.
// Rows with flag "analysis_incomplete" were produced by parseModerationResponse // Rows with flag "analysis_incomplete", "analysis_parse_failed", or "analysis_api_failed"
// as synthetic errors; they must be re-processed individually. // were produced by the client as synthetic errors.
const incompleteMessages = messages.filter((msg) => { const incompleteMessages: MessageRecord[] = [];
const parseFailedMessages: MessageRecord[] = [];
const apiFailedMessages: MessageRecord[] = [];
for (const msg of messages) {
const row = result.rows.find((r) => r.id === msg.id); const row = result.rows.find((r) => r.id === msg.id);
if (!row) { if (!row) {
// The DB update row is missing entirely — treat as incomplete. incompleteMessages.push(msg);
return true; continue;
} }
const flags: string[] = (() => { if (row.ai_status === "error") {
let flags: string[] = [];
try { try {
return JSON.parse(row.ai_moderation_flags ?? "[]") as string[]; flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
} catch { } catch {}
return [];
}
})();
return row.ai_status === "error" && flags.includes("analysis_incomplete");
});
if (incompleteMessages.length > 0) { if (flags.includes("analysis_incomplete")) {
incompleteMessages.push(msg);
} else if (flags.includes("analysis_parse_failed")) {
parseFailedMessages.push(msg);
} else if (flags.includes("analysis_api_failed")) {
apiFailedMessages.push(msg);
}
}
}
const messagesForIndividualQueue = [
...incompleteMessages,
...parseFailedMessages,
];
if (messagesForIndividualQueue.length > 0) {
logger.warn( logger.warn(
{ {
conversationKey, conversationKey,
incompleteCount: incompleteMessages.length, count: messagesForIndividualQueue.length,
incompleteIds: incompleteMessages.map((m) => m.id), ids: messagesForIndividualQueue.map((m) => m.id),
totalBatchSize: messages.length, totalBatchSize: messages.length,
}, },
"Batch returned incomplete results — fanning out to individual fallback queue", "Batch returned incomplete or unparseable results — fanning out to individual fallback queue",
); );
enqueueIndividualFallbacks(incompleteMessages); enqueueIndividualFallbacks(messagesForIndividualQueue);
} }
resetConversationBatchFailures(conversationKey); if (apiFailedMessages.length > 0) {
conversationErrorCooldown.delete(conversationKey); logger.warn(
{
conversationKey,
count: apiFailedMessages.length,
ids: apiFailedMessages.map((m) => m.id),
},
"Batch returned API failures — reverting to pending to put back in queue",
);
// Revert to pending so they are picked up again
const revertedRows = await updateMessagesAIAnalysisBulk(
apiFailedMessages.map((msg) => ({
messageId: msg.id,
result: {
status: "pending",
flags: null,
score: null,
analysis: null,
categories: null,
severity: null,
confidence: null,
recommendedAction: null,
analyzedAt: null,
error: null,
},
})),
).catch((err) => {
logger.error(
{ error: String(err) },
"Failed to revert API failures to pending",
);
return [];
});
for (const row of revertedRows) {
// Broadcast the pending status so the UI knows it's back in queue
broadcastAnalysisCompleted(row);
}
// Trigger conversation cooldown so we don't tight loop the API
recordConversationBatchFailure(conversationKey);
conversationErrorCooldown.set(
conversationKey,
Date.now() + config.AI_ANALYSIS_ERROR_COOLDOWN_MS,
);
}
if (apiFailedMessages.length === 0) {
resetConversationBatchFailures(conversationKey);
conversationErrorCooldown.delete(conversationKey);
}
shouldScheduleNext = true; shouldScheduleNext = true;
} catch (error) { } catch (error) {
recordConversationBatchFailure(conversationKey); recordConversationBatchFailure(conversationKey);