feat(ai-moderation): add simple text-only LLM fallback when normal analysis fails for small models

This commit is contained in:
MythEclipse
2026-06-04 13:40:25 +07:00
parent e55ee342b1
commit 3fc26592fc
2 changed files with 233 additions and 42 deletions
@@ -23,6 +23,7 @@ import {
} from "../message-capture/messageStore.js";
import type {
AnalysisQueueStatus,
AnalysisResult,
MessageRecord,
ModerationBroadcaster,
} from "../message-capture/types.js";
@@ -31,7 +32,10 @@ import {
buildConversationContext,
estimateTokens,
} from "./conversationContext.js";
import { runModerationAnalysis } from "./llmModerationClient.js";
import {
runModerationAnalysis,
runSimpleTextFallback,
} from "./llmModerationClient.js";
import { logModerationError } from "./responseLogger.js";
const logger = createChildLogger("ai-analyzer");
@@ -342,6 +346,7 @@ async function processIndividualFallback(
// Track whether all retries were exhausted specifically because the LLM
// consistently returned no result for this message (vs. a transient error).
let exhaustedOnIncomplete = false;
let usedSimpleFallback = false;
try {
const contextBefore = await getConversationContextBefore({
@@ -363,53 +368,84 @@ async function processIndividualFallback(
...contextIds,
]);
const analysisResult = await retryWithBackoff(
async () => {
try {
const result = await runModerationAnalysis({
targets: [message],
contextText: contextLines.join("\n"),
attachments,
});
// ── Step 1: Try the normal analysis path (retries on failure) ──
let analysisResult: { results: AnalysisResult[] } | null = null;
// If the LLM still dropped our only target, convert to a retryable
// throw so backoff kicks in. Track this so the catch block can
// distinguish it from a transient network/parse failure.
const stillIncomplete = result.results.some((r) =>
r.flags.includes("analysis_incomplete"),
);
if (stillIncomplete) {
exhaustedOnIncomplete = true;
throw new Error(
`LLM returned no result for single-target message ${messageId} — will retry with backoff`,
try {
analysisResult = await retryWithBackoff(
async () => {
try {
const result = await runModerationAnalysis({
targets: [message],
contextText: contextLines.join("\n"),
attachments,
});
// If the LLM still dropped our only target, convert to a retryable
// throw so backoff kicks in. Track this so the catch block can
// distinguish it from a transient network/parse failure.
const stillIncomplete = result.results.some((r) =>
r.flags.includes("analysis_incomplete"),
);
}
if (stillIncomplete) {
exhaustedOnIncomplete = true;
throw new Error(
`LLM returned no result for single-target message ${messageId} — will retry with backoff`,
);
}
// Got a real result — clear the incomplete flag.
exhaustedOnIncomplete = false;
// Got a real result — clear the incomplete flag.
exhaustedOnIncomplete = false;
return result;
} catch (err: any) {
// Propagate AbortError so outer retry is immediately cancelled on 429.
if (err instanceof AbortError) {
return result;
} catch (err: any) {
// Propagate AbortError so outer retry is immediately cancelled on 429.
if (err instanceof AbortError) {
throw err;
}
if (
err?.status === 429 ||
err?.status === 401 ||
err?.status === 403
) {
throw new AbortError(err);
}
throw err;
}
if (
err?.status === 429 ||
err?.status === 401 ||
err?.status === 403
) {
throw new AbortError(err);
}
throw err;
}
},
{
retries: 0,
minTimeout: 0,
maxTimeout: 0,
},
);
},
{
retries: 0,
minTimeout: 0,
maxTimeout: 0,
},
);
} catch {
// Normal path failed — don't give up yet. Try the simple fallback.
analysisResult = null;
}
// ── Step 2: If normal analysis failed, try SIMPLE fallback ──
// No JSON, no complex prompt — just asks the LLM for one word.
if (!analysisResult) {
logger.info(
{ messageId },
"Normal analysis failed for individual message — trying simple text fallback",
);
usedSimpleFallback = true;
const simpleResult = await runSimpleTextFallback(message);
analysisResult = { results: [simpleResult] };
// Clear the exhausted flag since we got a result from the simple path
exhaustedOnIncomplete = false;
}
// At this point we definitely have a result (either normal or simple)
if (usedSimpleFallback) {
logger.info(
{ messageId, status: analysisResult.results[0]?.status },
"Used simple text fallback for individual message — no JSON, one-word classification",
);
}
const updates = analysisResult.results.map((r) => ({
messageId: r.messageId,