feat: SearXNG search enrichment for moderation

- extractSearchQueries(): extract specific terms from trigger patterns
  instead of searching entire message (e.g. 'bokuno pico' from 'nonton bokuno pico anime')
- Add hentai studio/publisher triggers (Pink Pineapple, Maryjane, etc.)
- Fix media path SearXNG: use extractSearchQueries instead of quoted-only
- Prompt: add <web_searches> usage instructions so LLM actually uses search data
- Priority: searxng evidence > web_content > internal model knowledge
This commit is contained in:
MythEclipse
2026-06-22 10:58:52 +07:00
parent 641fb47d10
commit 18ee8a35d2
3 changed files with 218 additions and 3 deletions
@@ -20,6 +20,12 @@ import { getChannelCulture } from "./channelCultureStore.js";
import { llmChat, llmVision } from "./llmClient.js"; import { llmChat, llmVision } from "./llmClient.js";
import { buildSystemPrompt as buildSystemPromptModular, sanitizeAiContent } from "./moderationPrompt.js"; import { buildSystemPrompt as buildSystemPromptModular, sanitizeAiContent } from "./moderationPrompt.js";
import { logModerationAnalysis, logModerationError } from "./responseLogger.js"; import { logModerationAnalysis, logModerationError } from "./responseLogger.js";
import {
searchSearxng,
shouldSearchContent,
extractSearchQueries,
formatSearchResults,
} from "./searxngSearch.js";
import { import {
getStickerFromCache, getStickerFromCache,
initStickerCache, initStickerCache,
@@ -736,6 +742,41 @@ async function runTextOnlyBatch(
} }
} }
// ── SearXNG enrichment for suspicious/ambiguous content ──────────
// Uses SearXNG to look up references mentioned in messages (e.g. anime
// titles, drug names). Results are injected as <web_search> XML tags
// so the LLM can make informed decisions instead of guessing.
const searxngResults = new Map<string, string>(); // query → formatted XML
{
const queries = new Set<string>();
for (const msg of targets) {
const content = msg.edited_content ?? msg.content;
if (shouldSearchContent(content)) {
// Extract specific search terms from triggers (e.g. "boku no pico")
// instead of sending the entire message as a query
for (const q of extractSearchQueries(content)) {
queries.add(q);
}
}
}
if (queries.size > 0) {
const queryArr = Array.from(queries).slice(0, 3); // cap to 3 searches per batch
log.debug(
{ searchQueries: queryArr },
"Running SearXNG enrichment for batch",
);
const results = await Promise.allSettled(
queryArr.map((q) => searchSearxng(q)),
);
for (let i = 0; i < queryArr.length; i++) {
const r = results[i];
if (r.status === "fulfilled" && r.value.length > 0) {
searxngResults.set(queryArr[i], formatSearchResults(r.value));
}
}
}
}
// ── Group identical short messages (< 20 chars) to reduce redundant analysis ── // ── Group identical short messages (< 20 chars) to reduce redundant analysis ──
// Messages with identical normalized content share a single representative. // Messages with identical normalized content share a single representative.
// Results are fanned out to all group members after the LLM call. // Results are fanned out to all group members after the LLM call.
@@ -886,7 +927,11 @@ async function runTextOnlyBatch(
).then((blocks) => blocks.join("\n")); ).then((blocks) => blocks.join("\n"));
// XML delimiter wraps the entire messages block (R1) // XML delimiter wraps the entire messages block (R1)
return `${systemText}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`; // Append SearXNG results if any were fetched for this batch
const searxngBlock = searxngResults.size > 0
? `\n\n<web_searches>\n${Array.from(searxngResults.entries()).map(([q, xml]) => ` <search_query query="${escapeXml(q)}">\n${xml} </search_query>`).join("\n")}\n</web_searches>`
: "";
return `${systemText}${searxngBlock}\n\n<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`;
}; };
const abortController = new AbortController(); const abortController = new AbortController();
@@ -1067,7 +1112,28 @@ async function prepareMediaMessage(
), ),
); );
// ── 5. Build single-message XML block (R1) ── // ── 5. SearXNG search for suspicious content ──
let searxngXml = "";
if (shouldSearchContent(content)) {
const queries = extractSearchQueries(content);
if (queries.length > 0) {
const results = await Promise.allSettled(
queries.map((q) => searchSearxng(q)),
);
const parts: string[] = [];
for (let i = 0; i < results.length; i++) {
const r = results[i];
if (r.status === "fulfilled" && r.value.length > 0) {
parts.push(formatSearchResults(r.value));
}
}
if (parts.length > 0) {
searxngXml = `\n<web_searches>\n${parts.join("\n")}\n</web_searches>`;
}
}
}
// ── 6. Build single-message XML block (R1) ──
const webTexts = webTextMap.get(targetId) ?? []; const webTexts = webTextMap.get(targetId) ?? [];
const mediaAnalyses = mediaAnalysisMap.get(targetId) ?? []; const mediaAnalyses = mediaAnalysisMap.get(targetId) ?? [];
const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : ""; const webContext = webTexts.length > 0 ? `\n${webTexts.join("\n")}` : "";
@@ -1102,7 +1168,7 @@ async function prepareMediaMessage(
const refXml = await buildReferenceXml(target); const refXml = await buildReferenceXml(target);
const refLine = refXml ? `\n ${refXml}` : ""; const refLine = refXml ? `\n ${refXml}` : "";
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(target.username)}">\n ${userCtx}${userProfileCtx}${refLine}\n <content>${escapeXml(content)}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}\n</message>`; const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(target.username)}">\n ${userCtx}${userProfileCtx}${refLine}\n <content>${escapeXml(content)}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}\n</message>`;
return { targetId, messageBlock }; return { targetId, messageBlock };
} }
@@ -158,6 +158,14 @@ Prioritas rendah (PELANGGARAN RINGAN):
- Link ke dashboard/webapp Discord, GitHub, atau tools programming adalah AMAN. - Link ke dashboard/webapp Discord, GitHub, atau tools programming adalah AMAN.
- Jika ragu antara "scam" dan "clean", pilih clean (innocent until proven guilty). - Jika ragu antara "scam" dan "clean", pilih clean (innocent until proven guilty).
## Web Search Context GUNAKAN UNTUK VERIFIKASI
<web_searches> berisi hasil pencarian otomatis dari SearXNG untuk referensi mencurigakan yang disebut di pesan (misalnya judul anime yang mungkin hentai, nama narkoba, atau istilah scam). Ini DICARI OTOMATIS oleh sistem berdasarkan trigger tertentu.
- **GUNAKAN hasil <web_searches> sebagai bukti** untuk memutuskan apakah konten melanggar kebijakan. Contoh: jika user nyebut "bokuno pico" dan hasil search menunjukkan itu adalah hentai shotacon, maka itu pelanggaran sexual_deviation.
- **JANGAN abaikan <web_searches>** data ini sengaja dicari untuk membantu keputusan yang lebih akurat.
- Jika <web_searches> kosong (tidak ada di prompt), berarti tidak ada trigger pencarian jangan buat asumsi.
- Prioritas: <web_searches> > <web_content> > pengetahuan internal model. Gunakan data konkret dari search/web content sebagai bukti utama.
## Pohon Keputusan (Decision Tree) ## Pohon Keputusan (Decision Tree)
1. Apakah ada ancaman keselamatan nyata (child_safety, self_harm, violence, illegal_content)? flagged, critical 1. Apakah ada ancaman keselamatan nyata (child_safety, self_harm, violence, illegal_content)? flagged, critical
2. Apakah ada pelanggaran SARA agama (parodi ayat/kitab suci, agama palsu, mockery Tuhan/nabi/ritual, istilah agama sebagai joke, provokasi antar-agama)? flagged, high/critical. **JANGAN PERNAH menganggap parodi agama sebagai "clean" atau hanya "warn".** 2. Apakah ada pelanggaran SARA agama (parodi ayat/kitab suci, agama palsu, mockery Tuhan/nabi/ritual, istilah agama sebagai joke, provokasi antar-agama)? flagged, high/critical. **JANGAN PERNAH menganggap parodi agama sebagai "clean" atau hanya "warn".**
@@ -0,0 +1,141 @@
import { createChildLogger } from "@bete/shared/logger";
const log = createChildLogger("searxng-search");
const SEARXNG_BASE_URL = "https://searxng.imrnes.team";
const MAX_RESULTS = 3;
const TIMEOUT_MS = 8000;
export interface SearxngResult {
title: string;
url: string;
snippet: string;
}
/**
* Search SearXNG for a query and return structured results.
* Safe-guarded: only text results, no images fetched.
*/
export async function searchSearxng(
query: string,
category: "general" | "news" | "science" = "general",
): Promise<SearxngResult[]> {
try {
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
headers: {
Accept: "application/json",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
},
});
clearTimeout(timeoutId);
if (!response.ok) {
log.warn({ status: response.status, query }, "SearXNG search failed");
return [];
}
const data = (await response.json()) as {
results?: Array<{ title?: string; url?: string; content?: string }>;
};
const results = data.results ?? [];
return results.slice(0, MAX_RESULTS).map((r) => ({
title: r.title ?? "",
url: r.url ?? "",
snippet: (r.content ?? "").slice(0, 500),
}));
} catch (err) {
log.warn(
{ error: err instanceof Error ? err.message : String(err), query },
"SearXNG search error",
);
return [];
}
}
/**
* Trigger rules each entry is a regex pattern.
* The matched text is extracted as the search query.
* Use capturing groups to isolate the specific term to search.
*/
const SEARXNG_TRIGGERS: RegExp[] = [
// Anime hentai references (capture the title name)
/\b(boku\s*no\s*pico|euphoria|bible\s*black|la\s*blue\s*girl|kuroinu|oni\s*chichi|tsuma\s*netori|starless|discipline|immoral\s*sisters|night\s*shift\s*nurses|resonant)\b/i,
// Narutoverse hentai specific
/\b(naruto\s*hentai|boruto\s*hentai|sakura\s*hentai|hinata\s*hentai)\b/i,
// General hentai/porn triggers
/\b(hentai|nonton\s*.*\b(anime|kartun|film).*\b(18\+|dewasa|bokep)|l[o0]l[i1]|sh[o0]t[o0])\b/i,
// Suspicious anime title mentions — extract the title after "nonton"
/\bnonton\s+(\w+(?:\s+\w+){0,3})\s+(anime|kartun)\b/i,
// Drug references
/\b(jenis?\s+?narkoba|jenis?\s+?narkotika|ngefly|fly\s*.*\bdrug|rc\b.*\blegal|research\s*chemical)\b/i,
// Scam/phishing indicators that could use verification
/\b(phishing|penipuan|scam|skimming)\b/i,
// Known hentai studio/series references
/\b(pink\s*pineapple|maryjane|softhouse\s*chara|milky\s*studio|anime\s*lilith|mooon\s*phase|queen\s*bees)\b/i,
];
/**
* Determine if content should trigger a SearXNG lookup.
* Only search for suspicious/ambiguous content to avoid unnecessary cost.
*/
export function shouldSearchContent(content: string): boolean {
return SEARXNG_TRIGGERS.some((re) => re.test(content));
}
/**
* Extract specific search terms from content based on trigger matches.
* Returns up to 3 clean queries (e.g. ["boku no pico", "sexual_deviation"])
* instead of the entire message text.
*/
export function extractSearchQueries(content: string): string[] {
const queries = new Set<string>();
// Also check for quoted phrases (they're explicit intent)
const quotedPhrases = content.match(/"([^"]+)"|'([^']+)'/g);
if (quotedPhrases) {
for (const phrase of quotedPhrases) {
const clean = phrase.replace(/["']/g, "").trim().toLowerCase();
if (clean.length >= 3) queries.add(clean);
}
}
// Extract matched groups from trigger patterns
for (const re of SEARXNG_TRIGGERS) {
const match = content.match(re);
if (match) {
// Use the first capture group (the specific term) if available
const term = match[1] ?? match[2] ?? match[0];
const clean = term.replace(/\s+/g, " ").trim().toLowerCase();
if (clean.length >= 3) queries.add(clean);
}
}
return Array.from(queries).slice(0, 3);
}
/**
* Format SearXNG results as XML for LLM context.
*/
export function formatSearchResults(results: SearxngResult[]): string {
if (results.length === 0) return "";
const lines = results.map(
(r) =>
` <result title="${escapeXml(r.title)}">${escapeXml(r.snippet)}</result>`,
);
return `<web_search>\n${lines.join("\n")}\n</web_search>`;
}
function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}