fix(moderation): revert API failures to pending instead of sticking in error state
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user