chore: auto-commit task -
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
-- 0002_add_sticker_cache.sql
|
||||
-- Creates the sticker_cache table (defined in schema.ts but missing from migrations)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "sticker_cache" (
|
||||
"name" text PRIMARY KEY NOT NULL,
|
||||
"base64" text NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"size" integer NOT NULL,
|
||||
"fetched_at" bigint NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_sticker_cache_fetched_at" ON "sticker_cache" ("fetched_at");
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from "axios";
|
||||
import OpenAI from "openai";
|
||||
import { AbortError } from "p-retry";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||
import { retryWithBackoff } from "../../shared/utils/retry.js";
|
||||
@@ -74,9 +75,12 @@ const BADWORD_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
*/
|
||||
const DB_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const NEMOTRON_RATE_LIMIT_COOLDOWN_MS = 0;
|
||||
const PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS = 0;
|
||||
const GROQ_RATE_LIMIT_COOLDOWN_MS = 0;
|
||||
const NEMOTRON_RATE_LIMIT_COOLDOWN_MS = 60_000; // 1 min backoff on 429
|
||||
const PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS = 60_000; // 1 min backoff on 429
|
||||
const GROQ_RATE_LIMIT_COOLDOWN_MS = 60_000; // 1 min backoff on 429
|
||||
|
||||
/** How long to mark a provider unavailable after a transient (5xx/timeout) error. */
|
||||
const TRANSIENT_ERROR_COOLDOWN_MS = 30_000; // 30s backoff on 502/timeout
|
||||
|
||||
interface BadwordCacheEntry {
|
||||
value: string[];
|
||||
@@ -275,9 +279,9 @@ async function callPrimaryAiModeration(text: string): Promise<string[]> {
|
||||
} as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming);
|
||||
},
|
||||
{
|
||||
retries: 0,
|
||||
minTimeout: 0,
|
||||
maxTimeout: 0,
|
||||
retries: 2,
|
||||
minTimeout: 2000,
|
||||
maxTimeout: 5000,
|
||||
factor: 2,
|
||||
logger: log,
|
||||
},
|
||||
@@ -306,19 +310,36 @@ async function callGrokModeration(text: string): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
config.GROQ_MODERATION_BASE_URL,
|
||||
{
|
||||
model: config.GROQ_MODERATION_MODEL,
|
||||
messages: [{ role: "user", content: text }],
|
||||
const response = await retryWithBackoff(
|
||||
async () => {
|
||||
const res = await axios.post(
|
||||
config.GROQ_MODERATION_BASE_URL,
|
||||
{
|
||||
model: config.GROQ_MODERATION_MODEL,
|
||||
messages: [{ role: "user", content: text }],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 15_000,
|
||||
validateStatus: (status: number) => status < 500,
|
||||
},
|
||||
);
|
||||
// 429 should abort retry immediately — no point hammering a rate limit
|
||||
if (res.status === 429) {
|
||||
throw new AbortError("Groq rate limited");
|
||||
}
|
||||
return res;
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 10_000,
|
||||
retries: 2,
|
||||
minTimeout: 2000,
|
||||
maxTimeout: 5000,
|
||||
factor: 2,
|
||||
logger: log,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -360,22 +381,39 @@ async function callNemotronContentSafety(text: string): Promise<string[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
config.NVIDIA_NEMOTRON_BASE_URL,
|
||||
{
|
||||
model: config.NVIDIA_NEMOTRON_MODEL,
|
||||
messages: [{ role: "user", content: text }],
|
||||
max_tokens: 897,
|
||||
temperature: 0.2,
|
||||
top_p: 0.7,
|
||||
stream: false,
|
||||
const response = await retryWithBackoff(
|
||||
async () => {
|
||||
const res = await axios.post(
|
||||
config.NVIDIA_NEMOTRON_BASE_URL,
|
||||
{
|
||||
model: config.NVIDIA_NEMOTRON_MODEL,
|
||||
messages: [{ role: "user", content: text }],
|
||||
max_tokens: 897,
|
||||
temperature: 0.2,
|
||||
top_p: 0.7,
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
timeout: 15_000,
|
||||
validateStatus: (status: number) => status < 500,
|
||||
},
|
||||
);
|
||||
// 429 should abort retry immediately — no point hammering a rate limit
|
||||
if (res.status === 429) {
|
||||
throw new AbortError("NVIDIA rate limited");
|
||||
}
|
||||
return res;
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
timeout: 15_000,
|
||||
retries: 2,
|
||||
minTimeout: 2000,
|
||||
maxTimeout: 5000,
|
||||
factor: 2,
|
||||
logger: log,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -478,6 +516,9 @@ export async function detectIndonesianBadwords(
|
||||
if (status === 429) {
|
||||
nemotronUnavailableUntil =
|
||||
Date.now() + NEMOTRON_RATE_LIMIT_COOLDOWN_MS;
|
||||
} else {
|
||||
// 502, timeout, or other transient error — cooldown briefly
|
||||
nemotronUnavailableUntil = Date.now() + TRANSIENT_ERROR_COOLDOWN_MS;
|
||||
}
|
||||
log.warn(
|
||||
{ error },
|
||||
@@ -501,6 +542,9 @@ export async function detectIndonesianBadwords(
|
||||
if (status === 429) {
|
||||
primaryAiUnavailableUntil =
|
||||
Date.now() + PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS;
|
||||
} else {
|
||||
// 502, timeout, or other transient error — cooldown briefly
|
||||
primaryAiUnavailableUntil = Date.now() + TRANSIENT_ERROR_COOLDOWN_MS;
|
||||
}
|
||||
log.warn(
|
||||
{ error },
|
||||
@@ -525,6 +569,9 @@ export async function detectIndonesianBadwords(
|
||||
: null;
|
||||
if (status === 429) {
|
||||
groqUnavailableUntil = Date.now() + GROQ_RATE_LIMIT_COOLDOWN_MS;
|
||||
} else {
|
||||
// 502, timeout, or other transient error — cooldown briefly
|
||||
groqUnavailableUntil = Date.now() + TRANSIENT_ERROR_COOLDOWN_MS;
|
||||
}
|
||||
log.warn({ error }, "Groq Llama Prompt Guard moderation failed");
|
||||
}
|
||||
|
||||
@@ -750,6 +750,7 @@ async function callModerationLLM(
|
||||
throw parseError;
|
||||
}
|
||||
} catch (apiError: any) {
|
||||
// 429/401/403 → abort immediately, never retry
|
||||
if (
|
||||
apiError?.status === 429 ||
|
||||
apiError?.status === 401 ||
|
||||
@@ -757,63 +758,124 @@ async function callModerationLLM(
|
||||
) {
|
||||
throw new AbortError(apiError);
|
||||
}
|
||||
// 5xx server errors → retryable transient errors
|
||||
// p-retry will retry these; on final exhaustion the outer catch
|
||||
// will produce synthetic error results for all targets
|
||||
if (
|
||||
apiError?.status >= 500 ||
|
||||
apiError?.code === "ECONNRESET" ||
|
||||
apiError?.code === "ETIMEDOUT" ||
|
||||
apiError?.name === "APIError"
|
||||
) {
|
||||
// re-throw as-is so p-retry can retry
|
||||
throw apiError;
|
||||
}
|
||||
throw apiError;
|
||||
}
|
||||
},
|
||||
{
|
||||
retries: 0,
|
||||
retries: 2,
|
||||
minTimeout: 3000,
|
||||
maxTimeout: 8000,
|
||||
factor: 2,
|
||||
logger: log,
|
||||
},
|
||||
);
|
||||
parsed = analysis.parsed;
|
||||
result = analysis.result;
|
||||
} catch (parseError) {
|
||||
if (!state.lastInvalidContent) {
|
||||
throw parseError;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
const isApiError = !state.lastInvalidContent;
|
||||
|
||||
const errorMsg =
|
||||
parseError instanceof Error ? parseError.message : String(parseError);
|
||||
// For API errors (502, timeout, etc.) where retries exhausted, produce
|
||||
// synthetic error results so the batch doesn't crash entirely.
|
||||
// For parse errors, we already have lastInvalidContent and the existing
|
||||
// fallback path below handles it.
|
||||
const apiErrorCode = isApiError
|
||||
? `MOD_${Date.now().toString(36).slice(0, 6)}`
|
||||
: null;
|
||||
|
||||
log.error(
|
||||
{
|
||||
error: errorMsg,
|
||||
contentLength: state.lastInvalidContent.length,
|
||||
contentPreview: state.lastInvalidContent.substring(0, 500),
|
||||
if (isApiError) {
|
||||
log.warn(
|
||||
{
|
||||
error: errorMsg,
|
||||
targetIds,
|
||||
model: config.AI_LLM_MODEL,
|
||||
label,
|
||||
},
|
||||
`LLM API error after retries exhausted (${label}) — marking all targets as analysis errors`,
|
||||
);
|
||||
|
||||
logModerationError(
|
||||
targetIds,
|
||||
model: config.AI_LLM_MODEL,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
`Robust Fallback (${label}): Failed to parse moderation response. Marking all targets as analysis errors.`,
|
||||
);
|
||||
config.AI_LLM_MODEL,
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
{
|
||||
phase: "api_call",
|
||||
label,
|
||||
},
|
||||
);
|
||||
|
||||
// Log error with responseLogger
|
||||
logModerationError(
|
||||
targetIds,
|
||||
config.AI_LLM_MODEL,
|
||||
parseError as Error | string,
|
||||
{
|
||||
phase: "parse_response",
|
||||
label,
|
||||
contentLength: state.lastInvalidContent.length,
|
||||
},
|
||||
);
|
||||
parsed = targetIds.map((id) => ({
|
||||
messageId: id,
|
||||
status: "error",
|
||||
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",
|
||||
confidence: 0,
|
||||
recommendedAction: "review",
|
||||
policyVersion: "default-2026-05-30",
|
||||
evidence: [],
|
||||
}));
|
||||
} else {
|
||||
// Parse error fallback — existing path
|
||||
const parseMsg = err instanceof Error ? err.message : String(err);
|
||||
const contentPreview =
|
||||
state.lastInvalidContent?.substring(0, 500) ?? "<empty>";
|
||||
const contentLen = state.lastInvalidContent?.length ?? 0;
|
||||
|
||||
// Sanitized error messages — no internal details exposed (R10)
|
||||
const errorCode = `MOD_${Date.now().toString(36).slice(0, 6)}`;
|
||||
parsed = targetIds.map((id) => ({
|
||||
messageId: id,
|
||||
status: "error",
|
||||
flags: ["analysis_parse_failed"],
|
||||
score: 0,
|
||||
analysis: `Analisis gagal dan memerlukan pemeriksaan manual. Error code: ${errorCode}`,
|
||||
categories: ["analysis_parse_failed"],
|
||||
severity: "none",
|
||||
confidence: 0,
|
||||
recommendedAction: "review",
|
||||
policyVersion: "default-2026-05-30",
|
||||
evidence: [],
|
||||
}));
|
||||
log.error(
|
||||
{
|
||||
error: parseMsg,
|
||||
contentLength: contentLen,
|
||||
contentPreview,
|
||||
targetIds,
|
||||
model: config.AI_LLM_MODEL,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
`Robust Fallback (${label}): Failed to parse moderation response. Marking all targets as analysis errors.`,
|
||||
);
|
||||
|
||||
// Log error with responseLogger
|
||||
logModerationError(
|
||||
targetIds,
|
||||
config.AI_LLM_MODEL,
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
{
|
||||
phase: "parse_response",
|
||||
label,
|
||||
contentLength: contentLen,
|
||||
},
|
||||
);
|
||||
|
||||
// Sanitized error messages — no internal details exposed (R10)
|
||||
const errorCode = `MOD_${Date.now().toString(36).slice(0, 6)}`;
|
||||
parsed = targetIds.map((id) => ({
|
||||
messageId: id,
|
||||
status: "error",
|
||||
flags: ["analysis_parse_failed"],
|
||||
score: 0,
|
||||
analysis: `Analisis gagal dan memerlukan pemeriksaan manual. Error code: ${errorCode}`,
|
||||
categories: ["analysis_parse_failed"],
|
||||
severity: "none",
|
||||
confidence: 0,
|
||||
recommendedAction: "review",
|
||||
policyVersion: "default-2026-05-30",
|
||||
evidence: [],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return { results: parsed, raw: result };
|
||||
|
||||
@@ -131,28 +131,8 @@ const configSchema = z
|
||||
.int()
|
||||
.positive()
|
||||
.default(50),
|
||||
/** NVIDIA Nemotron-3 Content Safety API key for badword detection. */
|
||||
NVIDIA_NEMOTRON_API_KEY: z.string().optional(),
|
||||
/** NVIDIA Nemotron model identifier. */
|
||||
NVIDIA_NEMOTRON_MODEL: z
|
||||
.string()
|
||||
.default("nvidia/nemotron-3-content-safety"),
|
||||
/** NVIDIA Nemotron API base URL. */
|
||||
NVIDIA_NEMOTRON_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://integrate.api.nvidia.com/v1/chat/completions"),
|
||||
/** Groq API key for Llama Prompt Guard moderation fallback. */
|
||||
GROQ_API_KEY: z.string().optional(),
|
||||
/** Groq moderation model identifier. */
|
||||
GROQ_MODERATION_MODEL: z
|
||||
.string()
|
||||
.default("meta-llama/llama-prompt-guard-2-86m"),
|
||||
/** Groq API base URL. */
|
||||
GROQ_MODERATION_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://api.groq.com/openai/v1/chat/completions"),
|
||||
// AI moderation uses the Primary LLM (AI_LLM_*) endpoint only.
|
||||
// No NVIDIA or Groq fallback.
|
||||
AUTO_DELETE_FLAGGED_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
+2
-22
@@ -131,28 +131,8 @@ const configSchema = z
|
||||
.int()
|
||||
.positive()
|
||||
.default(50),
|
||||
/** NVIDIA Nemotron-3 Content Safety API key for badword detection. */
|
||||
NVIDIA_NEMOTRON_API_KEY: z.string().optional(),
|
||||
/** NVIDIA Nemotron model identifier. */
|
||||
NVIDIA_NEMOTRON_MODEL: z
|
||||
.string()
|
||||
.default("nvidia/nemotron-3-content-safety"),
|
||||
/** NVIDIA Nemotron API base URL. */
|
||||
NVIDIA_NEMOTRON_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://integrate.api.nvidia.com/v1/chat/completions"),
|
||||
/** Groq API key for Llama Prompt Guard moderation fallback. */
|
||||
GROQ_API_KEY: z.string().optional(),
|
||||
/** Groq moderation model identifier. */
|
||||
GROQ_MODERATION_MODEL: z
|
||||
.string()
|
||||
.default("meta-llama/llama-prompt-guard-2-86m"),
|
||||
/** Groq API base URL. */
|
||||
GROQ_MODERATION_BASE_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://api.groq.com/openai/v1/chat/completions"),
|
||||
// AI moderation uses the Primary LLM (AI_LLM_*) endpoint only.
|
||||
// No NVIDIA or Groq fallback.
|
||||
AUTO_DELETE_FLAGGED_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
Reference in New Issue
Block a user