Files
GMW/services/discord-gateway/tests/llmE2e.test.ts
T
asepharyana 43e35a71dd test: add live-LLM E2E moderation test suite
Add tests/llmE2e.test.ts — 7 end-to-end tests driving the REAL
moderation prompt pipeline (buildSystemPrompt → XML payload → llmChat
→ parseModerationResponse) against a live model via omniroute.

Covers: clean technical content (no false positives), harassment
(flagged), username-only offenses including 'Pecinta Pria' +
sexual/provocative usernames + SARA-in-username (always warn/low,
NEVER delete — the nickname-reset path), and spam bursts.

Gated behind AI_LLM_BASE_URL + AI_LLM_API_KEY: CI (no creds) skips
the file → 216 unit tests stay green, zero LLM cost. Run locally via
pnpm test:e2e:live (scripts/run-llm-e2e.sh injects creds from bws).

Verified: 223/223 tests pass with live LLM, stability across 4 runs,
typecheck + biome clean. docs: TESTING.md. ignore .hermes/ plans.
2026-09-18 21:27:06 +07:00

216 lines
8.1 KiB
TypeScript

// ═══════════════════════════════════════════════════════════════════════════
// 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() → <messages_to_analyze> 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 `<message id="${m.id}" user="${m.user}" time="2026-09-18T10:00:00.000Z"${repAttr}>\n <content>${m.content}</content>\n</message>`;
})
.join("\n");
return `<messages_to_analyze>\n${block}\n</messages_to_analyze>`;
}
async function moderate(
messages: Array<{
id: string;
user: string;
content: string;
repetitions?: string;
}>,
opts: { maxTokens?: number } = {},
): Promise<ReturnType<typeof parseModerationResponse>> {
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);
});