fix(ai-moderation): add Furina/Genshin character name exception to prevent false positive furry flags
Nama karakter game/anime populer seperti 'Furina' dari Genshin Impact sering kena false positive sebagai 'sexual_deviation' karena kemiripan fonetik dengan kata 'furry'. Menambahkan aturan eksplisit bahwa nama karakter fiksi normal bukan referensi furry fetish, dgn pengecualian jika konteks pesan secara eksplisit membahas aspek fetish/seksual. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
25c86dcc30
commit
817f1ce3df
@@ -73,6 +73,8 @@ export interface RetryOptions {
|
||||
maxTimeout?: number;
|
||||
/** Multiplication factor for each retry (default: 2) */
|
||||
factor?: number;
|
||||
/** Optional AbortSignal to cancel retries */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function retryWithBackoff<T>(
|
||||
@@ -84,20 +86,47 @@ export async function retryWithBackoff<T>(
|
||||
minTimeout = 1_000,
|
||||
maxTimeout = 30_000,
|
||||
factor = 2,
|
||||
signal,
|
||||
} = options;
|
||||
|
||||
let lastError: Error | undefined;
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
if (signal?.aborted) {
|
||||
const err = new Error("Aborted");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
if (lastError.name === "AbortError") {
|
||||
throw lastError;
|
||||
}
|
||||
if (attempt === retries) break;
|
||||
const backoff = Math.min(
|
||||
minTimeout * factor ** attempt + Math.random() * 100,
|
||||
maxTimeout,
|
||||
);
|
||||
await delay(backoff);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeoutId);
|
||||
const abortErr = new Error("Aborted");
|
||||
abortErr.name = "AbortError";
|
||||
reject(abortErr);
|
||||
};
|
||||
if (signal?.aborted) return onAbort();
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (signal) signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, backoff);
|
||||
|
||||
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
throw lastError!;
|
||||
|
||||
@@ -56,6 +56,8 @@ export interface LlmCallOpts {
|
||||
retries?: number;
|
||||
/** Whether to use streaming (if true, will consume stream and return aggregated result) */
|
||||
stream?: boolean;
|
||||
/** Optional AbortSignal to cancel the API request */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +81,7 @@ export async function llmChat(
|
||||
jsonResponse,
|
||||
retries = DEFAULT_RETRIES,
|
||||
stream,
|
||||
signal,
|
||||
} = opts;
|
||||
|
||||
const params: any = {
|
||||
@@ -101,7 +104,7 @@ export async function llmChat(
|
||||
async () => {
|
||||
return withLlmConcurrency(async () => {
|
||||
const execute = async (currentParams: any) => {
|
||||
const response = await client.chat.completions.create(currentParams);
|
||||
const response = await client.chat.completions.create(currentParams, { signal });
|
||||
if (currentParams.stream) {
|
||||
let content = "";
|
||||
let finishReason = "stop";
|
||||
@@ -168,6 +171,7 @@ export async function llmChat(
|
||||
minTimeout: 2_000,
|
||||
maxTimeout: 30_000,
|
||||
factor: 3,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createChildLogger } from "@bete/shared/logger";
|
||||
import { delay, retryWithBackoff } from "@bete/shared/utils";
|
||||
import { LRUCache } from "lru-cache";
|
||||
import type { ChatCompletion } from "openai/resources/chat/completions";
|
||||
import { AbortError } from "p-retry";
|
||||
import { z } from "zod";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { resizeImageForVision } from "../attachment-upload/imageResizer.js";
|
||||
@@ -758,6 +757,7 @@ async function callModerationLLM(
|
||||
buildContent: (state: RetryState) => Promise<string>,
|
||||
targetIds: string[],
|
||||
label: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{
|
||||
results: AnalysisResult[];
|
||||
raw: ChatCompletion | null;
|
||||
@@ -781,6 +781,7 @@ async function callModerationLLM(
|
||||
max_tokens: 16384,
|
||||
jsonResponse: { type: "json_object" },
|
||||
retries: 0,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!completion) {
|
||||
@@ -848,7 +849,9 @@ async function callModerationLLM(
|
||||
apiError?.status === 401 ||
|
||||
apiError?.status === 403
|
||||
) {
|
||||
throw new AbortError(apiError);
|
||||
const abortErr = new Error(String(apiError));
|
||||
abortErr.name = "AbortError";
|
||||
throw abortErr;
|
||||
}
|
||||
// 5xx server errors → retryable transient errors
|
||||
// p-retry will retry these; on final exhaustion the outer catch
|
||||
@@ -870,11 +873,16 @@ async function callModerationLLM(
|
||||
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;
|
||||
|
||||
@@ -1173,21 +1181,25 @@ async function runTextOnlyBatch(
|
||||
return `${systemText}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`;
|
||||
};
|
||||
|
||||
const batchResult = (await Promise.race([
|
||||
callModerationLLM(buildContent, targetIds, `text-batch-${i + 1}`),
|
||||
new Promise<{ results: AnalysisResult[]; raw: unknown }>((_, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Text-only batch sub-batch ${i + 1} timed out for messages ${targetIds.join(", ")}`,
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, timeoutMs);
|
||||
timeoutId.unref();
|
||||
|
||||
let batchResult: { results: AnalysisResult[]; raw: unknown };
|
||||
try {
|
||||
batchResult = await callModerationLLM(buildContent, targetIds, `text-batch-${i + 1}`, abortController.signal);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError" || abortController.signal.aborted) {
|
||||
throw new Error(
|
||||
`Text-only batch sub-batch ${i + 1} timed out for messages ${targetIds.join(", ")}`,
|
||||
);
|
||||
timeout.unref();
|
||||
}),
|
||||
])) as { results: AnalysisResult[]; raw: unknown };
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const rawUsage = (
|
||||
batchResult.raw as {
|
||||
@@ -1269,27 +1281,31 @@ async function runSingleMediaAnalysis(
|
||||
// Timeout wrapper (R4)
|
||||
const timeoutMs = config.AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS ?? 60000;
|
||||
|
||||
return Promise.race([
|
||||
_runSingleMediaAnalysis(
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, timeoutMs);
|
||||
timeoutId.unref();
|
||||
|
||||
try {
|
||||
return await _runSingleMediaAnalysis(
|
||||
target,
|
||||
contextText,
|
||||
allAttachments,
|
||||
targetId,
|
||||
targetIds,
|
||||
),
|
||||
new Promise<{ results: AnalysisResult[]; raw: unknown }>((_, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`Media analysis timed out after ${timeoutMs}ms for message ${targetId}`,
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
abortController.signal,
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError" || abortController.signal.aborted) {
|
||||
throw new Error(
|
||||
`Media analysis timed out after ${timeoutMs}ms for message ${targetId}`,
|
||||
);
|
||||
timeout.unref();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function _runSingleMediaAnalysis(
|
||||
@@ -1298,6 +1314,7 @@ async function _runSingleMediaAnalysis(
|
||||
allAttachments: AttachmentRecord[] | undefined,
|
||||
targetId: string,
|
||||
targetIds: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ results: AnalysisResult[]; raw: unknown }> {
|
||||
// Lazy init sticker cache
|
||||
if (!isStickerCacheReady()) {
|
||||
@@ -1694,6 +1711,7 @@ async function _runSingleMediaAnalysis(
|
||||
async (_state: RetryState) => userContent,
|
||||
targetIds,
|
||||
`media:${targetId}`,
|
||||
signal,
|
||||
);
|
||||
|
||||
return result;
|
||||
|
||||
@@ -21,6 +21,7 @@ Bahasa utama komunitas ini adalah BAHASA INDONESIA. Bahasa Inggris adalah bahasa
|
||||
## Aturan Umum
|
||||
- Bahasa gaul/slang Indonesia: "anjay", "wkwk", "gws", "gaskeun", "santuy", "njir", "baka", "woy", "woi", "hadeh", dll adalah AMAN.
|
||||
- Istilah kultur pop/anime Jepang: "moe", "waifu", "husbando", "tsundere", "wibu", "otaku" adalah ekspresi normal/AMAN dan BUKAN "sexual_deviation". JANGAN flag kata-kata ini kecuali diiringi deskripsi/ajakan seksual eksplisit.
|
||||
- **NAMA KARAKTER GAME/ANIME:** Nama karakter fiksi dari game, anime, atau media populer (seperti "Furina" dari Genshin Impact, "Lucario" dari Pokemon, "Kitsune" sebagai karakter, dll) adalah AMAN dan BUKAN referensi furry fetish, meskipun namanya secara fonetik mirip kata "furry". Jangan flag karakter humanoid atau desain karakter normal hanya karena kemiripan nama. PENGECUALIAN: Tetap flag jika konteks pesan secara eksplisit membahas aspek fetish/seksual dari karakter tersebut.
|
||||
- Lirik lagu (termasuk lagu sejarah/politik seperti Internasionale), puisi, copypasta meme, atau kutipan literatur adalah AMAN. JANGAN flag sebagai "conflict_instigation" atau "sara" HANYA karena teks aslinya bernada politis atau revolusioner. Flag hanya jika pengirim secara eksplisit menambahkan ajakan/hasutan bertengkar antar anggota server.
|
||||
- Singkatan umum: "gw", "lo", "emg", "kyk", "tdk", "krn", "jgn", dll adalah AMAN.
|
||||
- Makian/kata kasar umum (emosi marah seperti "anjing", "asu", "bangsat", "ngehe") BUKAN pelanggaran SARA. Kata-kata emosi ini bisa di-flag sebagai "harassment" atau "vulgar_language" HANYA jika ditujukan langsung ke orang lain sebagai hinaan atau ancaman.
|
||||
|
||||
Reference in New Issue
Block a user