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 axios from "axios";
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
|
import { AbortError } from "p-retry";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import { createChildLogger } from "../../shared/logger/logger.js";
|
import { createChildLogger } from "../../shared/logger/logger.js";
|
||||||
import { retryWithBackoff } from "../../shared/utils/retry.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 DB_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const NEMOTRON_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 = 0;
|
const PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS = 60_000; // 1 min backoff on 429
|
||||||
const GROQ_RATE_LIMIT_COOLDOWN_MS = 0;
|
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 {
|
interface BadwordCacheEntry {
|
||||||
value: string[];
|
value: string[];
|
||||||
@@ -275,9 +279,9 @@ async function callPrimaryAiModeration(text: string): Promise<string[]> {
|
|||||||
} as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming);
|
} as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
retries: 0,
|
retries: 2,
|
||||||
minTimeout: 0,
|
minTimeout: 2000,
|
||||||
maxTimeout: 0,
|
maxTimeout: 5000,
|
||||||
factor: 2,
|
factor: 2,
|
||||||
logger: log,
|
logger: log,
|
||||||
},
|
},
|
||||||
@@ -306,7 +310,9 @@ async function callGrokModeration(text: string): Promise<string[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await retryWithBackoff(
|
||||||
|
async () => {
|
||||||
|
const res = await axios.post(
|
||||||
config.GROQ_MODERATION_BASE_URL,
|
config.GROQ_MODERATION_BASE_URL,
|
||||||
{
|
{
|
||||||
model: config.GROQ_MODERATION_MODEL,
|
model: config.GROQ_MODERATION_MODEL,
|
||||||
@@ -318,7 +324,22 @@ async function callGrokModeration(text: string): Promise<string[]> {
|
|||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
timeout: 10_000,
|
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;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retries: 2,
|
||||||
|
minTimeout: 2000,
|
||||||
|
maxTimeout: 5000,
|
||||||
|
factor: 2,
|
||||||
|
logger: log,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -360,7 +381,9 @@ async function callNemotronContentSafety(text: string): Promise<string[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await retryWithBackoff(
|
||||||
|
async () => {
|
||||||
|
const res = await axios.post(
|
||||||
config.NVIDIA_NEMOTRON_BASE_URL,
|
config.NVIDIA_NEMOTRON_BASE_URL,
|
||||||
{
|
{
|
||||||
model: config.NVIDIA_NEMOTRON_MODEL,
|
model: config.NVIDIA_NEMOTRON_MODEL,
|
||||||
@@ -376,6 +399,21 @@ async function callNemotronContentSafety(text: string): Promise<string[]> {
|
|||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
},
|
},
|
||||||
timeout: 15_000,
|
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;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retries: 2,
|
||||||
|
minTimeout: 2000,
|
||||||
|
maxTimeout: 5000,
|
||||||
|
factor: 2,
|
||||||
|
logger: log,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -478,6 +516,9 @@ export async function detectIndonesianBadwords(
|
|||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
nemotronUnavailableUntil =
|
nemotronUnavailableUntil =
|
||||||
Date.now() + NEMOTRON_RATE_LIMIT_COOLDOWN_MS;
|
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(
|
log.warn(
|
||||||
{ error },
|
{ error },
|
||||||
@@ -501,6 +542,9 @@ export async function detectIndonesianBadwords(
|
|||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
primaryAiUnavailableUntil =
|
primaryAiUnavailableUntil =
|
||||||
Date.now() + PRIMARY_AI_RATE_LIMIT_COOLDOWN_MS;
|
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(
|
log.warn(
|
||||||
{ error },
|
{ error },
|
||||||
@@ -525,6 +569,9 @@ export async function detectIndonesianBadwords(
|
|||||||
: null;
|
: null;
|
||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
groqUnavailableUntil = Date.now() + GROQ_RATE_LIMIT_COOLDOWN_MS;
|
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");
|
log.warn({ error }, "Groq Llama Prompt Guard moderation failed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -750,6 +750,7 @@ async function callModerationLLM(
|
|||||||
throw parseError;
|
throw parseError;
|
||||||
}
|
}
|
||||||
} catch (apiError: any) {
|
} catch (apiError: any) {
|
||||||
|
// 429/401/403 → abort immediately, never retry
|
||||||
if (
|
if (
|
||||||
apiError?.status === 429 ||
|
apiError?.status === 429 ||
|
||||||
apiError?.status === 401 ||
|
apiError?.status === 401 ||
|
||||||
@@ -757,29 +758,89 @@ async function callModerationLLM(
|
|||||||
) {
|
) {
|
||||||
throw new AbortError(apiError);
|
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;
|
throw apiError;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
retries: 0,
|
retries: 2,
|
||||||
|
minTimeout: 3000,
|
||||||
|
maxTimeout: 8000,
|
||||||
|
factor: 2,
|
||||||
logger: log,
|
logger: log,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
parsed = analysis.parsed;
|
parsed = analysis.parsed;
|
||||||
result = analysis.result;
|
result = analysis.result;
|
||||||
} catch (parseError) {
|
} catch (err) {
|
||||||
if (!state.lastInvalidContent) {
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||||
throw parseError;
|
const isApiError = !state.lastInvalidContent;
|
||||||
}
|
|
||||||
|
|
||||||
const errorMsg =
|
// For API errors (502, timeout, etc.) where retries exhausted, produce
|
||||||
parseError instanceof Error ? parseError.message : String(parseError);
|
// 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;
|
||||||
|
|
||||||
|
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,
|
||||||
|
config.AI_LLM_MODEL,
|
||||||
|
err instanceof Error ? err : new Error(String(err)),
|
||||||
|
{
|
||||||
|
phase: "api_call",
|
||||||
|
label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
log.error(
|
log.error(
|
||||||
{
|
{
|
||||||
error: errorMsg,
|
error: parseMsg,
|
||||||
contentLength: state.lastInvalidContent.length,
|
contentLength: contentLen,
|
||||||
contentPreview: state.lastInvalidContent.substring(0, 500),
|
contentPreview,
|
||||||
targetIds,
|
targetIds,
|
||||||
model: config.AI_LLM_MODEL,
|
model: config.AI_LLM_MODEL,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -791,11 +852,11 @@ async function callModerationLLM(
|
|||||||
logModerationError(
|
logModerationError(
|
||||||
targetIds,
|
targetIds,
|
||||||
config.AI_LLM_MODEL,
|
config.AI_LLM_MODEL,
|
||||||
parseError as Error | string,
|
err instanceof Error ? err : new Error(String(err)),
|
||||||
{
|
{
|
||||||
phase: "parse_response",
|
phase: "parse_response",
|
||||||
label,
|
label,
|
||||||
contentLength: state.lastInvalidContent.length,
|
contentLength: contentLen,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -815,6 +876,7 @@ async function callModerationLLM(
|
|||||||
evidence: [],
|
evidence: [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { results: parsed, raw: result };
|
return { results: parsed, raw: result };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,28 +131,8 @@ const configSchema = z
|
|||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.default(50),
|
.default(50),
|
||||||
/** NVIDIA Nemotron-3 Content Safety API key for badword detection. */
|
// AI moderation uses the Primary LLM (AI_LLM_*) endpoint only.
|
||||||
NVIDIA_NEMOTRON_API_KEY: z.string().optional(),
|
// No NVIDIA or Groq fallback.
|
||||||
/** 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"),
|
|
||||||
AUTO_DELETE_FLAGGED_ENABLED: z
|
AUTO_DELETE_FLAGGED_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
+2
-22
@@ -131,28 +131,8 @@ const configSchema = z
|
|||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.default(50),
|
.default(50),
|
||||||
/** NVIDIA Nemotron-3 Content Safety API key for badword detection. */
|
// AI moderation uses the Primary LLM (AI_LLM_*) endpoint only.
|
||||||
NVIDIA_NEMOTRON_API_KEY: z.string().optional(),
|
// No NVIDIA or Groq fallback.
|
||||||
/** 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"),
|
|
||||||
AUTO_DELETE_FLAGGED_ENABLED: z
|
AUTO_DELETE_FLAGGED_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
Reference in New Issue
Block a user