Add TypeSafe Jev via @typesafe-ai/sdk v0.6.0 as the PRIMARY analyzer for text-only moderation sub-batches; the existing LLM stays as the fallback for anything Jev cannot decide confidently (per-message gate rejection or API failure) and for media batches. - jevAnalyzer.ts: TypeSafeClient wrapper, declarative state builder (System One models MUST get factual state, not chat-XML — chat framing made Jev confidently wrong on clean messages at 0.98 confidence), message-id-keyed question builder (5 typed questions per message), cross-consistency acceptance gate (noul↔status↔severity↔action↔category), answer→AnalysisResult mapper, fail-open outcome. - textBatchProcessor.ts: Jev-first per sub-batch, rejected ids + API failures fall back to callModerationLLM; no cross-batch pollution. - config: AI_LLM_JEV_ENABLED/API_KEY/BASE_URL/MODEL/TIMEOUT_MS/MIN_CONFIDENCE. - .env.example: documented all 6 Jev env vars. - tests: unit (question/state builders, gate, mapper) + live smoke (gated behind AI_LLM_JEV_SMOKE=1) verified 4/4 accepted vs real 9router.
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
/**
|
|
* jev-live-smoke.test.ts — LIVE smoke test for the Jev analyzer (real 9router).
|
|
*
|
|
* Gated behind `AI_LLM_JEV_SMOKE=1` (and the Jev env vars) so plain
|
|
* `pnpm test` / CI skips it. Verifies the ACTUAL question set + state
|
|
* builder + acceptance gate + mapper end-to-end against the real
|
|
* `analyzeBatchWithJev` module (no gateway restart needed).
|
|
*
|
|
* Run:
|
|
* AI_LLM_JEV_SMOKE=1 AI_LLM_JEV_API_KEY=sk-... npx vitest run tests/jev-live-smoke.test.ts
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
import { analyzeBatchWithJev } from "../src/modules/ai-moderation/jevAnalyzer.js";
|
|
|
|
const SMOKE = process.env.AI_LLM_JEV_SMOKE === "1";
|
|
const SKIP_REASON =
|
|
"AI_LLM_JEV_SMOKE != 1 — set it + AI_LLM_JEV_API_KEY to run the live smoke against 9router";
|
|
|
|
describe("Jev live smoke (real 9router /v1/systemone)", () => {
|
|
it.runIf(SMOKE)(
|
|
"analyzes 4 mixed messages with the production question set",
|
|
async () => {
|
|
const targets = [
|
|
{
|
|
id: "m_vulgar",
|
|
user: "alerte",
|
|
content: "kontol lu semua pada goblok, dasar tolol",
|
|
},
|
|
{
|
|
id: "m_clean",
|
|
user: "devganteng",
|
|
content: "gimana hasil compile nya bang? error terus nih",
|
|
},
|
|
{
|
|
id: "m_sara",
|
|
user: "anon",
|
|
content: "agama lu palestina itu sampah, mending dibom habis",
|
|
},
|
|
{
|
|
id: "m_help",
|
|
user: "helper",
|
|
content:
|
|
"nggak papa bang, nanti gw bantu debug, coba pnpm install dulu",
|
|
},
|
|
];
|
|
|
|
const outcome = await analyzeBatchWithJev(
|
|
targets,
|
|
{
|
|
contextBlock:
|
|
"<location_context channel_id='42424242' channel_name='dev-chat'/>\n" +
|
|
"<conversation_context>\n[conversation_flow] status=sparse context_msgs=3 dropped=0\n" +
|
|
"[context] id='c1' time='2026-09-23T08:00:00Z' user='alice': lagi pada error compile nih\n" +
|
|
"</conversation_context>",
|
|
webSearchBlock: "",
|
|
glossaryBlock: "",
|
|
channelCulture: "channel santai developer coding",
|
|
},
|
|
undefined,
|
|
"",
|
|
);
|
|
|
|
expect(outcome.error).toBeNull();
|
|
expect(outcome.rejectedIds).toHaveLength(0);
|
|
|
|
const byId = Object.fromEntries(
|
|
outcome.results.map((r) => [r.messageId, r]),
|
|
);
|
|
// Vulgar attack → flagged, vulgar_language
|
|
expect(byId.m_vulgar?.status).toBe("flagged");
|
|
expect(byId.m_vulgar?.categories).toContain("vulgar_language");
|
|
// SARA religious slur → flagged (zero-tolerance rule)
|
|
expect(byId.m_sara?.status).toBe("flagged");
|
|
// Clean technical messages → clean
|
|
expect(byId.m_clean?.status).toBe("clean");
|
|
expect(byId.m_help?.status).toBe("clean");
|
|
|
|
// Verdicts are calibration-honest
|
|
for (const r of Object.values(byId) as Array<{
|
|
status: string;
|
|
confidence: number;
|
|
score: number;
|
|
analysis: string;
|
|
}>) {
|
|
expect(r.confidence).toBeGreaterThanOrEqual(0.9);
|
|
if (r.status === "clean") expect(r.score).toBe(0);
|
|
expect(r.analysis).toContain("[Jev]");
|
|
expect(r.analysis).toContain("p_melanggar=");
|
|
}
|
|
},
|
|
SMOKE ? 60_000 : 0,
|
|
);
|
|
|
|
it.skipIf(!SMOKE)("skipped when AI_LLM_JEV_SMOKE is unset", () => {
|
|
expect(SKIP_REASON).toBeTruthy();
|
|
});
|
|
});
|