feat(moderation): add support for vision model in moderation analysis
This commit is contained in:
@@ -48,6 +48,8 @@ AI_ANALYSIS_ENABLED=false
|
|||||||
AI_LLM_API_KEY=your_9router_key_here
|
AI_LLM_API_KEY=your_9router_key_here
|
||||||
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1
|
||||||
AI_LLM_MODEL=free
|
AI_LLM_MODEL=free
|
||||||
|
# Vision model for image/video moderation (falls back to AI_LLM_MODEL if unset)
|
||||||
|
AI_LLM_VISION_MODEL=multimodal
|
||||||
|
|
||||||
# NVIDIA Nemotron Content Safety Configuration
|
# NVIDIA Nemotron Content Safety Configuration
|
||||||
NVIDIA_NEMOTRON_API_KEY=your_nvidia_api_key_here
|
NVIDIA_NEMOTRON_API_KEY=your_nvidia_api_key_here
|
||||||
|
|||||||
+9
-8
@@ -71,14 +71,18 @@ const configSchema = z
|
|||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.default("https://9router.asepharyana.tech/v1"),
|
.default("https://9router.asepharyana.tech/v1"),
|
||||||
AI_LLM_MODEL: z.string().default("free"),
|
/** Model used for text-only moderation (messages, badword analysis). */
|
||||||
|
AI_LLM_MODEL: z.string().default("text"),
|
||||||
|
/** Model used for image/video moderation (vision-capable model). */
|
||||||
|
AI_LLM_VISION_MODEL: z.string().optional(),
|
||||||
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
AI_ANALYSIS_DEBOUNCE_MS: z.coerce.number().positive().default(500),
|
||||||
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
|
AI_ANALYSIS_RECOVERY_INTERVAL_MS: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.positive()
|
.positive()
|
||||||
.default(15000),
|
.default(15000),
|
||||||
AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
||||||
AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(25),
|
/** Max messages fetched per conversation batch (token budget is the real constraint). */
|
||||||
|
AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200),
|
||||||
AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000),
|
AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000),
|
||||||
/** Token budget for target messages specifically (separate from context window). */
|
/** Token budget for target messages specifically (separate from context window). */
|
||||||
AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(4000),
|
AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(4000),
|
||||||
@@ -96,15 +100,12 @@ const configSchema = z
|
|||||||
.number()
|
.number()
|
||||||
.positive()
|
.positive()
|
||||||
.default(120000),
|
.default(120000),
|
||||||
/**
|
/** Max concurrent individual-fallback LLM calls (effectively unlimited). */
|
||||||
* Maximum number of concurrent individual-fallback LLM calls.
|
|
||||||
* Prevents OOM/connection exhaustion when many messages miss a batch.
|
|
||||||
*/
|
|
||||||
AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT: z.coerce
|
AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.default(20),
|
.default(1000),
|
||||||
/**
|
/**
|
||||||
* How many consecutive individual-fallback errors trigger the individual
|
* How many consecutive individual-fallback errors trigger the individual
|
||||||
* circuit breaker (separate from the batch circuit breaker).
|
* circuit breaker (separate from the batch circuit breaker).
|
||||||
@@ -113,7 +114,7 @@ const configSchema = z
|
|||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.default(10),
|
.default(50),
|
||||||
/** NVIDIA Nemotron-3 Content Safety API key for badword detection. */
|
/** NVIDIA Nemotron-3 Content Safety API key for badword detection. */
|
||||||
NVIDIA_NEMOTRON_API_KEY: z.string().optional(),
|
NVIDIA_NEMOTRON_API_KEY: z.string().optional(),
|
||||||
/** NVIDIA Nemotron model identifier. */
|
/** NVIDIA Nemotron model identifier. */
|
||||||
|
|||||||
@@ -486,34 +486,15 @@ function enqueueIndividualFallbacks(messages: MessageRecord[]): void {
|
|||||||
const newMessages = messages.filter((m) => !individualInFlight.has(m.id));
|
const newMessages = messages.filter((m) => !individualInFlight.has(m.id));
|
||||||
if (newMessages.length === 0) return;
|
if (newMessages.length === 0) return;
|
||||||
|
|
||||||
// FIX #1: Enforce concurrency cap.
|
|
||||||
const availableSlots =
|
|
||||||
config.AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT - individualInFlight.size;
|
|
||||||
if (availableSlots <= 0) {
|
|
||||||
logger.warn(
|
|
||||||
{
|
|
||||||
cap: config.AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT,
|
|
||||||
inFlight: individualInFlight.size,
|
|
||||||
skipped: newMessages.length,
|
|
||||||
},
|
|
||||||
"Individual fallback concurrency cap reached — messages will be recovered by recovery worker",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toProcess = newMessages.slice(0, availableSlots);
|
|
||||||
const skipped = newMessages.length - toProcess.length;
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
count: toProcess.length,
|
count: newMessages.length,
|
||||||
skipped,
|
messageIds: newMessages.map((m) => m.id),
|
||||||
messageIds: toProcess.map((m) => m.id),
|
|
||||||
},
|
},
|
||||||
"Enqueueing individual fallback analysis for batch-incomplete messages",
|
"Enqueueing individual fallback analysis for batch-incomplete messages",
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const msg of toProcess) {
|
for (const msg of newMessages) {
|
||||||
individualInFlight.add(msg.id);
|
individualInFlight.add(msg.id);
|
||||||
// Fire-and-forget: processIndividualFallback handles all errors internally.
|
// Fire-and-forget: processIndividualFallback handles all errors internally.
|
||||||
processIndividualFallback(msg).catch((err) => {
|
processIndividualFallback(msg).catch((err) => {
|
||||||
@@ -855,8 +836,8 @@ export function startPendingAIAnalysisWorker(client?: Client): void {
|
|||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
// FIX #3 pattern: no async arrow — chain promises explicitly.
|
// FIX #3 pattern: no async arrow — chain promises explicitly.
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getPendingConversationKeys(100),
|
getPendingConversationKeys(500),
|
||||||
getConversationKeysWithIncompleteAnalysis(50),
|
getConversationKeysWithIncompleteAnalysis(200),
|
||||||
])
|
])
|
||||||
.then(([pendingKeys, incompleteKeys]) => {
|
.then(([pendingKeys, incompleteKeys]) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -902,10 +883,7 @@ export function startPendingAIAnalysisWorker(client?: Client): void {
|
|||||||
if (isConversationProcessingLocked(key)) continue;
|
if (isConversationProcessingLocked(key)) continue;
|
||||||
|
|
||||||
promises.push(
|
promises.push(
|
||||||
getIncompleteMessagesByConversation(
|
getIncompleteMessagesByConversation(key, 500)
|
||||||
key,
|
|
||||||
config.AI_ANALYSIS_INDIVIDUAL_MAX_CONCURRENT,
|
|
||||||
)
|
|
||||||
.then(async (msgs) => {
|
.then(async (msgs) => {
|
||||||
const processableMessages =
|
const processableMessages =
|
||||||
await skipAgeRestrictedMessages(msgs);
|
await skipAgeRestrictedMessages(msgs);
|
||||||
|
|||||||
@@ -769,7 +769,7 @@ export async function runModerationAnalysis(
|
|||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: config.AI_LLM_MODEL,
|
model: config.AI_LLM_VISION_MODEL ?? config.AI_LLM_MODEL,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|||||||
@@ -630,7 +630,7 @@ export async function getConversationContextBefore(input: {
|
|||||||
|
|
||||||
export async function getPendingMessagesByConversation(
|
export async function getPendingMessagesByConversation(
|
||||||
conversationKey: string,
|
conversationKey: string,
|
||||||
limit: number = 25,
|
limit: number = 200,
|
||||||
): Promise<MessageRecord[]> {
|
): Promise<MessageRecord[]> {
|
||||||
try {
|
try {
|
||||||
const database = db();
|
const database = db();
|
||||||
@@ -667,7 +667,7 @@ export async function getPendingMessagesByConversation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getPendingConversationKeys(
|
export async function getPendingConversationKeys(
|
||||||
limit: number = 100,
|
limit: number = 500,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const database = db();
|
const database = db();
|
||||||
@@ -782,7 +782,7 @@ export async function searchMessages(input: {
|
|||||||
* the individual-fallback queue.
|
* the individual-fallback queue.
|
||||||
*/
|
*/
|
||||||
export async function getConversationKeysWithIncompleteAnalysis(
|
export async function getConversationKeysWithIncompleteAnalysis(
|
||||||
limit: number = 50,
|
limit: number = 200,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const database = db();
|
const database = db();
|
||||||
@@ -826,7 +826,7 @@ export async function getConversationKeysWithIncompleteAnalysis(
|
|||||||
*/
|
*/
|
||||||
export async function getIncompleteMessagesByConversation(
|
export async function getIncompleteMessagesByConversation(
|
||||||
conversationKey: string,
|
conversationKey: string,
|
||||||
limit: number = 20,
|
limit: number = 500,
|
||||||
): Promise<MessageRecord[]> {
|
): Promise<MessageRecord[]> {
|
||||||
try {
|
try {
|
||||||
const database = db();
|
const database = db();
|
||||||
|
|||||||
Reference in New Issue
Block a user