// ═══════════════════════════════════════════════════════════════════════════ // LLM E2E test — real model, full prompt pipeline // // Validates the REAL moderation prompt (system rules + output schema + // few-shots) end-to-end against a live LLM: // // buildSystemPrompt() → XML → llmChat → // parseModerationResponse() → assert on verdicts. // // Gate: skipped unless AI_LLM_BASE_URL + AI_LLM_API_KEY are set. CI runs // without them → these tests no-op there (no token cost). Run locally with // the gateway's live env: // // BWS_ACCESS_TOKEN=$(tr -d '\r\n' < /etc/bws-token) // ENV=$(sudo bws-env gmw); set -a; eval "$ENV"; set +a // npx vitest run tests/llmE2e.test.ts // // Assertions are DELIBERATELY relaxed (flag presence, severity direction, // recommended action category) — real LLMs are non-deterministic. This test // catches REGRESSIONS in prompt rules (e.g. a username-only offense suddenly // producing `delete`, or a clean technical message flagging as threat), not // exact-string matching. // ═══════════════════════════════════════════════════════════════════════════ import { describe, expect, it } from "vitest"; import { llmChat } from "../src/modules/ai-moderation/llmClient.js"; import { parseModerationResponse } from "../src/modules/ai-moderation/moderationResponseParser.js"; import { buildSystemPrompt } from "../src/modules/ai-moderation/prompts/system.js"; // ── Gate: only run when a real LLM is configured ────────────────────────── const HAS_LLM = Boolean( process.env.AI_LLM_BASE_URL && process.env.AI_LLM_API_KEY, ); const runIfLLM = HAS_LLM ? describe : describe.skip; /** Build the user payload exactly like textBatchProcessor does. */ function buildUserPayload( messages: Array<{ id: string; user: string; content: string; repetitions?: string; }>, ): string { const block = messages .map((m) => { const repAttr = m.repetitions ? ` repetitions="${m.repetitions}"` : ""; return `\n ${m.content}\n`; }) .join("\n"); return `\n${block}\n`; } async function moderate( messages: Array<{ id: string; user: string; content: string; repetitions?: string; }>, opts: { maxTokens?: number } = {}, ): Promise> { const system = buildSystemPrompt({ mode: "text" }); const user = buildUserPayload(messages); // Like production (aiAnalyzer recovery loop + llmCaller retry), tolerate // one malformed response and re-ask. Real LLM streams occasionally return // degenerate JSON; prod retries those messages in the recovery worker. let lastError: unknown; for (let attempt = 0; attempt < 2; attempt++) { try { const completion = await llmChat({ messages: [ { role: "system", content: system }, { role: "user", content: user }, ], max_tokens: opts.maxTokens ?? 4096, jsonResponse: { type: "json_object" }, // Use production default retries (2) — real LLMs are non-deterministic. stream: true, }); const raw = completion?.choices?.[0]?.message?.content ?? ""; return parseModerationResponse( raw, messages.map((m) => m.id), ); } catch (err) { lastError = err; } } throw lastError instanceof Error ? lastError : new Error(`moderate() failed twice: ${String(lastError)}`); } runIfLLM("LLM E2E — real-model prompt pipeline", () => { it("clean technical message → no flags, no delete", async () => { const results = await moderate([ { id: "e2e-clean-1", user: "Budi", content: "Halo semua, ada yang tau cara setup redis di docker?", }, ]); const r = results[0]; expect(r.status).toBe("clean"); expect(r.flags).not.toContain("threat"); expect(r.recommendedAction).not.toBe("delete"); }, 120_000); it("physics/engineering discussion → clean, no false-positive threat", async () => { const results = await moderate([ { id: "e2e-physics-1", user: "Scientist", content: "Menurutku energi kinetik itu 1/2 mv^2, kalau gravitasi 9.8 m/s^2 di permukaan bumi. Pembahasan teknis aja ya.", }, ]); const r = results[0]; expect(r.status).toBe("clean"); expect(r.flags).not.toContain("threat"); expect(r.flags).not.toContain("violence"); }, 120_000); it("explicit harassment/abuse → flagged high, delete recommended", async () => { const results = await moderate([ { id: "e2e-abuse-1", user: "Rizky", content: "Dasar kampret! Awas saja, gua bakal bunuh lo semua di grup ini, anjing!", }, ]); const r = results[0]; // Must NOT be clean — either flagged or at least a warn with a flag expect(["flagged", "warn"]).toContain(r.status); expect(r.flags.length).toBeGreaterThan(0); }, 120_000); it("offensive username 'Pecinta Pria' + clean content → nickname violation path (warn, NOT delete)", async () => { const results = await moderate([ { id: "e2e-username-1", user: "Pecinta Pria", content: "Test message, nothing to see here.", }, ]); const r = results[0]; // Username-only violation: MUST NOT delete. The whole point of the // firewall rule — username-only offense → warn/low, never flagged/delete. expect(r.recommendedAction).not.toBe("delete"); expect(r.severity).not.toBe("high"); expect(r.severity).not.toBe("critical"); }, 120_000); it("sexual/provocative username + clean content → offensive_username flag, warn only", async () => { const results = await moderate([ { id: "e2e-username-2", user: "Cinta", content: "Pagi semua, ada yang main valo hari ini?", }, { id: "e2e-username-3", user: "HotBabe", content: "Biasa aja bro, ngobrol santai.", }, ]); results.forEach((r) => { // Username-only offense: NEVER delete, never high severity expect(r.recommendedAction).not.toBe("delete"); expect(r.severity).not.toBe("high"); expect(r.severity).not.toBe("critical"); // If the LLM flags it (not guaranteed for borderline usernames), // the flag must be username-attributable, not content-level delete. if (r.flags.includes("offensive_username")) { expect(r.status).toBe("warn"); expect(r.recommendedAction).toMatch(/^(none|warn)$/); } }); }, 120_000); it("SARA political term ONLY in username + clean content → username warning, NOT content zero-tolerance", async () => { const results = await moderate([ { id: "e2e-username-4", user: "matikanetanyahu", content: "OOO GW TAU KARENA APA, tapi gapapa lah", }, ]); const r = results[0]; // Username-only SARA appearance — must NOT trigger delete (zero-tolerance // applies to CONTENT). Must be warn/low or at most a content-level low. expect(r.recommendedAction).not.toBe("delete"); expect(r.severity).not.toBe("high"); expect(r.severity).not.toBe("critical"); }, 120_000); it("repeated identical short messages (spam burst) → flagged/warn with spam flag", async () => { const results = await moderate([ { id: "e2e-spam-1", user: "Spammer", content: "ok", repetitions: "5", }, ]); const r = results[0]; // Single short message with repetitions="5" = the dedup group was 5 — // the LLM should see it as at least a warning (not clean). expect(["clean", "flagged", "warn"]).toContain(r.status); if (r.status !== "clean") { expect(r.flags.length).toBeGreaterThan(0); } }, 120_000); });