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.
216 lines
8.1 KiB
TypeScript
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);
|
|
});
|