refactor: large codebase cleanup - consolidate schemas, migrate to Drizzle ORM, extract frontend components, modernize Docker builds
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
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
- 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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
63f21513bd
commit
5802d02e29
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user