refactor(ai): replace SearXNG with Wikipedia adapter for analysis enrichment

- Add wikipediaClient.ts: native fetch to Wikipedia REST/Action APIs
  (search + summary), no extra npm dependency.
- Extract shared Redis cache into cacheStore.ts (decoupled from search).
- Term glossary now uses wikipediaSummary for direct article lookup.
- Remove searxngSearch.ts entirely; drop SEARXNG_BASE_URL config,
  add WIKIPEDIA_LANG / WIKIPEDIA_TIMEOUT_MS.
- Rename backend searxngCalls metric to webSearchCalls.
This commit is contained in:
asepharyana
2026-08-17 20:07:19 +07:00
parent 2825250804
commit 479f4719ba
10 changed files with 434 additions and 589 deletions
@@ -0,0 +1,78 @@
/**
* cacheStore.ts
*
* Shared Redis cache used by the AI-moderation modules (term glossary, etc.).
*
* Extracted when SearXNG was removed (replaced by the Wikipedia adapter in
* wikipediaClient.ts). The cache was never SearXNG-specific — it is a generic
* namespaced key/value store with graceful degradation when Redis is
* unavailable. Other modules import `makeCacheKey`, `cacheGet`, `cacheSet`,
* and `initCacheStore` instead of reaching into a search module.
*/
import Redis from "ioredis";
import { createChildLogger } from "@/shared/logger/index";
const log = createChildLogger("cache-store");
const CACHE_PREFIX = "gmw:";
const CACHE_TTL = 86400; // 24 hours (used as a sane default)
let redis: Redis | null = null;
/**
* Initialize the shared Redis connection for the moderation cache.
* Safe to call multiple times — only creates one connection.
* Degrades gracefully to `null` (no-cache) when Redis is unavailable.
*/
export function initCacheStore(redisUrl: string): void {
if (redis) return;
redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 2000);
return delay;
},
lazyConnect: true,
enableReadyCheck: false,
});
redis.on("error", (err) => {
log.warn({ err: err.message }, "Cache Redis error");
});
redis.connect().catch(() => {
log.warn("Cache Redis unavailable — falling back to no-cache");
redis = null;
});
log.info("Cache Redis initialized");
}
/** Exposes the shared Redis connection; null when Redis is unavailable. */
export function getCacheRedis(): Redis | null {
return redis;
}
/** Builds a namespaced cache key (shared across modules). */
export function makeCacheKey(namespace: string, key: string): string {
return `${CACHE_PREFIX}${namespace}:${key.toLowerCase().trim()}`;
}
/** Reads a value from the cache; null on miss/unavailable. */
export async function cacheGet(key: string): Promise<string | null> {
if (!redis) return null;
try {
return await redis.get(key);
} catch {
return null;
}
}
/** Writes a value to the cache, fire-and-forget. */
export function cacheSet(key: string, value: string, ttlSeconds: number): void {
if (!redis) return;
redis.setex(key, ttlSeconds, value).catch(() => {
// Cache write failed silently
});
}
/** Default TTL (exposed for callers that want the standard window). */
export const DEFAULT_CACHE_TTL = CACHE_TTL;
@@ -12,12 +12,12 @@ import type {
AttachmentRecord,
MessageRecord,
} from "../message-capture/types.js";
import { initCacheStore } from "./cacheStore.js";
import { embedTexts, isEmbeddingEnabled } from "./embeddingClient.js";
import { hasMediaContent } from "./mediaAnalysisClient.js";
import { runMediaBatch } from "./mediaBatchProcessor.js";
import { isQdrantConfigured, searchQdrantBatch } from "./qdrantClient.js";
import { logCacheEvent } from "./responseLogger.js";
import { initSearxngCache } from "./searxngSearch.js";
import { runTextOnlyBatch } from "./textBatchProcessor.js";
import {
findSimilarTextModeration,
@@ -70,7 +70,7 @@ export async function runModerationAnalysis(
): Promise<ModerationOutput> {
const { targets, contextBlock, attachments } = input;
initSearxngCache(config.REDIS_URL);
initCacheStore(config.REDIS_URL);
if (!targets.length) throw new Error("No targets provided for analysis");
// ── Phase 1: exact-hash cache (per conversation context) ────────────────
@@ -1,262 +0,0 @@
import Redis from "ioredis";
import { createChildLogger } from "@/shared/logger/index";
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
import { config } from "../../shared/config/config.js";
const log = createChildLogger("searxng-search");
const SEARXNG_BASE_URL = config.SEARXNG_BASE_URL;
const MAX_RESULTS = 3;
const TIMEOUT_MS = 8000;
const CACHE_TTL = 86400; // 24 hours
const CACHE_PREFIX = "searxng:";
let redis: Redis | null = null;
/**
* Exposes the shared SearXNG Redis connection so other modules (e.g. the
* term glossary) reuse the same connection and cache prefix instead of
* opening their own. Returns null when Redis is unavailable.
*/
export function getSearxngRedis(): Redis | null {
return redis;
}
/** Builds a namespaced SearXNG cache key (shared across modules). */
export function makeSearxngCacheKey(namespace: string, key: string): string {
return `${CACHE_PREFIX}${namespace}:${key.toLowerCase().trim()}`;
}
/** Reads a value from the SearXNG Redis cache; null on miss/unavailable. */
export async function searxngCacheGet(key: string): Promise<string | null> {
if (!redis) return null;
try {
return await redis.get(key);
} catch {
return null;
}
}
/** Writes a value to the SearXNG Redis cache, fire-and-forget. */
export function searxngCacheSet(
key: string,
value: string,
ttlSeconds: number,
): void {
if (!redis) return;
redis.setex(key, ttlSeconds, value).catch(() => {
// Cache write failed silently
});
}
/**
* Initialize Redis connection for SearXNG cache.
* Safe to call multiple times — only creates one connection.
*/
export function initSearxngCache(redisUrl: string): void {
if (redis) return;
// Dedicated Redis connection needed because: this connection serves as an
// optional cache for SearXNG web search results with graceful degradation
// when Redis is unavailable (lazyConnect + null-assignment on failure).
// It uses custom retry strategy and must not block or break the main event
// pipeline if the cache is down.
redis = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 2000);
return delay;
},
lazyConnect: true,
enableReadyCheck: false,
});
redis.on("error", (err) => {
log.warn({ err: err.message }, "SearXNG Redis cache error");
});
redis.connect().catch(() => {
log.warn("SearXNG Redis cache unavailable — falling back to no-cache");
redis = null;
});
log.info("SearXNG Redis cache initialized");
}
export interface SearxngResult {
title: string;
url: string;
snippet: string;
}
/**
* Search SearXNG for a query and return structured results.
* Uses Redis cache when available — same query within 24h returns cached results.
*
* @param engines Optional comma-separated SearXNG engine list to constrain
* the search (e.g. "wikipedia"). When set, results are cached under a
* separate cache namespace so engine-specific results never collide.
*/
export async function searchSearxng(
query: string,
category: "general" | "news" | "science" = "general",
engines?: string,
timeoutMs: number = TIMEOUT_MS,
): Promise<SearxngResult[]> {
const engineNs = engines ? `eng:${engines}` : "auto";
const cacheKey = makeSearxngCacheKey(`${category}:${engineNs}`, query);
// Try cache first
if (redis) {
try {
const cached = await redis.get(cacheKey);
if (cached) {
log.debug({ query, category, engines }, "SearXNG cache HIT");
return JSON.parse(cached) as SearxngResult[];
}
} catch {
// Cache read failed, continue to API
}
}
// Cache miss — hit SearXNG API
try {
const engineParam = engines
? `&engines=${encodeURIComponent(engines)}`
: "";
const url = `${SEARXNG_BASE_URL}/search?q=${encodeURIComponent(query)}&format=json&language=id&categories=${category}${engineParam}`;
const { controller, clear } = createAbortControllerWithTimeout(timeoutMs);
try {
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",
},
});
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 ?? [];
const mapped = results.slice(0, MAX_RESULTS).map((r) => ({
title: r.title ?? "",
url: r.url ?? "",
snippet: (r.content ?? "").slice(0, 500),
}));
// Store in cache (fire and forget — don't block on write)
if (redis) {
redis.setex(cacheKey, CACHE_TTL, JSON.stringify(mapped)).catch(() => {
// Cache write failed silently
});
}
log.debug(
{ query, category, resultCount: mapped.length },
"SearXNG search OK",
);
return mapped;
} finally {
clear();
}
} catch (err) {
log.warn(
{ error: err instanceof Error ? err.message : String(err), query },
"SearXNG search error",
);
return [];
}
}
/**
* Extract meaningful search queries from message content.
* Uses multiple strategies to find terms worth searching.
* Returns up to 3 clean queries.
*/
export function extractSearchQueries(content: string): string[] {
const queries = new Set<string>();
// 1. Quoted phrases (explicit user intent)
const quotedPhrases = content.match(/"([^"]+)"|'([^']+)'/g);
if (quotedPhrases) {
for (const phrase of quotedPhrases) {
const clean = phrase.replace(/["']/g, "").trim();
if (clean.length >= 3) queries.add(clean);
}
}
// 2. "nonton X" pattern — extract the title
const nontonMatch = content.match(
/\b(nonton|tonton|rekomen|cari|search|google)\s+(.+?)(?:\s+(?:anime|kartun|film|movie|series|serial))?\s*[!?.]*$/i,
);
if (nontonMatch) {
const title = nontonMatch[2].trim();
if (title.length >= 2 && title.length <= 80) {
queries.add(title);
}
}
// 3. "X anime/film" pattern — title before category
const titleBeforeCategory = content.match(
/\b(\w[\w\s]{2,40})\s+(?:anime|kartun|film|movie|series|serial)\b/i,
);
if (titleBeforeCategory) {
const title = titleBeforeCategory[1].trim();
if (
title.length >= 3 &&
!/^(yang|yang|sama|dari|untuk|ini|itu|ada)$/i.test(title)
) {
queries.add(title);
}
}
// 4. Standalone proper nouns (2+ words, capitalized) that look like titles
const properNouns = content.match(
/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,4})\b/g,
);
if (properNouns) {
for (const noun of properNouns) {
// Skip common non-title proper nouns
const skip =
/^(Discord|YouTube|Google|Facebook|Instagram|Twitter|Github|ChatGPT|OpenAI|Claude|Telegram|WhatsApp|TikTok|Netflix|Spotify|Steam|Instagram)$/i;
if (!skip.test(noun) && noun.length >= 5) {
queries.add(noun);
}
}
}
// 5. Terms that suggest research intent
const researchTerms = content.match(
/\b(apa\s+(?:itu|sih)|what\s+is|siapa\s+itu|who\s+is|arti|meaning|definisi|definition)\s+(.{3,60})/i,
);
if (researchTerms) {
const term = researchTerms[2].trim().replace(/[?!.]+$/, "");
if (term.length >= 3) queries.add(term);
}
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;");
}
@@ -10,18 +10,18 @@
* wording (false negative on an unknown vulgar/slang term).
*
* Solution: extract candidate "unknown-looking" words from message content,
* look each one up on Wikipedia via SearXNG, and inject the definitions into
* the LLM prompt as a `<term_glossary>` block so verdicts are based on facts
* instead of guesses.
* look each one up on Wikipedia via the Wikipedia REST/Action APIs, and inject
* the definitions into the LLM prompt as a `<term_glossary>` block so verdicts
* are based on facts instead of guesses.
*
* Cost control & persistence:
* - successfully resolved definitions are PERSISTED PERMANENTLY in Postgres
* (`term_glossary_cache`) — definitions rarely change, so a resolved term
* is never searched again; only misses stay ephemeral (Redis/LRU, 1h);
* - in-memory LRU + Redis (shared with the SearXNG cache) sit in front of
* - in-memory LRU + Redis (shared cache store) sit in front of
* the DB as fast read caches, so repeat lookups are effectively free;
* - lookups per batch are bounded (AI_GLOSSARY_MAX_TERMS);
* - live SearXNG calls are rate-limit aware: concurrency 2 + stagger, retry
* - live Wikipedia calls are rate-limit aware: concurrency 2 + stagger, retry
* once on empty results, and misses cached for only 1h so a limiter/
* network blip is not treated as a permanent miss;
* - only results that read like actual definitions are accepted (Wikipedia
@@ -35,17 +35,13 @@ import pLimit from "p-limit";
import { createChildLogger } from "@/shared/logger/index";
import { delay } from "@/shared/utils/index";
import { config } from "../../shared/config/config.js";
import { cacheGet, cacheSet, makeCacheKey } from "./cacheStore.js";
import { escapeXml } from "./moderationBuilders.js";
import {
makeSearxngCacheKey,
searchSearxng,
searxngCacheGet,
searxngCacheSet,
} from "./searxngSearch.js";
import {
getTermDefinitionFromDb,
setTermDefinitionInDb,
} from "./termGlossaryStore.js";
import { wikipediaSummary } from "./wikipediaClient.js";
const log = createChildLogger("term-glossary");
@@ -65,16 +61,13 @@ const MISS_TTL_SECONDS = 60 * 60;
const MISS_TTL_MS = MISS_TTL_SECONDS * 1000;
/** Sentinel stored in caches for "term has no resolvable definition". */
const EMPTY_SENTINEL = "__not_found__";
/** Per-search timeout — keep glossary lookups snappy even on a slow SearXNG. */
const GLOSSARY_SEARCH_TIMEOUT_MS = 5000;
/** Delay before retrying a search that returned zero results. */
const RETRY_DELAY_MS = 350;
/** Max definition snippet length kept in the prompt. */
const MAX_DEFINITION_CHARS = 300;
/**
* SearXNG rate-limits aggressive parallel bursts (returns 200 with empty
* results). Never fire all terms at once — cap live searches at 2 concurrent
* and stagger the start times slightly.
* Wikipedia can be flaky under aggressive parallel bursts. Never fire all
* terms at once — cap live lookups at 2 concurrent and stagger the start times.
*/
const LIVE_SEARCH_CONCURRENCY = 2;
const LIVE_SEARCH_STAGGER_MS = 250;
@@ -90,7 +83,7 @@ const termLru = new LRUCache<string, TermDefinition>({
ttl: 24 * 60 * 60 * 1000,
});
/** Serializes live SearXNG lookups (rate-limit aware) with a small stagger. */
/** Serializes live Wikipedia lookups (rate-limit aware) with a small stagger. */
const liveSearchLimit = pLimit(LIVE_SEARCH_CONCURRENCY);
let lastLiveSearchAt = 0;
async function acquireLiveSlot(): Promise<void> {
@@ -274,58 +267,8 @@ export interface TermDefinition {
sourceUrl: string;
}
/** Definition-like markers for accepting a non-Wikipedia search result. */
const DEF_MARKERS =
/adalah|merupakan|istilah (?:untuk|yang|yg)|artinya|sebutan|berarti|refers? to|known as|also called|short for|a term (?:for|used)|istilah dalam|kata (?:asing|serapan)? ?untuk/i;
/** True when the term appears in the result text (or a 4+ char word in the
* result is part of the term). Lenient — "kafircel" matches a "Kafir"
* article via substring, while a Google-Translate homepage snippet does not. */
function hasTermOverlap(term: string, title: string, snippet: string): boolean {
const termLower = term.toLowerCase();
const text = `${title} ${snippet}`.toLowerCase();
if (text.includes(termLower)) return true;
const words = text.match(/[a-z0-9]{4,}/gi) ?? [];
return words.some((w) => termLower.includes(w));
}
/** Quality gate: is this result good enough to quote as a definition? */
function isUsableDefinition(
r: { title: string; url: string; snippet: string },
term: string,
isWiki: boolean,
): boolean {
const text = `${r.title} ${r.snippet}`;
// Wikipedia disambiguation pages are not definitions
if (/disambiguasi|disambiguation/i.test(text)) return false;
if ((r.snippet ?? "").trim().length < 25) return false;
if (!hasTermOverlap(term, r.title, r.snippet)) return false;
// Wikipedia articles are accepted with just the overlap+length gate;
// everything else must read like an actual definition, not an ad,
// a translate homepage, or a navigation blurb.
if (isWiki) return true;
return DEF_MARKERS.test(r.snippet);
}
/** Picks the best definition from search results, preferring a genuine
* Wikipedia article; otherwise the first result that reads like a
* definition. Returns null when nothing qualifies. */
function pickDefinition(
results: Array<{ title: string; url: string; snippet: string }>,
term: string,
): TermDefinition | null {
const wiki = results.find((r) => /wikipedia\.org/i.test(r.url));
const best = wiki && isUsableDefinition(wiki, term, true) ? wiki : null;
if (!best) {
for (const r of results) {
if (isUsableDefinition(r, term, false)) {
return buildDefinition(r, term);
}
}
return null;
}
return buildDefinition(best, term);
}
/** Per-search timeout — keep glossary lookups snappy even on a slow Wikipedia. */
const GLOSSARY_SEARCH_TIMEOUT_MS = 5000;
function buildDefinition(
best: { title: string; url: string; snippet: string },
@@ -339,7 +282,7 @@ function buildDefinition(
return { term, definition, sourceUrl: best.url };
}
/** Live (network) lookup — runs under the shared SearXNG rate-limit gate. */
/** Live (network) lookup — runs under the shared Wikipedia rate-limit gate. */
async function fetchDefinitionLive(
term: string,
key: string,
@@ -348,31 +291,21 @@ async function fetchDefinitionLive(
return liveSearchLimit(async () => {
await acquireLiveSlot();
try {
let results = await searchSearxng(
key,
"general",
undefined,
GLOSSARY_SEARCH_TIMEOUT_MS,
);
let def = pickDefinition(results, term);
// Zero results is usually the limiter kicking in, not a real miss —
// retry once. Results-but-unusable = genuine miss, no retry.
if (!def && results.length === 0) {
let result = await wikipediaSummary(key, GLOSSARY_SEARCH_TIMEOUT_MS);
let def = result ? buildDefinition(result, term) : null;
// Zero result is usually the limiter/network blip, not a real miss —
// retry once. Result-but-unusable = genuine miss, no retry.
if (!def) {
await delay(RETRY_DELAY_MS);
results = await searchSearxng(
key,
"general",
undefined,
GLOSSARY_SEARCH_TIMEOUT_MS,
);
def = pickDefinition(results, term);
result = await wikipediaSummary(key, GLOSSARY_SEARCH_TIMEOUT_MS);
def = result ? buildDefinition(result, term) : null;
}
if (def) {
// Persist permanently (definitions rarely change) — best-effort,
// then warm the fast caches.
void setTermDefinitionInDb(key, def.definition, def.sourceUrl);
searxngCacheSet(
cacheSet(
cacheKey,
JSON.stringify({
definition: def.definition,
@@ -393,13 +326,13 @@ async function fetchDefinitionLive(
// No definition — cache the miss with a SHORT TTL so a transient
// limiter/network failure is retried on a later batch.
searxngCacheSet(cacheKey, EMPTY_SENTINEL, MISS_TTL_SECONDS);
cacheSet(cacheKey, EMPTY_SENTINEL, MISS_TTL_SECONDS);
termLru.set(key, NOT_FOUND, { ttl: MISS_TTL_MS });
return null;
});
}
/** Resolve one term: LRU → Redis → Postgres (permanent) → live SearXNG
/** Resolve one term: LRU → Redis → Postgres (permanent) → live Wikipedia
* (rate-limited). The fast caches sit in front of the DB; the DB is the
* source of truth for successfully resolved definitions. */
async function resolveTerm(term: string): Promise<TermDefinition | null> {
@@ -412,8 +345,8 @@ async function resolveTerm(term: string): Promise<TermDefinition | null> {
// 2. Redis — shared across processes/workers. A miss sentinel here is NOT
// a definitive answer: it may predate a permanent DB entry written by
// another process, so we keep going and let the DB decide.
const cacheKey = makeSearxngCacheKey("def", key);
const cached = await searxngCacheGet(cacheKey);
const cacheKey = makeCacheKey("def", key);
const cached = await cacheGet(cacheKey);
let redisMiss = false;
if (cached !== null) {
if (cached === EMPTY_SENTINEL) {
@@ -449,7 +382,7 @@ async function resolveTerm(term: string): Promise<TermDefinition | null> {
sourceUrl: dbDef.sourceUrl,
};
termLru.set(key, def);
searxngCacheSet(
cacheSet(
cacheKey,
JSON.stringify({ definition: def.definition, sourceUrl: def.sourceUrl }),
DEF_TTL_SECONDS,
@@ -1,7 +1,7 @@
/**
* textBatchProcessor.ts
*
* Processes text-only moderation batches — fetches URL content, runs SearXNG
* Processes text-only moderation batches — fetches URL content, runs Wikipedia
* searches, deduplicates short messages, splits into sub-batches, and calls
* the LLM for analysis. Extracted from moderationOrchestrator.ts.
*/
@@ -28,15 +28,15 @@ import {
} from "./moderationBuilders.js";
import { buildSystemPrompt as buildSystemPromptModular } from "./moderationPrompt.js";
import { logModerationAnalysis } from "./responseLogger.js";
import {
extractSearchQueries,
formatSearchResults,
searchSearxng,
} from "./searxngSearch.js";
import { buildTermGlossaryBlock } from "./termGlossary.js";
import { getRecentCorrectedModerations } from "./textCacheStore.js";
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
import type { MessageImagePart } from "./visionAnalyzer.js";
import {
extractSearchQueries,
formatSearchResults,
wikipediaSearch,
} from "./wikipediaClient.js";
const log = createChildLogger("textBatchProcessor");
@@ -117,7 +117,7 @@ export async function runTextOnlyBatch(
return { text: textMap, image: imageMap, title: titleMap };
})();
const searxngPromise = (async () => {
const webSearchPromise = (async () => {
const queries = new Set<string>();
for (const msg of targets) {
for (const q of extractSearchQueries(msg.edited_content ?? msg.content))
@@ -126,7 +126,7 @@ export async function runTextOnlyBatch(
if (queries.size === 0) return new Map<string, string>();
const queryArr = Array.from(queries).slice(0, 3);
const results = await Promise.allSettled(
queryArr.map((q) => searchSearxng(q)),
queryArr.map((q) => wikipediaSearch(q)),
);
const map = new Map<string, string>();
for (let i = 0; i < queryArr.length; i++) {
@@ -138,15 +138,13 @@ export async function runTextOnlyBatch(
})();
// Term glossary — per-word Wikipedia lookups for words the LLM may not
// know (slang, jargon, regional language). Cached in Redis + in-memory, so
// repeat terms resolve instantly and only genuinely new words hit SearXNG.
const glossaryPromise = buildTermGlossaryBlock(
targets.map((msg) => getAnalysisContent(msg)),
).catch(() => "");
const [urlFetchMaps, searxngResults, glossaryBlock] = await Promise.all([
const [urlFetchMaps, webSearchResults, glossaryBlock] = await Promise.all([
urlFetchPromise,
searxngPromise,
webSearchPromise,
glossaryPromise,
]);
const urlFetchMap = urlFetchMaps.text;
@@ -308,9 +306,9 @@ export async function runTextOnlyBatch(
)
).join("\n");
const searxngBlock =
searxngResults.size > 0
? `<web_searches>\n${Array.from(searxngResults.entries())
const webSearchBlock =
webSearchResults.size > 0
? `<web_searches>\n${Array.from(webSearchResults.entries())
.map(
([q, xml]) =>
` <search_query query="${escapeXml(q)}">\n${xml} </search_query>`,
@@ -323,7 +321,7 @@ export async function runTextOnlyBatch(
// profile descriptions are intentionally omitted (see above).
const userBlocks = [
contextBlock?.trimEnd() ?? "",
searxngBlock,
webSearchBlock,
glossaryBlock,
`<messages_to_analyze>\n${messagesBlock}\n</messages_to_analyze>`,
].filter((b) => b.trim().length > 0);
@@ -76,13 +76,13 @@ import {
buildStickerTextOnlyWarning,
buildStickerVisionPrompt,
} from "./moderationPrompt.js";
import { buildTermGlossaryBlock } from "./termGlossary.js";
import { extractUrlsFromText } from "./urlFetcher.js";
import {
extractSearchQueries,
formatSearchResults,
searchSearxng,
} from "./searxngSearch.js";
import { buildTermGlossaryBlock } from "./termGlossary.js";
import { extractUrlsFromText } from "./urlFetcher.js";
wikipediaSearch,
} from "./wikipediaClient.js";
// ---------------------------------------------------------------------------
// Types
@@ -354,12 +354,12 @@ export async function prepareMediaMessage(
),
);
// SearXNG
let searxngXml = "";
// Wikipedia web search (context enrichment)
let webSearchXml = "";
const queries = extractSearchQueries(content);
if (queries.length > 0) {
const results = await Promise.allSettled(
queries.map((q) => searchSearxng(q)),
queries.map((q) => wikipediaSearch(q)),
);
const parts: string[] = [];
for (let i = 0; i < results.length; i++) {
@@ -368,7 +368,7 @@ export async function prepareMediaMessage(
parts.push(formatSearchResults(r.value));
}
if (parts.length > 0)
searxngXml = `\n<web_searches>\n${parts.join("\n")}\n</web_searches>`;
webSearchXml = `\n<web_searches>\n${parts.join("\n")}\n</web_searches>`;
}
// Term glossary — cached per-word Wikipedia definitions for words the LLM
@@ -403,6 +403,6 @@ export async function prepareMediaMessage(
// still tracked in the DB for enforcement, just not shown to the LLM.
const isBot = resolveIsBot(target);
const isEdited = resolveIsEdited(target);
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}" time="${new Date(target.created_at).toISOString()}"${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${searxngXml}${glossaryCtx}\n</message>`;
const messageBlock = `<message id="${escapeXml(target.id)}" user="${escapeXml(resolveDisplayName(target))}" time="${new Date(target.created_at).toISOString()}"${isBot ? ` bot="true"` : ""}${isEdited ? ` edited="true"` : ""}>\n ${refXml ? `\n ${refXml}` : ""}\n <content>${escapeXml(truncateForAi(content))}</content>${mediaContext ? ` ${escapeXml(mediaContext)}` : ""}${webContext}${mediaAnalysisContext}${webSearchXml}${glossaryCtx}\n</message>`;
return { targetId, messageBlock };
}
@@ -0,0 +1,260 @@
/**
* wikipediaClient.ts
*
* Wikipedia adapter for AI analysis context enrichment.
*
* Replaces the old SearXNG web-search dependency (removed). Instead of a
* meta-search instance, we talk to the public Wikipedia REST + Action APIs
* directly with native `fetch` no extra npm dependency, full control, and
* a stable, well-documented endpoint.
*
* Layer exposed to the moderation pipeline:
* WikipediaClient
* search() list(query) (Action API: opensearch-like)
* getSummary() summary(title) (REST summary endpoint)
* (page content) page(title) [reserved]
*
* The functions below are thin wrappers matching the old consumer surface so
* call sites change as little as possible.
*/
import { createChildLogger } from "@/shared/logger/index";
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
import { config } from "../../shared/config/config.js";
const log = createChildLogger("wikipedia-client");
const WIKIPEDIA_LANG = config.WIKIPEDIA_LANG.toLowerCase();
const MAX_RESULTS = 3;
const DEFAULT_TIMEOUT_MS = config.WIKIPEDIA_TIMEOUT_MS;
/** Canonical article URL for a title in the active wiki language. */
export function wikipediaPageUrl(title: string): string {
return `https://${WIKIPEDIA_LANG}.wikipedia.org/wiki/${encodeURIComponent(
title.trim().replace(/ /g, "_"),
)}`;
}
export interface SearchResult {
title: string;
url: string;
snippet: string;
}
function buildUserAgent(): string {
return "GMWBeta/1.0 (https://github.com/asepharyana; Discord moderation bot)";
}
function stripHtml(snippet: string): string {
return snippet
.replace(/<[^>]+>/g, "")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&")
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/\s+/g, " ")
.trim();
}
/**
* Search Wikipedia for a query and return up to MAX_RESULTS structured hits.
* Uses the Action API `list=search` (srsearch) which is stable and returns
* title + HTML snippet. Graceful: returns [] on any failure.
*/
export async function wikipediaSearch(
query: string,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<SearchResult[]> {
const q = query.trim();
if (!q) return [];
const params = new URLSearchParams({
action: "query",
list: "search",
srsearch: q,
srlimit: String(MAX_RESULTS),
format: "json",
origin: "*",
});
const { controller, clear } = createAbortControllerWithTimeout(timeoutMs);
try {
const res = await fetch(
`https://${WIKIPEDIA_LANG}.wikipedia.org/w/api.php?${params.toString()}`,
{
signal: controller.signal,
headers: {
Accept: "application/json",
"User-Agent": buildUserAgent(),
},
},
);
if (!res.ok) {
log.warn({ status: res.status, query: q }, "Wikipedia search failed");
return [];
}
const data = (await res.json()) as {
query?: { search?: Array<{ title: string; snippet?: string }> };
};
const hits = data.query?.search ?? [];
const mapped = hits.slice(0, MAX_RESULTS).map((h) => ({
title: h.title,
url: wikipediaPageUrl(h.title),
snippet: stripHtml(h.snippet ?? "").slice(0, 500),
}));
log.debug({ query: q, resultCount: mapped.length }, "Wikipedia search OK");
return mapped;
} catch (err) {
log.warn(
{ error: err instanceof Error ? err.message : String(err), query: q },
"Wikipedia search error",
);
return [];
} finally {
clear();
}
}
/**
* Fetch the lead summary of a specific Wikipedia article via the REST
* summary endpoint. Returns null when the article is missing or the request
* fails. Useful for the term glossary's direct lookups.
*/
export async function wikipediaSummary(
title: string,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<SearchResult | null> {
const t = title.trim();
if (!t) return null;
const { controller, clear } = createAbortControllerWithTimeout(timeoutMs);
try {
const res = await fetch(
`https://${WIKIPEDIA_LANG}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(
t.replace(/ /g, "_"),
)}`,
{
signal: controller.signal,
headers: {
Accept: "application/json",
"User-Agent": buildUserAgent(),
},
},
);
if (!res.ok) return null;
const data = (await res.json()) as {
title?: string;
extract?: string;
content_urls?: { desktop?: { page?: string } };
};
if (!data.extract) return null;
return {
title: data.title ?? t,
url: data.content_urls?.desktop?.page ?? wikipediaPageUrl(t),
snippet: data.extract.slice(0, 500),
};
} catch (err) {
log.warn(
{ error: err instanceof Error ? err.message : String(err), title: t },
"Wikipedia summary error",
);
return null;
} finally {
clear();
}
}
/**
* Extract meaningful search queries from message content.
* Uses multiple strategies to find terms worth searching.
* Returns up to 3 clean queries.
*/
export function extractSearchQueries(content: string): string[] {
const queries = new Set<string>();
// 1. Quoted phrases (explicit user intent)
const quotedPhrases = content.match(/"([^"]+)"|'([^']+)'/g);
if (quotedPhrases) {
for (const phrase of quotedPhrases) {
const clean = phrase.replace(/["']/g, "").trim();
if (clean.length >= 3) queries.add(clean);
}
}
// 2. "nonton X" pattern — extract the title
const nontonMatch = content.match(
/\b(nonton|tonton|rekomen|cari|search|google)\s+(.+?)(?:\s+(?:anime|kartun|film|movie|series|serial))?\s*[!?.]*$/i,
);
if (nontonMatch) {
const title = nontonMatch[2].trim();
if (title.length >= 2 && title.length <= 80) {
queries.add(title);
}
}
// 3. "X anime/film" pattern — title before category
const titleBeforeCategory = content.match(
/\b(\w[\w\s]{2,40})\s+(?:anime|kartun|film|movie|series|serial)\b/i,
);
if (titleBeforeCategory) {
const title = titleBeforeCategory[1].trim();
if (
title.length >= 3 &&
!/^(yang|yang|sama|dari|untuk|ini|itu|ada)$/i.test(title)
) {
queries.add(title);
}
}
// 4. Standalone proper nouns (2+ words, capitalized) that look like titles
const properNouns = content.match(
/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,4})\b/g,
);
if (properNouns) {
for (const noun of properNouns) {
// Skip common non-title proper nouns
const skip =
/^(Discord|YouTube|Google|Facebook|Instagram|Twitter|Github|ChatGPT|OpenAI|Claude|Telegram|WhatsApp|TikTok|Netflix|Spotify|Steam|Instagram)$/i;
if (!skip.test(noun) && noun.length >= 5) {
queries.add(noun);
}
}
}
// 5. Terms that suggest research intent
const researchTerms = content.match(
/\b(apa\s+(?:itu|sih)|what\s+is|siapa\s+itu|who\s+is|arti|meaning|definisi|definition)\s+(.{3,60})/i,
);
if (researchTerms) {
const term = researchTerms[2].trim().replace(/[?!.]+$/, "");
if (term.length >= 3) queries.add(term);
}
return Array.from(queries).slice(0, 3);
}
/**
* Format Wikipedia results as XML for LLM context.
*/
export function formatSearchResults(results: SearchResult[]): 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;");
}
@@ -104,10 +104,12 @@ export const configSchema = z
// ── Redis ────────────────────────────────────────────────────────────
REDIS_URL: z.string().default("redis://localhost:6379"),
// ── SearXNG ───────────────────────────────────────────────────────────
// Instance for web search + term glossary lookups. Override when the
// default instance is down/rate-limited.
SEARXNG_BASE_URL: z.string().url().default("https://searxng.imrnes.team"),
// ── Wikipedia (web-search / glossary source) ─────────────────────────
// Native fetch to Wikipedia REST + Action APIs — no SearXNG dependency.
// Language for summaries/search (e.g. "id", "en").
WIKIPEDIA_LANG: z.string().min(1).default("id"),
// Per-request timeout (ms) for Wikipedia API calls.
WIKIPEDIA_TIMEOUT_MS: z.coerce.number().positive().default(8000),
// ── Voice PCM WebSocket (direct gateway→backend, bypasses Redis) ────
VOICE_PCM_WS_ENABLED: z
.string()