From 8f6a35f591b98efca4604564f4bf8bf94b9aa9d9 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Sat, 30 May 2026 14:48:50 +0700 Subject: [PATCH] feat: integrate NVIDIA Nemotron-3 Content Safety API for Indonesian badword detection - Added configuration options for NVIDIA Nemotron API key, model, and base URL. - Refactored badword detection to utilize NVIDIA API, with a fallback to a local badword list. - Updated moderation functions to handle asynchronous operations for text evidence generation. - Removed dependency on the `indonesian-badwords` package and implemented custom detection logic. - Enhanced tests to accommodate asynchronous behavior and validate new detection methods. --- .env.example | 3 + package.json | 2 +- pnpm-lock.yaml | 128 ++++++-- src/config.ts | 11 + src/moderation/aiAnalysisWorker.ts | 2 +- src/moderation/aiAnalyzer.ts | 11 +- src/moderation/conversationContext.ts | 21 +- src/moderation/indonesianTextNormalizer.ts | 279 +++++++++++------- src/moderation/llmModerationClient.ts | 16 +- src/types/indonesian-badwords.d.ts | 25 -- .../indonesianTextNormalizer.test.ts | 24 +- 11 files changed, 344 insertions(+), 178 deletions(-) delete mode 100644 src/types/indonesian-badwords.d.ts diff --git a/.env.example b/.env.example index f96d35c..8540ea3 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,9 @@ AI_LLM_API_KEY=your_9router_key_here AI_LLM_BASE_URL=https://9router.asepharyana.tech/v1 AI_LLM_MODEL=free +# NVIDIA Nemotron Content Safety Configuration +NVIDIA_NEMOTRON_API_KEY=your_nvidia_api_key_here + # Database Configuration DATABASE_TYPE=sqlite # DATABASE_TYPE=postgres diff --git a/package.json b/package.json index 172b8dd..3ff63bd 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@snazzah/davey": "^0.1.11", "@types/pg": "^8.20.0", "@vitejs/plugin-react": "^6.0.2", + "axios": "^1.16.1", "better-sqlite3": "^12.10.0", "clsx": "^2.1.1", "discord.js-selfbot-v13": "workspace:*", @@ -42,7 +43,6 @@ "drizzle-orm": "^0.45.2", "express": "^5.2.1", "helmet": "^8.1.0", - "indonesian-badwords": "^1.0.1", "libsodium-wrappers": "^0.8.4", "lucide-react": "^1.16.0", "motion": "^12.40.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8684284..3f33591 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,7 +34,10 @@ importers: version: 8.20.0 '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) + version: 6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) + axios: + specifier: ^1.16.1 + version: 1.16.1 better-sqlite3: specifier: ^12.10.0 version: 12.10.0 @@ -56,9 +59,6 @@ importers: helmet: specifier: ^8.1.0 version: 8.1.0 - indonesian-badwords: - specifier: ^1.0.1 - version: 1.0.1 libsodium-wrappers: specifier: ^0.8.4 version: 0.8.4 @@ -103,7 +103,7 @@ importers: version: 3.6.0 vite: specifier: ^8.0.13 - version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) + version: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) winston: specifier: ^3.19.0 version: 3.19.0 @@ -158,7 +158,7 @@ importers: version: 5.9.3 vitest: specifier: latest - version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) + version: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) vendor/discord-video-stream: dependencies: @@ -2191,6 +2191,9 @@ packages: resolution: {integrity: sha512-nTQfwHtnL+MSqPaUJhV22GWP3jThj0GnS4Nw1uJyBus6EQ40hQFnbBGUvWxC5P3m+1neSqH1p8asMXg/ypmsQw==} engines: {node: '>=6.0.0'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -2202,6 +2205,9 @@ packages: peerDependencies: postcss: ^8.1.0 + axios@1.16.1: + resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2388,6 +2394,10 @@ packages: resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} engines: {node: '>=18'} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + command-line-args@5.2.1: resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} engines: {node: '>=4.0.0'} @@ -2510,6 +2520,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + delegates@1.0.0: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} @@ -2707,6 +2721,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.18.20: resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} engines: {node: '>=12'} @@ -2921,6 +2939,19 @@ packages: fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -3057,6 +3088,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + has-unicode@2.0.1: resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} @@ -3118,9 +3153,6 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - indonesian-badwords@1.0.1: - resolution: {integrity: sha512-A8V6hklYqql15yelhnx5jkoDLru9hPr/q7TE6S4fICZvb7lbluWUXqGjcWpgmDCsbXpecs38/fbuhvM0em/7CQ==} - inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -3513,10 +3545,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.2: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} @@ -3974,6 +4014,10 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -6303,10 +6347,10 @@ snapshots: dependencies: '@types/node': 25.8.0 - '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))': + '@vitejs/plugin-react@6.0.2(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) '@vitest/expect@4.1.7': dependencies: @@ -6317,13 +6361,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2))': + '@vitest/mocker@4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.7 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) '@vitest/pretty-format@4.1.7': dependencies: @@ -6446,6 +6490,8 @@ snapshots: asyncc@2.0.9: {} + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} autoprefixer@10.5.0(postcss@8.5.14): @@ -6457,6 +6503,16 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 + axios@1.16.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.5 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + balanced-match@1.0.2: {} base64-js@1.5.1: {} @@ -6649,6 +6705,10 @@ snapshots: color-convert: 3.1.3 color-string: 2.1.4 + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + command-line-args@5.2.1: dependencies: array-back: 3.1.0 @@ -6764,6 +6824,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + delayed-stream@1.0.0: {} + delegates@1.0.0: {} depd@2.0.0: {} @@ -6871,6 +6933,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + esbuild@0.18.20: optionalDependencies: '@esbuild/android-arm': 0.18.20 @@ -7226,6 +7295,16 @@ snapshots: fn.name@1.1.0: {} + follow-redirects@1.16.0: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.3 + mime-types: 2.1.35 + forwarded@0.2.0: {} fraction.js@5.3.4: {} @@ -7368,6 +7447,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + has-unicode@2.0.1: {} hasown@2.0.3: @@ -7422,8 +7505,6 @@ snapshots: indent-string@4.0.0: {} - indonesian-badwords@1.0.1: {} - inflight@1.0.6: dependencies: once: 1.4.0 @@ -7788,8 +7869,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.2: dependencies: mime-db: 1.54.0 @@ -8212,6 +8299,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@2.1.0: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -8926,7 +9015,7 @@ snapshots: vary@1.1.2: {} - vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2): + vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -8939,11 +9028,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 tsx: 4.22.2 + yaml: 2.9.0 - vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)): + vitest@4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.0)(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.7 - '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)) + '@vitest/mocker': 4.1.7(vite@8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.7 '@vitest/runner': 4.1.7 '@vitest/snapshot': 4.1.7 @@ -8960,7 +9050,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2) + vite: 8.0.13(@types/node@25.9.0)(esbuild@0.28.0)(jiti@2.7.0)(tsx@4.22.2)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.1 diff --git a/src/config.ts b/src/config.ts index 6705a70..ef7e718 100644 --- a/src/config.ts +++ b/src/config.ts @@ -114,6 +114,17 @@ const configSchema = z .int() .positive() .default(10), + /** 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"), AUTO_DELETE_FLAGGED_ENABLED: z .string() .optional() diff --git a/src/moderation/aiAnalysisWorker.ts b/src/moderation/aiAnalysisWorker.ts index 4de4843..46961e3 100644 --- a/src/moderation/aiAnalysisWorker.ts +++ b/src/moderation/aiAnalysisWorker.ts @@ -77,7 +77,7 @@ export default async function processAnalysisRequest({ limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT, }); - const contextLines = buildConversationContext({ + const contextLines = await buildConversationContext({ contextBefore, targets: messages, maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS, diff --git a/src/moderation/aiAnalyzer.ts b/src/moderation/aiAnalyzer.ts index 97b6210..c476808 100644 --- a/src/moderation/aiAnalyzer.ts +++ b/src/moderation/aiAnalyzer.ts @@ -9,8 +9,6 @@ import { retryWithBackoff } from "../retry.js"; import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js"; import { buildConversationContext, - estimateTokens, - formatMessageForPrompt, } from "./conversationContext.js"; import { runModerationAnalysis } from "./llmModerationClient.js"; import { @@ -157,6 +155,8 @@ export function getConversationKey(message: MessageRecord): string { /** * Picks a batch of messages within a token budget. * `tokensPerMessage` accounts for JSON structure overhead around each entry. + * Uses a rough character-based token estimate (avoids async formatMessageForPrompt + * since this function runs in a synchronous promise chain). */ export function pickBatchWithinBudget( messages: MessageRecord[], @@ -167,8 +167,9 @@ export function pickBatchWithinBudget( let usedTokens = 0; for (const msg of messages) { - const formatted = formatMessageForPrompt(msg, "target"); - const msgTokens = estimateTokens(formatted) + tokensPerMessage; + const content = msg.edited_content ?? msg.content; + // Rough token estimate: ~3 chars per token + metadata overhead + const msgTokens = Math.ceil(content.length / 3) + tokensPerMessage; if (usedTokens + msgTokens <= maxTokens) { batch.push(msg); @@ -238,7 +239,7 @@ async function processIndividualFallback( limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT, }); - const contextLines = buildConversationContext({ + const contextLines = await buildConversationContext({ contextBefore, targets: [message], maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS, diff --git a/src/moderation/conversationContext.ts b/src/moderation/conversationContext.ts index 1256dd1..bb8bb1e 100644 --- a/src/moderation/conversationContext.ts +++ b/src/moderation/conversationContext.ts @@ -25,13 +25,13 @@ export function estimateTokens(text: string): number { /** * Formats a single message for context or target display */ -export function formatMessageForPrompt( +export async function formatMessageForPrompt( msg: MessageRecord, label: "context" | "target", -): string { +): Promise { const content = msg.edited_content ?? msg.content; const timestamp = formatTimestamp(msg.created_at); - const textEvidence = formatModerationTextEvidenceForPrompt(content); + const textEvidence = await formatModerationTextEvidenceForPrompt(content); const textSuffix = textEvidence ? ` ${textEvidence}` : ""; const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata); const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : ""; @@ -42,22 +42,23 @@ export function formatMessageForPrompt( * Builds conversation historical context without including targets. * Calculates how much token budget targets use, and fills the rest with context. */ -export function buildConversationContext( +export async function buildConversationContext( input: ConversationContextInput, -): string[] { +): Promise { const { contextBefore, targets, maxTokens } = input; - // Calculate tokens used by targets - let usedTokens = targets.reduce((sum, msg) => { - return sum + estimateTokens(formatMessageForPrompt(msg, "target")); - }, 0); + // Calculate tokens used by targets (parallel) + const targetLines = await Promise.all( + targets.map((msg) => formatMessageForPrompt(msg, "target")), + ); + let usedTokens = targetLines.reduce((sum, line) => sum + estimateTokens(line), 0); const selectedContextLines: string[] = []; // Go backwards through context, taking most recent first for (let i = contextBefore.length - 1; i >= 0; i--) { const msg = contextBefore[i]; - const line = formatMessageForPrompt(msg, "context"); + const line = await formatMessageForPrompt(msg, "context"); const lineTokens = estimateTokens(line); if (usedTokens + lineTokens <= maxTokens) { diff --git a/src/moderation/indonesianTextNormalizer.ts b/src/moderation/indonesianTextNormalizer.ts index da118d7..5138ce4 100644 --- a/src/moderation/indonesianTextNormalizer.ts +++ b/src/moderation/indonesianTextNormalizer.ts @@ -1,20 +1,40 @@ -import badwordsModule from "indonesian-badwords"; +import axios from "axios"; +import { config } from "../config.js"; import { INDONESIAN_SLANG_LEXICON } from "./resources/indonesianSlangLexicon.js"; +import { createChildLogger } from "../logger.js"; + +const log = createChildLogger("indonesianTextNormalizer"); const CUSTOM_EMOJI_PATTERN = //g; const WORD_PATTERN = /[\p{L}\p{N}_]+/gu; -interface BadwordAnalyzeResult { - badwords?: string[]; - count?: number; -} +/** NVIDIA content safety categories that map to offensive/badword content. */ +const NVIDIA_BAD_CATEGORIES = new Set([ + "hate", + "harassment", + "sexual", + "violence", + "self-harm", + "illicit", + "profanity", + "vulgar", + "insult", +]); -interface BadwordsModule { - analyze?: (text: string) => BadwordAnalyzeResult; - flag?: (text: string) => boolean; -} - -const badwords = badwordsModule as BadwordsModule; +/** + * Map NVIDIA Nemotron category labels to Indonesian badword-style labels. + */ +const CATEGORY_TO_BADWORD_LABEL: Record = { + hate: "hate_speech", + harassment: "harassment", + sexual: "sexual_content", + violence: "violence", + "self-harm": "self_harm", + illicit: "illegal_content", + profanity: "vulgar_language", + vulgar: "vulgar_language", + insult: "harassment", +}; export interface ModerationTextEvidence { raw: string; @@ -24,6 +44,10 @@ export interface ModerationTextEvidence { hasBadwords: boolean; } +// --------------------------------------------------------------------------- +// Sync helpers (unchanged) +// --------------------------------------------------------------------------- + export function normalizeDiscordCustomEmoji(text: string): { text: string; emojiNames: string[]; @@ -53,104 +77,155 @@ export function normalizeIndonesianSlang(text: string): { return { text: normalized, notes: Array.from(new Set(notes)) }; } -export function detectIndonesianBadwords(text: string): string[] { - try { - const result = badwords.analyze?.(text); - if (Array.isArray(result?.badwords)) { - let hits = Array.from(new Set(result.badwords.map((word) => word.toLowerCase()))); +// --------------------------------------------------------------------------- +// Local fallback badword list (used when NVIDIA API is unavailable) +// --------------------------------------------------------------------------- - const lowerText = text.toLowerCase(); +const LOCAL_BADWORDS = [ + "anjing", "bangsat", "brengsek", "bajingan", "kontol", "memek", + "tai", "goblok", "tolol", "bego", "sialan", "jancuk", "kampret", + "pepek", "jembut", "ngentot", "ngewe", "coli", "celaka", "laknat", + "pantek", "entod", "ndasmu", "ndas", "piyo", "asu", +]; - // ----------------------------------------------------------------------- - // False-positive filters — exclude badword hits that appear only as - // substrings of longer innocent words. Each filter checks whether the - // hit exists as a standalone word OR as part of a word that is NOT in - // the whitelist. - // ----------------------------------------------------------------------- - const words = lowerText.match(/[\p{L}\p{N}_]+/gu) || []; +const FALSE_POSITIVE_WHITELISTS: Record = { + asu: [ + "asus", "masuk", "termasuk", "dimasukkan", "memasukkan", + "kasur", "asumsi", "asuransi", "asupan", "pasukan", "pasundan", + ], + goblok: ["goblok"], + kontol: ["kontol"], + memek: ["memek"], + tolol: ["tolol"], +}; - /** Returns true if the given hit appears in the text as a standalone word - * or inside a word that is NOT in the whitelist. */ - const isRealHit = (hit: string, whitelist: string[]): boolean => { - for (const w of words) { - if (w.includes(hit)) { - // If the word IS an exact match, it's definitely a real hit. - if (w === hit) return true; - // If it's inside a longer word, check the whitelist. - if (!whitelist.includes(w)) return true; - } - } - return false; - }; +function detectLocalBadwords(text: string): string[] { + const lowerText = text.toLowerCase(); + const words = lowerText.match(/[\p{L}\p{N}_]+/gu) || []; - hits = hits.filter((hit) => { - switch (hit) { - case "asu": - return isRealHit(hit, [ - "asus", "masuk", "termasuk", "dimasukkan", "memasukkan", - "kasur", "asumsi", "asuransi", "asupan", "pasukan", "pasundan", - ]); - case "goblok": - return isRealHit(hit, [ - "goblok", // standalone is always flagged - ]); - case "kontol": - return isRealHit(hit, [ - "kontol", // standalone is always flagged - ]); - case "memek": - return isRealHit(hit, [ - "memek", // standalone is always flagged - ]); - case "tolol": - return isRealHit(hit, [ - "tolol", // standalone is always flagged - ]); - case "beg": - // Short substring — only flag if it appears as a standalone word - // or in a known profanity context, not inside "bego" variants. - return words.some(w => w === "beg" || w === "bgo" || w === "bgoo"); - default: - return true; - } - }); - - // ----------------------------------------------------------------------- - // Secondary detection: catch slang/vowelless forms the npm package misses. - // These are words that appear standalone (not inside a longer word) after - // normalization has already run. - // ----------------------------------------------------------------------- - const SLANG_BADWORDS = [ - "anjing", "bangsat", "brengsek", "bajingan", "kontol", "memek", - "tai", "goblok", "tolol", "bego", "sialan", "jancuk", "kampret", - "pepek", "jembut", "ngentot", "ngewe", "coli", "celaka", "laknat", - "pantek", "entod", "ndasmu", "ndas", "piyo", - ]; - - for (const slang of SLANG_BADWORDS) { - if (hits.includes(slang)) continue; - - const standalonePattern = new RegExp( - `(?:^|\\s|[^\\p{L}])${slang}(?:$|\\s|[^\\p{L}])`, - "iu", - ); - if (standalonePattern.test(lowerText)) { - hits.push(slang); - } + const isRealHit = (hit: string, whitelist: string[]): boolean => { + for (const w of words) { + if (w.includes(hit)) { + if (w === hit) return true; + if (!whitelist.includes(w)) return true; } - - return Array.from(new Set(hits)); } - } catch { - // Keep moderation pipeline resilient if dependency changes shape. + return false; + }; + + const hits: string[] = []; + + for (const badword of LOCAL_BADWORDS) { + const whitelist = FALSE_POSITIVE_WHITELISTS[badword] ?? [badword]; + if (isRealHit(badword, whitelist)) { + hits.push(badword); + } } - return []; + + return Array.from(new Set(hits)); } -export function buildModerationTextEvidence(text: string): ModerationTextEvidence { +// --------------------------------------------------------------------------- +// NVIDIA Nemotron-3 Content Safety API +// --------------------------------------------------------------------------- + +/** + * Call NVIDIA Nemotron-3 Content Safety API to detect harmful content. + * Returns categories/flags from the API response. + */ +async function callNemotronContentSafety(text: string): Promise { + const apiKey = config.NVIDIA_NEMOTRON_API_KEY; + if (!apiKey) { + 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, + chat_template_kwargs: { request_categories: "/categories" }, + }, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + timeout: 15_000, + }, + ); + + const data = response.data; + const categories: string[] = []; + + // Parse the LLM response for category flags + const content = data?.choices?.[0]?.message?.content ?? ""; + if (content) { + const lowerContent = content.toLowerCase(); + for (const category of NVIDIA_BAD_CATEGORIES) { + // Check if the category appears as a key in the response + // The Nemotron content safety model returns structured data with category scores + if (lowerContent.includes(category)) { + categories.push(CATEGORY_TO_BADWORD_LABEL[category] ?? category); + } + } + } + + // Also check for structured response fields + const choice = data?.choices?.[0]; + if (choice?.message?.content) { + try { + const parsed = JSON.parse(choice.message.content); + if (parsed.categories && Array.isArray(parsed.categories)) { + for (const cat of parsed.categories) { + if (NVIDIA_BAD_CATEGORIES.has(cat.name ?? cat)) { + categories.push(CATEGORY_TO_BADWORD_LABEL[cat.name ?? cat] ?? cat); + } + } + } + } catch { + // Not JSON — already handled via text search above + } + } + + return Array.from(new Set(categories)); +} + +/** + * Detect badwords in text using NVIDIA Nemotron-3 Content Safety API. + * Falls back to local lexical list if API key is missing or call fails. + */ +export async function detectIndonesianBadwords(text: string): Promise { + // Always run local detection first (fast, no network dependency) + const localHits = detectLocalBadwords(text); + + // Try NVIDIA API if key is configured + const apiKey = config.NVIDIA_NEMOTRON_API_KEY; + if (apiKey) { + try { + const apiCategories = await callNemotronContentSafety(text); + const allHits = Array.from(new Set([...localHits, ...apiCategories])); + return allHits; + } catch (error) { + log.warn({ error }, "NVIDIA Nemotron API call failed, falling back to local detection"); + } + } + + return localHits; +} + +// --------------------------------------------------------------------------- +// Async evidence builders +// --------------------------------------------------------------------------- + +export async function buildModerationTextEvidence(text: string): Promise { const emojiNormalized = normalizeDiscordCustomEmoji(text); const slangNormalized = normalizeIndonesianSlang(emojiNormalized.text); - const badwordHits = detectIndonesianBadwords(slangNormalized.text); + const badwordHits = await detectIndonesianBadwords(slangNormalized.text); const notes = [...slangNormalized.notes]; for (const emojiName of emojiNormalized.emojiNames) { @@ -160,9 +235,9 @@ export function buildModerationTextEvidence(text: string): ModerationTextEvidenc } if (badwordHits.length > 0) { - notes.push(`local lexical check: Indonesian badword detected: ${badwordHits.join(", ")}`); + notes.push(`Indonesian badword detected: ${badwordHits.join(", ")}`); } else { - notes.push("local lexical check: no Indonesian badword detected"); + notes.push("no Indonesian badword detected"); } return { @@ -174,8 +249,8 @@ export function buildModerationTextEvidence(text: string): ModerationTextEvidenc }; } -export function formatModerationTextEvidenceForPrompt(text: string): string { - const evidence = buildModerationTextEvidence(text); +export async function formatModerationTextEvidenceForPrompt(text: string): Promise { + const evidence = await buildModerationTextEvidence(text); if (evidence.normalized === evidence.raw && evidence.notes.length === 0) { return ""; } diff --git a/src/moderation/llmModerationClient.ts b/src/moderation/llmModerationClient.ts index 1737274..8321216 100644 --- a/src/moderation/llmModerationClient.ts +++ b/src/moderation/llmModerationClient.ts @@ -805,7 +805,17 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan let lastParseError: string | null = null; let lastInvalidContent: string | null = null; - const buildMessageContent = (): string => { + // Pre-compute text evidence for all targets in parallel + const textEvidenceMap = new Map(); + await Promise.all( + targets.map(async (msg) => { + const content = msg.edited_content ?? msg.content; + const evidence = await formatModerationTextEvidenceForPrompt(content); + textEvidenceMap.set(msg.id, evidence); + }), + ); + + const buildMessageContent = async (): Promise => { const correction = lastParseError ? { error: lastParseError, @@ -821,7 +831,7 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan const webTexts = messageWebTextMap.get(msg.id) ?? []; const mediaAnalyses = messageMediaAnalysisMap.get(msg.id) ?? []; const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : ""; - const textEvidence = formatModerationTextEvidenceForPrompt(content); + const textEvidence = textEvidenceMap.get(msg.id) ?? ""; const textContext = textEvidence ? `\n${textEvidence}` : ""; const mediaAnalysisContext = mediaAnalyses.length > 0 ? `\n${mediaAnalyses.join("\n")}` : ""; @@ -856,7 +866,7 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan messages: [ { role: "user", - content: buildMessageContent(), + content: await buildMessageContent(), }, ], temperature: 0.2, diff --git a/src/types/indonesian-badwords.d.ts b/src/types/indonesian-badwords.d.ts deleted file mode 100644 index 61a5ca6..0000000 --- a/src/types/indonesian-badwords.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -declare module "indonesian-badwords" { - export interface BadwordAnalyzeResult { - text?: string; - words?: number; - censored?: string; - badwords?: string[]; - count?: number; - locations?: Array<{ word: string; index: number }>; - } - - export function analyze(text: string): BadwordAnalyzeResult; - export function flag(text: string): boolean; - export function filter(text: string): string; - export function censor(text: string): string; - - const value: { - analyze: typeof analyze; - flag: typeof flag; - filter: typeof filter; - censor: typeof censor; - dict?: unknown; - badwords?: unknown; - }; - export default value; -} diff --git a/tests/moderation/indonesianTextNormalizer.test.ts b/tests/moderation/indonesianTextNormalizer.test.ts index 065ee50..aa61c25 100644 --- a/tests/moderation/indonesianTextNormalizer.test.ts +++ b/tests/moderation/indonesianTextNormalizer.test.ts @@ -42,20 +42,20 @@ describe("normalizeIndonesianSlang", () => { }); describe("detectIndonesianBadwords", () => { - it("detects known badword", () => { - const badwords = detectIndonesianBadwords("kontol banget"); + it("detects known badword via local fallback", async () => { + const badwords = await detectIndonesianBadwords("kontol banget"); expect(badwords).toContain("kontol"); }); - it("returns empty array for safe slang", () => { - const badwords = detectIndonesianBadwords("woy hadeh gua"); + it("returns empty array for safe slang", async () => { + const badwords = await detectIndonesianBadwords("woy hadeh gua"); expect(badwords).toHaveLength(0); }); }); describe("buildModerationTextEvidence", () => { - it("produces correct evidence for woy with emoji", () => { - const evidence = buildModerationTextEvidence( + it("produces correct evidence for woy with emoji", async () => { + const evidence = await buildModerationTextEvidence( "Bersiaplah woy <:hadeh:1217434294281048185>", ); expect(evidence.normalized).toContain("[emoji:hadeh]"); @@ -66,16 +66,16 @@ describe("buildModerationTextEvidence", () => { expect(evidence.notes.some((n) => n.includes("casual"))).toBe(true); }); - it("detects badword when present", () => { - const evidence = buildModerationTextEvidence("anjing loe kontol"); + it("detects badword when present", async () => { + const evidence = await buildModerationTextEvidence("anjing loe kontol"); expect(evidence.hasBadwords).toBe(true); expect(evidence.notes.some((n) => n.includes("badword detected"))).toBe(true); }); }); describe("formatModerationTextEvidenceForPrompt", () => { - it("returns prompt evidence for slang + emoji", () => { - const formatted = formatModerationTextEvidenceForPrompt( + it("returns prompt evidence for slang + emoji", async () => { + const formatted = await formatModerationTextEvidenceForPrompt( "Bersiaplah woy <:hadeh:1217434294281048185>", ); expect(formatted).toContain("[normalized_text:"); @@ -84,8 +84,8 @@ describe("formatModerationTextEvidenceForPrompt", () => { expect(formatted).toContain("no Indonesian badword detected"); }); - it("includes normalized text even for clean input", () => { - const formatted = formatModerationTextEvidenceForPrompt("Halo semua"); + it("includes normalized text even for clean input", async () => { + const formatted = await formatModerationTextEvidenceForPrompt("Halo semua"); expect(formatted).toContain("[normalized_text:"); expect(formatted).toContain("no Indonesian badword detected"); });