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.
This commit is contained in:
MythEclipse
2026-05-30 14:48:50 +07:00
parent 3cc6b7a924
commit 8f6a35f591
11 changed files with 344 additions and 178 deletions
+3
View File
@@ -49,6 +49,9 @@ 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
# NVIDIA Nemotron Content Safety Configuration
NVIDIA_NEMOTRON_API_KEY=your_nvidia_api_key_here
# Database Configuration # Database Configuration
DATABASE_TYPE=sqlite DATABASE_TYPE=sqlite
# DATABASE_TYPE=postgres # DATABASE_TYPE=postgres
+1 -1
View File
@@ -35,6 +35,7 @@
"@snazzah/davey": "^0.1.11", "@snazzah/davey": "^0.1.11",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"axios": "^1.16.1",
"better-sqlite3": "^12.10.0", "better-sqlite3": "^12.10.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"discord.js-selfbot-v13": "workspace:*", "discord.js-selfbot-v13": "workspace:*",
@@ -42,7 +43,6 @@
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"indonesian-badwords": "^1.0.1",
"libsodium-wrappers": "^0.8.4", "libsodium-wrappers": "^0.8.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"motion": "^12.40.0", "motion": "^12.40.0",
+109 -19
View File
@@ -34,7 +34,10 @@ importers:
version: 8.20.0 version: 8.20.0
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^6.0.2 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: better-sqlite3:
specifier: ^12.10.0 specifier: ^12.10.0
version: 12.10.0 version: 12.10.0
@@ -56,9 +59,6 @@ importers:
helmet: helmet:
specifier: ^8.1.0 specifier: ^8.1.0
version: 8.1.0 version: 8.1.0
indonesian-badwords:
specifier: ^1.0.1
version: 1.0.1
libsodium-wrappers: libsodium-wrappers:
specifier: ^0.8.4 specifier: ^0.8.4
version: 0.8.4 version: 0.8.4
@@ -103,7 +103,7 @@ importers:
version: 3.6.0 version: 3.6.0
vite: vite:
specifier: ^8.0.13 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: winston:
specifier: ^3.19.0 specifier: ^3.19.0
version: 3.19.0 version: 3.19.0
@@ -158,7 +158,7 @@ importers:
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: latest 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: vendor/discord-video-stream:
dependencies: dependencies:
@@ -2191,6 +2191,9 @@ packages:
resolution: {integrity: sha512-nTQfwHtnL+MSqPaUJhV22GWP3jThj0GnS4Nw1uJyBus6EQ40hQFnbBGUvWxC5P3m+1neSqH1p8asMXg/ypmsQw==} resolution: {integrity: sha512-nTQfwHtnL+MSqPaUJhV22GWP3jThj0GnS4Nw1uJyBus6EQ40hQFnbBGUvWxC5P3m+1neSqH1p8asMXg/ypmsQw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
atomic-sleep@1.0.0: atomic-sleep@1.0.0:
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@@ -2202,6 +2205,9 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.1.0 postcss: ^8.1.0
axios@1.16.1:
resolution: {integrity: sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -2388,6 +2394,10 @@ packages:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'} engines: {node: '>=18'}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
command-line-args@5.2.1: command-line-args@5.2.1:
resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==} resolution: {integrity: sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg==}
engines: {node: '>=4.0.0'} engines: {node: '>=4.0.0'}
@@ -2510,6 +2520,10 @@ packages:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
delegates@1.0.0: delegates@1.0.0:
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
@@ -2707,6 +2721,10 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'} 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: esbuild@0.18.20:
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -2921,6 +2939,19 @@ packages:
fn.name@1.1.0: fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} 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: forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'} engines: {node: '>= 0.6'}
@@ -3057,6 +3088,10 @@ packages:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
has-unicode@2.0.1: has-unicode@2.0.1:
resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
@@ -3118,9 +3153,6 @@ packages:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'} engines: {node: '>=8'}
indonesian-badwords@1.0.1:
resolution: {integrity: sha512-A8V6hklYqql15yelhnx5jkoDLru9hPr/q7TE6S4fICZvb7lbluWUXqGjcWpgmDCsbXpecs38/fbuhvM0em/7CQ==}
inflight@1.0.6: inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} 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. 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==} resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'} engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-db@1.54.0: mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'} 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: mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3974,6 +4014,10 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} 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: pump@3.0.4:
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
@@ -6303,10 +6347,10 @@ snapshots:
dependencies: dependencies:
'@types/node': 25.8.0 '@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: dependencies:
'@rolldown/pluginutils': 1.0.1 '@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': '@vitest/expect@4.1.7':
dependencies: dependencies:
@@ -6317,13 +6361,13 @@ snapshots:
chai: 6.2.2 chai: 6.2.2
tinyrainbow: 3.1.0 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: dependencies:
'@vitest/spy': 4.1.7 '@vitest/spy': 4.1.7
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: 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': '@vitest/pretty-format@4.1.7':
dependencies: dependencies:
@@ -6446,6 +6490,8 @@ snapshots:
asyncc@2.0.9: {} asyncc@2.0.9: {}
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {} atomic-sleep@1.0.0: {}
autoprefixer@10.5.0(postcss@8.5.14): autoprefixer@10.5.0(postcss@8.5.14):
@@ -6457,6 +6503,16 @@ snapshots:
postcss: 8.5.14 postcss: 8.5.14
postcss-value-parser: 4.2.0 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: {} balanced-match@1.0.2: {}
base64-js@1.5.1: {} base64-js@1.5.1: {}
@@ -6649,6 +6705,10 @@ snapshots:
color-convert: 3.1.3 color-convert: 3.1.3
color-string: 2.1.4 color-string: 2.1.4
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
command-line-args@5.2.1: command-line-args@5.2.1:
dependencies: dependencies:
array-back: 3.1.0 array-back: 3.1.0
@@ -6764,6 +6824,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
gopd: 1.2.0 gopd: 1.2.0
delayed-stream@1.0.0: {}
delegates@1.0.0: {} delegates@1.0.0: {}
depd@2.0.0: {} depd@2.0.0: {}
@@ -6871,6 +6933,13 @@ snapshots:
dependencies: dependencies:
es-errors: 1.3.0 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: esbuild@0.18.20:
optionalDependencies: optionalDependencies:
'@esbuild/android-arm': 0.18.20 '@esbuild/android-arm': 0.18.20
@@ -7226,6 +7295,16 @@ snapshots:
fn.name@1.1.0: {} 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: {} forwarded@0.2.0: {}
fraction.js@5.3.4: {} fraction.js@5.3.4: {}
@@ -7368,6 +7447,10 @@ snapshots:
has-symbols@1.1.0: {} has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
has-unicode@2.0.1: {} has-unicode@2.0.1: {}
hasown@2.0.3: hasown@2.0.3:
@@ -7422,8 +7505,6 @@ snapshots:
indent-string@4.0.0: {} indent-string@4.0.0: {}
indonesian-badwords@1.0.1: {}
inflight@1.0.6: inflight@1.0.6:
dependencies: dependencies:
once: 1.4.0 once: 1.4.0
@@ -7788,8 +7869,14 @@ snapshots:
braces: 3.0.3 braces: 3.0.3
picomatch: 2.3.2 picomatch: 2.3.2
mime-db@1.52.0: {}
mime-db@1.54.0: {} mime-db@1.54.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime-types@3.0.2: mime-types@3.0.2:
dependencies: dependencies:
mime-db: 1.54.0 mime-db: 1.54.0
@@ -8212,6 +8299,8 @@ snapshots:
forwarded: 0.2.0 forwarded: 0.2.0
ipaddr.js: 1.9.1 ipaddr.js: 1.9.1
proxy-from-env@2.1.0: {}
pump@3.0.4: pump@3.0.4:
dependencies: dependencies:
end-of-stream: 1.4.5 end-of-stream: 1.4.5
@@ -8926,7 +9015,7 @@ snapshots:
vary@1.1.2: {} 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: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.4 picomatch: 4.0.4
@@ -8939,11 +9028,12 @@ snapshots:
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.7.0 jiti: 2.7.0
tsx: 4.22.2 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: dependencies:
'@vitest/expect': 4.1.7 '@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/pretty-format': 4.1.7
'@vitest/runner': 4.1.7 '@vitest/runner': 4.1.7
'@vitest/snapshot': 4.1.7 '@vitest/snapshot': 4.1.7
@@ -8960,7 +9050,7 @@ snapshots:
tinyexec: 1.1.2 tinyexec: 1.1.2
tinyglobby: 0.2.16 tinyglobby: 0.2.16
tinyrainbow: 3.1.0 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 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.1 '@opentelemetry/api': 1.9.1
+11
View File
@@ -114,6 +114,17 @@ const configSchema = z
.int() .int()
.positive() .positive()
.default(10), .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 AUTO_DELETE_FLAGGED_ENABLED: z
.string() .string()
.optional() .optional()
+1 -1
View File
@@ -77,7 +77,7 @@ export default async function processAnalysisRequest({
limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT, limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT,
}); });
const contextLines = buildConversationContext({ const contextLines = await buildConversationContext({
contextBefore, contextBefore,
targets: messages, targets: messages,
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS, maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
+6 -5
View File
@@ -9,8 +9,6 @@ import { retryWithBackoff } from "../retry.js";
import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js"; import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js";
import { import {
buildConversationContext, buildConversationContext,
estimateTokens,
formatMessageForPrompt,
} from "./conversationContext.js"; } from "./conversationContext.js";
import { runModerationAnalysis } from "./llmModerationClient.js"; import { runModerationAnalysis } from "./llmModerationClient.js";
import { import {
@@ -157,6 +155,8 @@ export function getConversationKey(message: MessageRecord): string {
/** /**
* Picks a batch of messages within a token budget. * Picks a batch of messages within a token budget.
* `tokensPerMessage` accounts for JSON structure overhead around each entry. * `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( export function pickBatchWithinBudget(
messages: MessageRecord[], messages: MessageRecord[],
@@ -167,8 +167,9 @@ export function pickBatchWithinBudget(
let usedTokens = 0; let usedTokens = 0;
for (const msg of messages) { for (const msg of messages) {
const formatted = formatMessageForPrompt(msg, "target"); const content = msg.edited_content ?? msg.content;
const msgTokens = estimateTokens(formatted) + tokensPerMessage; // Rough token estimate: ~3 chars per token + metadata overhead
const msgTokens = Math.ceil(content.length / 3) + tokensPerMessage;
if (usedTokens + msgTokens <= maxTokens) { if (usedTokens + msgTokens <= maxTokens) {
batch.push(msg); batch.push(msg);
@@ -238,7 +239,7 @@ async function processIndividualFallback(
limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT, limit: config.AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT,
}); });
const contextLines = buildConversationContext({ const contextLines = await buildConversationContext({
contextBefore, contextBefore,
targets: [message], targets: [message],
maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS, maxTokens: config.AI_ANALYSIS_MAX_CONTEXT_TOKENS,
+11 -10
View File
@@ -25,13 +25,13 @@ export function estimateTokens(text: string): number {
/** /**
* Formats a single message for context or target display * Formats a single message for context or target display
*/ */
export function formatMessageForPrompt( export async function formatMessageForPrompt(
msg: MessageRecord, msg: MessageRecord,
label: "context" | "target", label: "context" | "target",
): string { ): Promise<string> {
const content = msg.edited_content ?? msg.content; const content = msg.edited_content ?? msg.content;
const timestamp = formatTimestamp(msg.created_at); const timestamp = formatTimestamp(msg.created_at);
const textEvidence = formatModerationTextEvidenceForPrompt(content); const textEvidence = await formatModerationTextEvidenceForPrompt(content);
const textSuffix = textEvidence ? ` ${textEvidence}` : ""; const textSuffix = textEvidence ? ` ${textEvidence}` : "";
const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata); const mediaEvidence = formatMediaEvidenceForPrompt(msg.metadata);
const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : ""; const mediaSuffix = mediaEvidence ? ` ${mediaEvidence}` : "";
@@ -42,22 +42,23 @@ export function formatMessageForPrompt(
* Builds conversation historical context without including targets. * Builds conversation historical context without including targets.
* Calculates how much token budget targets use, and fills the rest with context. * Calculates how much token budget targets use, and fills the rest with context.
*/ */
export function buildConversationContext( export async function buildConversationContext(
input: ConversationContextInput, input: ConversationContextInput,
): string[] { ): Promise<string[]> {
const { contextBefore, targets, maxTokens } = input; const { contextBefore, targets, maxTokens } = input;
// Calculate tokens used by targets // Calculate tokens used by targets (parallel)
let usedTokens = targets.reduce((sum, msg) => { const targetLines = await Promise.all(
return sum + estimateTokens(formatMessageForPrompt(msg, "target")); targets.map((msg) => formatMessageForPrompt(msg, "target")),
}, 0); );
let usedTokens = targetLines.reduce((sum, line) => sum + estimateTokens(line), 0);
const selectedContextLines: string[] = []; const selectedContextLines: string[] = [];
// Go backwards through context, taking most recent first // Go backwards through context, taking most recent first
for (let i = contextBefore.length - 1; i >= 0; i--) { for (let i = contextBefore.length - 1; i >= 0; i--) {
const msg = contextBefore[i]; const msg = contextBefore[i];
const line = formatMessageForPrompt(msg, "context"); const line = await formatMessageForPrompt(msg, "context");
const lineTokens = estimateTokens(line); const lineTokens = estimateTokens(line);
if (usedTokens + lineTokens <= maxTokens) { if (usedTokens + lineTokens <= maxTokens) {
+177 -102
View File
@@ -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 { INDONESIAN_SLANG_LEXICON } from "./resources/indonesianSlangLexicon.js";
import { createChildLogger } from "../logger.js";
const log = createChildLogger("indonesianTextNormalizer");
const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/g; const CUSTOM_EMOJI_PATTERN = /<a?:([a-zA-Z0-9_]+):(\d+)>/g;
const WORD_PATTERN = /[\p{L}\p{N}_]+/gu; const WORD_PATTERN = /[\p{L}\p{N}_]+/gu;
interface BadwordAnalyzeResult { /** NVIDIA content safety categories that map to offensive/badword content. */
badwords?: string[]; const NVIDIA_BAD_CATEGORIES = new Set([
count?: number; "hate",
} "harassment",
"sexual",
"violence",
"self-harm",
"illicit",
"profanity",
"vulgar",
"insult",
]);
interface BadwordsModule { /**
analyze?: (text: string) => BadwordAnalyzeResult; * Map NVIDIA Nemotron category labels to Indonesian badword-style labels.
flag?: (text: string) => boolean; */
} const CATEGORY_TO_BADWORD_LABEL: Record<string, string> = {
hate: "hate_speech",
const badwords = badwordsModule as BadwordsModule; 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 { export interface ModerationTextEvidence {
raw: string; raw: string;
@@ -24,6 +44,10 @@ export interface ModerationTextEvidence {
hasBadwords: boolean; hasBadwords: boolean;
} }
// ---------------------------------------------------------------------------
// Sync helpers (unchanged)
// ---------------------------------------------------------------------------
export function normalizeDiscordCustomEmoji(text: string): { export function normalizeDiscordCustomEmoji(text: string): {
text: string; text: string;
emojiNames: string[]; emojiNames: string[];
@@ -53,104 +77,155 @@ export function normalizeIndonesianSlang(text: string): {
return { text: normalized, notes: Array.from(new Set(notes)) }; return { text: normalized, notes: Array.from(new Set(notes)) };
} }
export function detectIndonesianBadwords(text: string): string[] { // ---------------------------------------------------------------------------
try { // Local fallback badword list (used when NVIDIA API is unavailable)
const result = badwords.analyze?.(text); // ---------------------------------------------------------------------------
if (Array.isArray(result?.badwords)) {
let hits = Array.from(new Set(result.badwords.map((word) => word.toLowerCase())));
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",
];
// ----------------------------------------------------------------------- const FALSE_POSITIVE_WHITELISTS: Record<string, string[]> = {
// False-positive filters — exclude badword hits that appear only as asu: [
// substrings of longer innocent words. Each filter checks whether the "asus", "masuk", "termasuk", "dimasukkan", "memasukkan",
// hit exists as a standalone word OR as part of a word that is NOT in "kasur", "asumsi", "asuransi", "asupan", "pasukan", "pasundan",
// the whitelist. ],
// ----------------------------------------------------------------------- goblok: ["goblok"],
const words = lowerText.match(/[\p{L}\p{N}_]+/gu) || []; kontol: ["kontol"],
memek: ["memek"],
tolol: ["tolol"],
};
/** Returns true if the given hit appears in the text as a standalone word function detectLocalBadwords(text: string): string[] {
* or inside a word that is NOT in the whitelist. */ const lowerText = text.toLowerCase();
const isRealHit = (hit: string, whitelist: string[]): boolean => { const words = lowerText.match(/[\p{L}\p{N}_]+/gu) || [];
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;
};
hits = hits.filter((hit) => { const isRealHit = (hit: string, whitelist: string[]): boolean => {
switch (hit) { for (const w of words) {
case "asu": if (w.includes(hit)) {
return isRealHit(hit, [ if (w === hit) return true;
"asus", "masuk", "termasuk", "dimasukkan", "memasukkan", if (!whitelist.includes(w)) return true;
"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);
}
} }
return Array.from(new Set(hits));
} }
} catch { return false;
// Keep moderation pipeline resilient if dependency changes shape. };
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<string[]> {
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<string[]> {
// 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<ModerationTextEvidence> {
const emojiNormalized = normalizeDiscordCustomEmoji(text); const emojiNormalized = normalizeDiscordCustomEmoji(text);
const slangNormalized = normalizeIndonesianSlang(emojiNormalized.text); const slangNormalized = normalizeIndonesianSlang(emojiNormalized.text);
const badwordHits = detectIndonesianBadwords(slangNormalized.text); const badwordHits = await detectIndonesianBadwords(slangNormalized.text);
const notes = [...slangNormalized.notes]; const notes = [...slangNormalized.notes];
for (const emojiName of emojiNormalized.emojiNames) { for (const emojiName of emojiNormalized.emojiNames) {
@@ -160,9 +235,9 @@ export function buildModerationTextEvidence(text: string): ModerationTextEvidenc
} }
if (badwordHits.length > 0) { if (badwordHits.length > 0) {
notes.push(`local lexical check: Indonesian badword detected: ${badwordHits.join(", ")}`); notes.push(`Indonesian badword detected: ${badwordHits.join(", ")}`);
} else { } else {
notes.push("local lexical check: no Indonesian badword detected"); notes.push("no Indonesian badword detected");
} }
return { return {
@@ -174,8 +249,8 @@ export function buildModerationTextEvidence(text: string): ModerationTextEvidenc
}; };
} }
export function formatModerationTextEvidenceForPrompt(text: string): string { export async function formatModerationTextEvidenceForPrompt(text: string): Promise<string> {
const evidence = buildModerationTextEvidence(text); const evidence = await buildModerationTextEvidence(text);
if (evidence.normalized === evidence.raw && evidence.notes.length === 0) { if (evidence.normalized === evidence.raw && evidence.notes.length === 0) {
return ""; return "";
} }
+13 -3
View File
@@ -805,7 +805,17 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
let lastParseError: string | null = null; let lastParseError: string | null = null;
let lastInvalidContent: string | null = null; let lastInvalidContent: string | null = null;
const buildMessageContent = (): string => { // Pre-compute text evidence for all targets in parallel
const textEvidenceMap = new Map<string, string>();
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<string> => {
const correction = lastParseError const correction = lastParseError
? { ? {
error: 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 webTexts = messageWebTextMap.get(msg.id) ?? [];
const mediaAnalyses = messageMediaAnalysisMap.get(msg.id) ?? []; const mediaAnalyses = messageMediaAnalysisMap.get(msg.id) ?? [];
const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : ""; 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 textContext = textEvidence ? `\n${textEvidence}` : "";
const mediaAnalysisContext = const mediaAnalysisContext =
mediaAnalyses.length > 0 ? `\n${mediaAnalyses.join("\n")}` : ""; mediaAnalyses.length > 0 ? `\n${mediaAnalyses.join("\n")}` : "";
@@ -856,7 +866,7 @@ CRITICAL: "message_id" HARUS berupa STRING (dibungkus tanda kutip ganda). Jangan
messages: [ messages: [
{ {
role: "user", role: "user",
content: buildMessageContent(), content: await buildMessageContent(),
}, },
], ],
temperature: 0.2, temperature: 0.2,
-25
View File
@@ -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;
}
@@ -42,20 +42,20 @@ describe("normalizeIndonesianSlang", () => {
}); });
describe("detectIndonesianBadwords", () => { describe("detectIndonesianBadwords", () => {
it("detects known badword", () => { it("detects known badword via local fallback", async () => {
const badwords = detectIndonesianBadwords("kontol banget"); const badwords = await detectIndonesianBadwords("kontol banget");
expect(badwords).toContain("kontol"); expect(badwords).toContain("kontol");
}); });
it("returns empty array for safe slang", () => { it("returns empty array for safe slang", async () => {
const badwords = detectIndonesianBadwords("woy hadeh gua"); const badwords = await detectIndonesianBadwords("woy hadeh gua");
expect(badwords).toHaveLength(0); expect(badwords).toHaveLength(0);
}); });
}); });
describe("buildModerationTextEvidence", () => { describe("buildModerationTextEvidence", () => {
it("produces correct evidence for woy with emoji", () => { it("produces correct evidence for woy with emoji", async () => {
const evidence = buildModerationTextEvidence( const evidence = await buildModerationTextEvidence(
"Bersiaplah woy <:hadeh:1217434294281048185>", "Bersiaplah woy <:hadeh:1217434294281048185>",
); );
expect(evidence.normalized).toContain("[emoji:hadeh]"); expect(evidence.normalized).toContain("[emoji:hadeh]");
@@ -66,16 +66,16 @@ describe("buildModerationTextEvidence", () => {
expect(evidence.notes.some((n) => n.includes("casual"))).toBe(true); expect(evidence.notes.some((n) => n.includes("casual"))).toBe(true);
}); });
it("detects badword when present", () => { it("detects badword when present", async () => {
const evidence = buildModerationTextEvidence("anjing loe kontol"); const evidence = await buildModerationTextEvidence("anjing loe kontol");
expect(evidence.hasBadwords).toBe(true); expect(evidence.hasBadwords).toBe(true);
expect(evidence.notes.some((n) => n.includes("badword detected"))).toBe(true); expect(evidence.notes.some((n) => n.includes("badword detected"))).toBe(true);
}); });
}); });
describe("formatModerationTextEvidenceForPrompt", () => { describe("formatModerationTextEvidenceForPrompt", () => {
it("returns prompt evidence for slang + emoji", () => { it("returns prompt evidence for slang + emoji", async () => {
const formatted = formatModerationTextEvidenceForPrompt( const formatted = await formatModerationTextEvidenceForPrompt(
"Bersiaplah woy <:hadeh:1217434294281048185>", "Bersiaplah woy <:hadeh:1217434294281048185>",
); );
expect(formatted).toContain("[normalized_text:"); expect(formatted).toContain("[normalized_text:");
@@ -84,8 +84,8 @@ describe("formatModerationTextEvidenceForPrompt", () => {
expect(formatted).toContain("no Indonesian badword detected"); expect(formatted).toContain("no Indonesian badword detected");
}); });
it("includes normalized text even for clean input", () => { it("includes normalized text even for clean input", async () => {
const formatted = formatModerationTextEvidenceForPrompt("Halo semua"); const formatted = await formatModerationTextEvidenceForPrompt("Halo semua");
expect(formatted).toContain("[normalized_text:"); expect(formatted).toContain("[normalized_text:");
expect(formatted).toContain("no Indonesian badword detected"); expect(formatted).toContain("no Indonesian badword detected");
}); });