Files
GMW/services/discord-gateway/src/modules/ai-moderation/llmCaller.ts
T
DeveloperandClaude Opus 4.8 5802d02e29
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 2m22s
Build & Deploy / build-and-push (backend) (push) Failing after 3m22s
Build & Deploy / build-and-push (proxy) (push) Successful in 1m36s
Build & Deploy / deploy (push) Skipped
refactor: large codebase cleanup - consolidate schemas, migrate to Drizzle ORM, extract frontend components, modernize Docker builds
- Consolidate all DB schema definitions into packages/shared as single source of truth
- Migrate backend from raw SQL to Drizzle ORM across all modules
- Extract frontend inline UI into separate component files
- Refactor discord-gateway circuitBreaker into conversationState + moderationState
- Convert messageStore to Proxy singleton pattern
- Add validateBody/validateQuery middleware + Zod schemas for API endpoints
- Modernize Docker builds with multi-stage + pnpm deploy
- Migrate CI/CD from deployment to image-based pipeline
- Remove 60+ unused/dead files (~15K lines)
- Update color scheme from sky-blue to teal-cyan
- Move DB connection management to @bete/shared/database

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 21:54:31 +07:00

211 lines
6.8 KiB
TypeScript

/**
* llmCaller.ts
*
* Shared LLM call + parse + retry helper extracted from moderationOrchestrator
* to break the circular import chain:
*
* moderationOrchestrator → mediaBatchProcessor / textBatchProcessor
* mediaBatchProcessor / textBatchProcessor → moderationOrchestrator (callModerationLLM)
*
* Both sides now import from this module instead.
*/
import { createChildLogger } from "@bete/shared/logger";
import { delay, retryWithBackoff } from "@bete/shared/utils";
import type { ChatCompletion } from "openai/resources/chat/completions";
import { config } from "../../shared/config/config.js";
import type { AnalysisResult } from "../message-capture/types.js";
import { llmChat } from "./llmClient.js";
import { logModerationError } from "./responseLogger.js";
const log = createChildLogger("llm-caller");
// ---------------------------------------------------------------------------
// Retry state
// ---------------------------------------------------------------------------
export interface RetryState {
lastParseError: string | null;
lastInvalidContent: string | null;
}
// ---------------------------------------------------------------------------
// Shared LLM call + parse + fallback helper
// ---------------------------------------------------------------------------
export async function callModerationLLM(
buildContent: (state: RetryState) => Promise<string>,
targetIds: string[],
label: string,
signal?: AbortSignal,
): Promise<{
results: AnalysisResult[];
raw: ChatCompletion | null;
}> {
const state: RetryState = {
lastParseError: null,
lastInvalidContent: null,
};
let parsed: AnalysisResult[];
let result: ChatCompletion | null = null;
try {
const analysis = await retryWithBackoff(
async () => {
try {
const content = await buildContent(state);
const completion = await llmChat({
messages: [{ role: "user", content }],
max_tokens: 16384,
jsonResponse: { type: "json_object" },
retries: 0,
signal,
});
if (!completion)
throw new Error("LLM client unavailable (no API key)");
if (
!completion.choices ||
!Array.isArray(completion.choices) ||
!completion.choices[0]
) {
throw new Error("Invalid LLM response structure");
}
const rawContent = completion.choices[0].message?.content;
if (!rawContent) throw new Error("No content in LLM response");
try {
const { parseModerationResponse } = await import(
"./moderationResponseParser.js"
);
return {
parsed: parseModerationResponse(rawContent, targetIds),
result: completion,
};
} catch (parseError) {
state.lastParseError =
parseError instanceof Error
? parseError.message
: String(parseError);
state.lastInvalidContent = rawContent;
log.warn(
{
error: state.lastParseError,
contentLength: rawContent.length,
targetIds,
model: config.AI_LLM_MODEL,
},
`Failed to parse moderation response (${label})`,
);
throw parseError;
}
} catch (apiError: any) {
if (apiError?.status === 429) {
log.warn(
{ status: 429, targetIds, model: config.AI_LLM_MODEL, label },
"LLM API 429 — will retry",
);
await delay(Math.floor(Math.random() * 1000) + 500);
throw apiError;
}
if (apiError?.status === 401 || apiError?.status === 403) {
const abortErr = new Error(String(apiError));
abortErr.name = "AbortError";
throw abortErr;
}
if (
apiError?.status >= 500 ||
apiError?.code === "ECONNRESET" ||
apiError?.code === "ETIMEDOUT" ||
apiError?.name === "APIError"
) {
throw apiError;
}
throw apiError;
}
},
{
retries: 3,
minTimeout: 5_000,
maxTimeout: 60_000,
factor: 3,
signal,
},
);
parsed = analysis.parsed;
result = analysis.result;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") throw err;
const errorMsg = err instanceof Error ? err.message : String(err);
const isApiError = !state.lastInvalidContent;
const apiErrorCode = isApiError
? `MOD_${Date.now().toString(36).slice(0, 6)}`
: null;
if (isApiError) {
log.warn(
{ error: errorMsg, targetIds, model: config.AI_LLM_MODEL, label },
`LLM API error after retries (${label})`,
);
logModerationError(
targetIds,
config.AI_LLM_MODEL,
err instanceof Error ? err : new Error(String(err)),
{ phase: "api_call", label },
);
parsed = targetIds.map((id) => ({
messageId: id,
status: "error" as const,
flags: ["analysis_api_failed"],
score: 0,
analysis: `Analisis gagal karena error pada server AI dan memerlukan pemeriksaan manual. Error code: ${apiErrorCode}`,
categories: ["analysis_api_failed"],
severity: "none" as const,
confidence: 0,
recommendedAction: "review" as const,
policyVersion: "default-2026-05-30",
evidence: [],
}));
} else {
const parseMsg = err instanceof Error ? err.message : String(err);
const contentPreview =
state.lastInvalidContent?.substring(0, 500) ?? "<empty>";
log.error(
{
error: parseMsg,
contentLength: state.lastInvalidContent?.length ?? 0,
contentPreview,
targetIds,
model: config.AI_LLM_MODEL,
},
`Robust Fallback (${label}): parse error`,
);
logModerationError(
targetIds,
config.AI_LLM_MODEL,
err instanceof Error ? err : new Error(String(err)),
{
phase: "parse_response",
label,
contentLength: state.lastInvalidContent?.length ?? 0,
},
);
const errorCode = `MOD_${Date.now().toString(36).slice(0, 6)}`;
parsed = targetIds.map((id) => ({
messageId: id,
status: "error" as const,
flags: ["analysis_parse_failed"],
score: 0,
analysis: `Analisis gagal dan memerlukan pemeriksaan manual. Error code: ${errorCode}`,
categories: ["analysis_parse_failed"],
severity: "none" as const,
confidence: 0,
recommendedAction: "review" as const,
policyVersion: "default-2026-05-30",
evidence: [],
}));
}
}
return { results: parsed, raw: result };
}