Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a59f3132ee | ||
|
|
c7f53e4f7e | ||
|
|
8583bcdf17 | ||
|
|
b12eb0a038 | ||
|
|
b72423c64d | ||
|
|
6b34fc97ec | ||
|
|
8ce8978755 | ||
|
|
869cad88e4 | ||
|
|
f136cf3f6b | ||
|
|
cd3ee5b5d8 | ||
|
|
93288afa0b |
No files matched your search
@@ -74,12 +74,6 @@ AI_LLM_IMAGE_MAX_DIMENSION=1024 # Max image dimension in pixels before r
|
||||
AI_LLM_TEXT_BATCH_SIZE=20 # Max messages per text-only moderation batch (default: 20)
|
||||
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS=60000 # Timeout in ms for media analysis calls (default: 60000)
|
||||
AI_LLM_TEXT_ANALYSIS_TIMEOUT_MS=30000 # Timeout in ms for text-only analysis calls (default: 30000)
|
||||
AI_LLM_JEV_ENABLED=false # Use TypeSafe Jev (System One) as PRIMARY text analyzer; LLM is fallback
|
||||
AI_LLM_JEV_API_KEY= # REQUIRED if AI_LLM_JEV_ENABLED=true. 9router/TypeSafe API key for /v1/systemone
|
||||
AI_LLM_JEV_BASE_URL=http://127.0.0.1:4014 # 9router base URL (default: local 9router; prod: https://9router.asepharyana.my.id)
|
||||
AI_LLM_JEV_MODEL=oc/jev-1.13-free # Jev model id (default: oc/jev-1.13-free)
|
||||
AI_LLM_JEV_TIMEOUT_MS=45000 # Timeout in ms for a Jev systemone batch call (default: 45000)
|
||||
AI_LLM_JEV_MIN_CONFIDENCE=0.9 # Min status-choice confidence to accept a Jev verdict (default: 0.9)
|
||||
|
||||
# === AI Analysis Tuning ===
|
||||
AI_ANALYSIS_DEBOUNCE_MS=500 # Debounce window for batching messages in ms (default: 500)
|
||||
|
||||
@@ -23,3 +23,7 @@ result
|
||||
|
||||
# Playwright MCP artifacts
|
||||
.playwright-mcp/
|
||||
findings.md
|
||||
findings.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
@@ -23,7 +23,6 @@
|
||||
"test:e2e:live": "bash scripts/run-llm-e2e.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typesafe-ai/sdk": "^0.6.0",
|
||||
"axios": "^1.20.0",
|
||||
"discord.js-selfbot-v13": "^3.7.1",
|
||||
"dotenv": "^18.0.0",
|
||||
@@ -48,7 +47,7 @@
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tsx": "^4.23.13",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "latest"
|
||||
}
|
||||
|
||||
Generated
+14
-23
@@ -8,9 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@typesafe-ai/sdk':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
axios:
|
||||
specifier: ^1.20.0
|
||||
version: 1.20.0(debug@4.4.3(supports-color@7.2.0))(supports-color@7.2.0)
|
||||
@@ -79,14 +76,14 @@ importers:
|
||||
specifier: ^0.31.10
|
||||
version: 0.31.10
|
||||
tsx:
|
||||
specifier: ^4.23.13
|
||||
version: 4.23.13
|
||||
specifier: ^4.23.15
|
||||
version: 4.23.15
|
||||
typescript:
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
vitest:
|
||||
specifier: latest
|
||||
version: 5.0.1(@types/node@26.4.0)(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13))
|
||||
version: 5.0.1(@types/node@26.4.0)(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1090,10 +1087,6 @@ packages:
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typesafe-ai/sdk@0.6.0':
|
||||
resolution: {integrity: sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
@@ -2172,8 +2165,8 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tsx@4.23.13:
|
||||
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
|
||||
tsx@4.23.15:
|
||||
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -2993,8 +2986,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 26.4.0
|
||||
|
||||
'@typesafe-ai/sdk@0.6.0': {}
|
||||
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
@@ -3055,14 +3046,14 @@ snapshots:
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@vitest/mocker@5.0.1(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13))':
|
||||
'@vitest/mocker@5.0.1(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15))':
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
'@vitest/spy': 5.0.1
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 1.2.3
|
||||
optionalDependencies:
|
||||
vite: 8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)
|
||||
vite: 8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15)
|
||||
|
||||
'@vitest/spy@5.0.1': {}
|
||||
|
||||
@@ -3230,7 +3221,7 @@ snapshots:
|
||||
'@drizzle-team/brocli': 0.10.2
|
||||
'@esbuild-kit/esm-loader': 2.6.5
|
||||
esbuild: 0.25.12
|
||||
tsx: 4.23.13
|
||||
tsx: 4.23.15
|
||||
|
||||
drizzle-orm@0.45.2(@types/pg@8.23.1)(pg@8.23.0):
|
||||
optionalDependencies:
|
||||
@@ -3960,7 +3951,7 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.23.13:
|
||||
tsx@4.23.15:
|
||||
dependencies:
|
||||
esbuild: 0.28.2
|
||||
optionalDependencies:
|
||||
@@ -3996,7 +3987,7 @@ snapshots:
|
||||
util-deprecate@1.0.2:
|
||||
optional: true
|
||||
|
||||
vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13):
|
||||
vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15):
|
||||
dependencies:
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
@@ -4007,12 +3998,12 @@ snapshots:
|
||||
'@types/node': 26.4.0
|
||||
esbuild: 0.28.2
|
||||
fsevents: 2.3.3
|
||||
tsx: 4.23.13
|
||||
tsx: 4.23.15
|
||||
|
||||
vitest@5.0.1(@types/node@26.4.0)(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)):
|
||||
vitest@5.0.1(@types/node@26.4.0)(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15)):
|
||||
dependencies:
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/mocker': 5.0.1(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13))
|
||||
'@vitest/mocker': 5.0.1(vite@8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15))
|
||||
chai: 6.2.2
|
||||
es-module-lexer: 2.3.2
|
||||
expect-type: 1.4.0
|
||||
@@ -4023,7 +4014,7 @@ snapshots:
|
||||
tinybench: 6.1.4
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.17
|
||||
vite: 8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)
|
||||
vite: 8.1.5(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.15)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 26.4.0
|
||||
|
||||
@@ -1,563 +0,0 @@
|
||||
/**
|
||||
* jevAnalyzer.ts
|
||||
*
|
||||
* Jev (TypeSafe System One, `oc/jev-1.13-free` via 9router `/v1/systemone`)
|
||||
* — the PRIMARY analyzer for text-only moderation sub-batches. The existing
|
||||
* LLM (`llmChat`) stays as the fallback for any message Jev cannot decide
|
||||
* confidently (see the acceptance gate) and for media batches (Jev is
|
||||
* decision-only, no image input).
|
||||
*
|
||||
* CRITICAL framing rule (verified 2026-09-23, live probes):
|
||||
* Jev is a System One model — it evaluates typed questions against a STATE.
|
||||
* Feeding it the chat-optimized `SYSTEM_RULES` verbatim INSIDE chat-style
|
||||
* XML (`<messages_to_analyze>`, `<location_context>`, …) makes it
|
||||
* pattern-match the structure and return CONFIDENTLY WRONG verdicts
|
||||
* (flagged clean messages at confidence 0.98 in a probe — would pass any
|
||||
* naive gate and could auto-delete innocent content).
|
||||
*
|
||||
* The state MUST be declarative facts:
|
||||
* OBJEK PENILAIAN / PESAN: `- Pesan "<id>" dari "<user>": "<content>"` /
|
||||
* KEBIJAKAN as statements / KONTEKS as statements
|
||||
* and the questions phrased as "is this true" / "classify this" against
|
||||
* those facts. With that framing the same 4-message probe returned 4/4
|
||||
* correct verdicts at confidence 1.0, including the SARA zero-tolerance
|
||||
* case and the technical-clean case.
|
||||
*
|
||||
* The distilled `JEV_POLICY` below is a compact declarative summary of the
|
||||
* full chat policy (`prompts/rules.ts` SYSTEM_RULES). It is deliberately
|
||||
* kept short (~300 tokens) — the LLM keeps the full 12k-char policy; Jev
|
||||
* triages on the core axes, and anything it can't decide confidently falls
|
||||
* back to the LLM. Keep this block in sync when SYSTEM_RULES changes.
|
||||
*/
|
||||
|
||||
import type { Question, Questions } from "@typesafe-ai/sdk";
|
||||
import { choice, noul, TypeSafeClient } from "@typesafe-ai/sdk";
|
||||
import { createChildLogger } from "@/shared/logger/index";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { incrementCounterBy } from "../gateway-metrics/index.js";
|
||||
import type { AnalysisResult } from "../message-capture/types.js";
|
||||
|
||||
const log = createChildLogger("jev-analyzer");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Policy + vocab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Distilled declarative policy for Jev. DERIVED from `SYSTEM_RULES`
|
||||
* (prompts/rules.ts) — update this when the full policy changes. Kept as
|
||||
* factual statements, NOT instructions (System One evaluates truth).
|
||||
*/
|
||||
export const JEV_POLICY = `KEBIJAKAN SERVER (fakta yang berlaku):
|
||||
- Kata vulgar anatomi (kontol, memek, tit, dick, dll) = pelanggaran berat, tanpa kecuali.
|
||||
- SARA / penistaan agama / parodi ayat / mockery tokoh agama / provokasi antar-agama = pelanggaran berat.
|
||||
- Promosi atau diskusi LGBT = pelanggaran berat (zero-tolerance).
|
||||
- Diskusi Israel/Palestina/Yahudi = pelanggaran berat (zero-tolerance).
|
||||
- Hinaan terarah ke orang (harassment), seksisme, ageisme, diskriminasi fisik = pelanggaran.
|
||||
- Konten seksual eksplisit / ajakan seksual / fetish / lolicon-shota = pelanggaran.
|
||||
- Judi, narkoba, scam, doxxing, ancaman kekerasan, self-harm, child safety, konten ilegal = pelanggaran.
|
||||
- Teknik evasi (zalgo, leetspeak, regional indicator, simbol acak) yang menyembunyikan kata terlarang = pelanggaran.
|
||||
- Spam berulang / promosi = pelanggaran ringan.
|
||||
- Memancing konflik (conflict instigation) = pelanggaran ringan.
|
||||
- Username ofensif saja (isi pesan bersih) = peringatan ringan, BUKAN hapus pesan.
|
||||
- Percakapan teknis/normal, slang santai (anjay, wkwk, gaskeun, njir), typo, panggilan akrab (bang, kak, dek), ekspresi religius normal (astaghfirullah, alhamdulillah), istilah anime (waifu, wibu), lirik/kutipan, makian ke benda mati = BUKAN pelanggaran.
|
||||
- Teks acak (kode, log, stack trace, output API, cuplikan UI) = BUKAN pelanggaran.
|
||||
- Setiap pesan dinilai dari isinya sendiri; konteks percakapan dapat memengaruhi interpretasi, bukan menggantikan isi.`;
|
||||
|
||||
/** Choice labels must stay in sync with `AIRecommendedAction` (moderation-types). */
|
||||
export const JEV_ACTIONS = [
|
||||
"none",
|
||||
"monitor",
|
||||
"warn",
|
||||
"review",
|
||||
"delete",
|
||||
"escalate",
|
||||
] as const;
|
||||
|
||||
/** Category choices — the moderation category vocabulary (kept tight). */
|
||||
export const JEV_CATEGORIES = [
|
||||
"none",
|
||||
"harassment",
|
||||
"hate_speech",
|
||||
"sara",
|
||||
"sexual_content",
|
||||
"vulgar_language",
|
||||
"sexual_deviation",
|
||||
"self_harm",
|
||||
"violence",
|
||||
"illegal_content",
|
||||
"gambling",
|
||||
"drugs",
|
||||
"scam",
|
||||
"spam",
|
||||
"conflict_instigation",
|
||||
"offensive_username",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export const JEV_STATUSES = ["clean", "warn", "flagged"] as const;
|
||||
export const JEV_SEVERITIES = [
|
||||
"none",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"critical",
|
||||
] as const;
|
||||
|
||||
/** Policy version stamped on every Jev verdict (cache/DB provenance). */
|
||||
export const JEV_POLICY_VERSION = "jev-systemone-2026-09-23";
|
||||
|
||||
/** Lazily-built SDK client (config resolves at first use). */
|
||||
let client: TypeSafeClient | null = null;
|
||||
let clientKey = "";
|
||||
|
||||
function getClient(): TypeSafeClient | null {
|
||||
if (!config.AI_LLM_JEV_API_KEY) return null;
|
||||
if (!client || clientKey !== config.AI_LLM_JEV_API_KEY) {
|
||||
client = new TypeSafeClient({
|
||||
apiKey: config.AI_LLM_JEV_API_KEY,
|
||||
baseURL: config.AI_LLM_JEV_BASE_URL,
|
||||
defaultModel: config.AI_LLM_JEV_MODEL,
|
||||
timeout: config.AI_LLM_JEV_TIMEOUT_MS,
|
||||
retry: { maxRetries: 0 }, // pipeline owns retries/abort
|
||||
logLevel: "warn",
|
||||
});
|
||||
clientKey = config.AI_LLM_JEV_API_KEY;
|
||||
log.info(
|
||||
{
|
||||
baseURL: config.AI_LLM_JEV_BASE_URL,
|
||||
model: config.AI_LLM_JEV_MODEL,
|
||||
},
|
||||
"Jev analyzer client initialized",
|
||||
);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Question + state builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface JevTarget {
|
||||
/** Message id — echoed verbatim into analysis/result. */
|
||||
id: string;
|
||||
/** Display name shown to the model (username). */
|
||||
user: string;
|
||||
/** Content to evaluate (truncated by caller). */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build per-message questions, keyed by the message id itself so the
|
||||
* questions read naturally against the declarative state facts
|
||||
* ("pesan <id> melanggar kebijakan server"). Five questions per message.
|
||||
*/
|
||||
export function buildJevQuestions(targets: JevTarget[]): Questions {
|
||||
const questions: Record<string, Question> = {};
|
||||
for (const t of targets) {
|
||||
const k = t.id;
|
||||
questions[`${k}__v`] = noul(
|
||||
`Pesan ${JSON.stringify(t.id)} dari ${JSON.stringify(t.user)} melanggar kebijakan server`,
|
||||
);
|
||||
questions[`${k}__status`] = choice(
|
||||
`Klasifikasi pesan ${JSON.stringify(t.id)} dari ${JSON.stringify(t.user)}`,
|
||||
{
|
||||
clean: "tidak melanggar kebijakan",
|
||||
warn: "pelanggaran ringan",
|
||||
flagged: "melanggar kebijakan",
|
||||
},
|
||||
);
|
||||
questions[`${k}__severity`] = choice(
|
||||
`Tingkat keparahan pelanggaran pesan ${JSON.stringify(t.id)}`,
|
||||
{
|
||||
none: "tidak ada pelanggaran",
|
||||
low: "ringan",
|
||||
medium: "sedang",
|
||||
high: "berat",
|
||||
critical: "kritis/darurat",
|
||||
},
|
||||
);
|
||||
questions[`${k}__category`] = choice(
|
||||
`Kategori utama pelanggaran pesan ${JSON.stringify(t.id)}`,
|
||||
Object.fromEntries(JEV_CATEGORIES.map((c) => [c, null])),
|
||||
);
|
||||
questions[`${k}__action`] = choice(
|
||||
`Tindakan moderasi yang tepat untuk pesan ${JSON.stringify(t.id)}`,
|
||||
{
|
||||
none: "tidak ada tindakan",
|
||||
monitor: "pantau",
|
||||
warn: "beri peringatan",
|
||||
review: "tinjau manual",
|
||||
delete: "hapus pesan",
|
||||
escalate: "eskalasi",
|
||||
},
|
||||
);
|
||||
}
|
||||
return questions as Questions;
|
||||
}
|
||||
|
||||
export interface JevBatchContext {
|
||||
/** Context block (location/conversation) as raw XML or prose — stripped to facts. */
|
||||
contextBlock: string;
|
||||
/** `<web_searches>` XML block (may be ""). */
|
||||
webSearchBlock: string;
|
||||
/** `<term_glossary>` XML block (may be ""). */
|
||||
glossaryBlock: string;
|
||||
/** Raw channel culture summary (may be undefined). */
|
||||
channelCulture?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip XML/HTML tags from a raw block and collapse whitespace so it can be
|
||||
* restated as plain factual prose in the declarative state. Empty after
|
||||
* stripping → omitted from the state.
|
||||
*/
|
||||
function stripToFacts(block: string, maxLength: number): string | null {
|
||||
const cleaned = block
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (!cleaned) return null;
|
||||
return JSON.stringify(cleaned.slice(0, maxLength));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the declarative `state` payload. NO chat/XML scaffolding — plain
|
||||
* factual statements (see the framing rule above; chat-style injection
|
||||
* makes Jev confidently wrong).
|
||||
*/
|
||||
export function buildJevState(
|
||||
targets: JevTarget[],
|
||||
ctx: JevBatchContext,
|
||||
correctedExamples = "",
|
||||
): string {
|
||||
const facts = targets.map(
|
||||
(t) =>
|
||||
`- Pesan ${JSON.stringify(t.id)} dari ${JSON.stringify(t.user)}: ${JSON.stringify(t.content)}`,
|
||||
);
|
||||
const parts = [
|
||||
`OBJEK PENILAIAN: ${targets.length} pesan dari server Discord.`,
|
||||
"PESAN:",
|
||||
...facts,
|
||||
JEV_POLICY,
|
||||
];
|
||||
|
||||
// Conversation/who context as facts (declarative, not instructions).
|
||||
const extraFacts: string[] = [];
|
||||
if (ctx.channelCulture) {
|
||||
extraFacts.push(
|
||||
`KULTUR CHANNEL (fakta): ${JSON.stringify(ctx.channelCulture.slice(0, 800))}`,
|
||||
);
|
||||
}
|
||||
const contextFacts = stripToFacts(ctx.contextBlock, 1200);
|
||||
if (contextFacts) extraFacts.push(`KONTEKS: ${contextFacts}`);
|
||||
const webSearchFacts = stripToFacts(ctx.webSearchBlock, 1500);
|
||||
if (webSearchFacts) extraFacts.push(`HASIL PENCARIAN WEB: ${webSearchFacts}`);
|
||||
const glossaryFacts = stripToFacts(ctx.glossaryBlock, 800);
|
||||
if (glossaryFacts) extraFacts.push(`GLOSARIUM: ${glossaryFacts}`);
|
||||
const correctionFacts = stripToFacts(correctedExamples, 800);
|
||||
if (correctionFacts)
|
||||
extraFacts.push(`KOREKSI SEBELUMNYA: ${correctionFacts}`);
|
||||
if (extraFacts.length > 0) parts.push(...extraFacts);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Acceptance gate + mapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Shape of the raw `answers` map returned by `systemOne`. */
|
||||
export type JevAnswers = Record<
|
||||
string,
|
||||
| { type: "noul"; noul: number }
|
||||
| {
|
||||
type: "choice";
|
||||
choice: string;
|
||||
confidence: number;
|
||||
probabilities?: Record<string, number>;
|
||||
}
|
||||
>;
|
||||
|
||||
/** Per-message answer subset (nullable until validated — `answersOf`). */
|
||||
interface JevMessageAnswers {
|
||||
v?: { type: "noul"; noul: number };
|
||||
status?: { type: "choice"; choice: string; confidence: number };
|
||||
severity?: { type: "choice"; choice: string };
|
||||
category?: { type: "choice"; choice: string };
|
||||
action?: { type: "choice"; choice: string };
|
||||
}
|
||||
|
||||
/** Reads the per-message answer subset by id, missing → undefined. */
|
||||
function answersOf(answers: JevAnswers, id: string): JevMessageAnswers {
|
||||
return {
|
||||
v: answers[`${id}__v`] as JevMessageAnswers["v"],
|
||||
status: answers[`${id}__status`] as JevMessageAnswers["status"],
|
||||
severity: answers[`${id}__severity`] as JevMessageAnswers["severity"],
|
||||
category: answers[`${id}__category`] as JevMessageAnswers["category"],
|
||||
action: answers[`${id}__action`] as JevMessageAnswers["action"],
|
||||
};
|
||||
}
|
||||
|
||||
/** Optionally-typed accessor for a choice answer's label ("" when missing). */
|
||||
function labelOf(a: { type: "choice"; choice: string } | undefined): string {
|
||||
return a?.type === "choice" ? a.choice : "";
|
||||
}
|
||||
|
||||
/** Set form of the vocab arrays for O(1) membership tests. */
|
||||
const JEV_STATUS_SET = new Set<string>(JEV_STATUSES);
|
||||
const JEV_SEVERITY_SET = new Set<string>(JEV_SEVERITIES);
|
||||
const JEV_CATEGORY_SET = new Set<string>(JEV_CATEGORIES);
|
||||
const JEV_ACTION_SET = new Set<string>(JEV_ACTIONS);
|
||||
|
||||
/**
|
||||
* Decide per-message Jev acceptance. Requires ALL five questions present
|
||||
* with valid labels and CROSS-CONSISTENT semantics:
|
||||
* - status choice confidence >= threshold
|
||||
* - status == clean ⟺ noul < 0.5 (flagged/warn need noul ≥ 0.5)
|
||||
* - severity == none ⟺ status == clean (flagged must have severity)
|
||||
* - action == none ⟺ status == clean; warn must not delete/escalate;
|
||||
* clean must never delete/escalate
|
||||
* - category == none ⟺ status == clean
|
||||
* Anything else → LLM fallback (fail-open).
|
||||
*/
|
||||
export function isJevAccepted(
|
||||
answers: JevAnswers,
|
||||
messageId: string,
|
||||
minConfidence: number,
|
||||
): boolean {
|
||||
const a = answersOf(answers, messageId);
|
||||
if (!a.v || a.v.type !== "noul" || typeof a.v.noul !== "number") return false;
|
||||
if (!a.status || a.status.type !== "choice" || !a.status.choice) return false;
|
||||
if (!a.severity || a.severity.type !== "choice" || !a.severity.choice)
|
||||
return false;
|
||||
if (!a.category || a.category.type !== "choice" || !a.category.choice)
|
||||
return false;
|
||||
if (!a.action || a.action.type !== "choice" || !a.action.choice) return false;
|
||||
|
||||
const { status, severity, category, action } = a;
|
||||
if (
|
||||
typeof status.confidence !== "number" ||
|
||||
status.confidence < minConfidence
|
||||
)
|
||||
return false;
|
||||
|
||||
// Valid label = one of the vocab const arrays. The membership guards
|
||||
// (Set.has) reject anything unknown, then the labels are narrowed via the
|
||||
// const-array includes so the downstream comparisons typecheck.
|
||||
const statusLabel = labelOf(status);
|
||||
const severityLabel = labelOf(severity);
|
||||
const categoryLabel = labelOf(category);
|
||||
const actionLabel = labelOf(action);
|
||||
if (
|
||||
!JEV_STATUS_SET.has(statusLabel) ||
|
||||
!JEV_SEVERITY_SET.has(severityLabel) ||
|
||||
!JEV_CATEGORY_SET.has(categoryLabel) ||
|
||||
!JEV_ACTION_SET.has(actionLabel)
|
||||
)
|
||||
return false; // unknown label — LLM fallback
|
||||
|
||||
const s = statusLabel as (typeof JEV_STATUSES)[number];
|
||||
const sev = severityLabel as (typeof JEV_SEVERITIES)[number];
|
||||
const cat = categoryLabel as (typeof JEV_CATEGORIES)[number];
|
||||
const act = actionLabel as (typeof JEV_ACTIONS)[number];
|
||||
|
||||
const noulVal = a.v.noul;
|
||||
|
||||
// noul ↔ status consistency
|
||||
if (s === "clean" && noulVal >= 0.5) return false;
|
||||
if (s !== "clean" && noulVal < 0.5) return false;
|
||||
// severity ↔ status: clean must be none; flagged/warn must NOT be none
|
||||
if (s === "clean" && sev !== "none") return false;
|
||||
if (s !== "clean" && sev === "none") return false;
|
||||
// action ↔ status: clean must be none; flagged must NOT be none;
|
||||
// warn must not delete/escalate; clean must never delete/escalate
|
||||
if (s === "clean" && act !== "none") return false;
|
||||
if (s === "flagged" && act === "none") return false;
|
||||
if (s === "warn" && (act === "delete" || act === "escalate")) return false;
|
||||
// category ↔ status: clean must be none; flagged must NOT be none
|
||||
if (s === "clean" && cat !== "none") return false;
|
||||
if (s === "flagged" && cat === "none") return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map accepted Jev answers for one message into the pipeline's `AnalysisResult`.
|
||||
* All values are derived from the model's own typed answers — no fabrication.
|
||||
*/
|
||||
export function mapJevAnswersToResult(
|
||||
answers: JevAnswers,
|
||||
messageId: string,
|
||||
): AnalysisResult {
|
||||
const a = answersOf(answers, messageId);
|
||||
const v = a.v as { type: "noul"; noul: number };
|
||||
const st = a.status as {
|
||||
type: "choice";
|
||||
choice: string;
|
||||
confidence: number;
|
||||
};
|
||||
const sev = labelOf(a.severity);
|
||||
const cat = labelOf(a.category);
|
||||
const act = labelOf(a.action);
|
||||
|
||||
const status = st.choice as (typeof JEV_STATUSES)[number];
|
||||
// Calibrated score: clean → 0; warn → 0.45; flagged → P(violates) clamped.
|
||||
const rawNoul = typeof v.noul === "number" ? v.noul : 0;
|
||||
const score =
|
||||
status === "clean"
|
||||
? 0
|
||||
: status === "warn"
|
||||
? 0.45
|
||||
: Math.min(1, Math.max(0.7, rawNoul));
|
||||
const confidence =
|
||||
typeof st.confidence === "number"
|
||||
? st.confidence
|
||||
: config.AI_LLM_JEV_MIN_CONFIDENCE;
|
||||
|
||||
return {
|
||||
messageId,
|
||||
status,
|
||||
flags: cat === "none" ? [] : [cat],
|
||||
score,
|
||||
analysis:
|
||||
`[Jev] status=${status}, kategori=${cat}, keparahan=${sev}, ` +
|
||||
`keyakinan=${confidence.toFixed(2)}, tindakan=${act}, p_melanggar=${rawNoul.toFixed(2)}`,
|
||||
categories: cat === "none" ? [] : [cat],
|
||||
severity: sev as AnalysisResult["severity"],
|
||||
confidence,
|
||||
recommendedAction: act as AnalysisResult["recommendedAction"],
|
||||
policyVersion: JEV_POLICY_VERSION,
|
||||
evidence: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch entry point (one systemOne call per sub-batch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface JevBatchOutcome {
|
||||
/** Accepted Jev verdicts (keyed by message id). */
|
||||
results: AnalysisResult[];
|
||||
/** Answers the gate rejected for ANY reason (keys = message ids). */
|
||||
rejectedIds: string[];
|
||||
/** Raw `SystemOneResult` (for usage logging / raw passthrough). */
|
||||
raw: unknown;
|
||||
/** Error thrown by the call, if the whole call failed (null = success). */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** True when Jev is configured and enabled (fail-open wrapper). */
|
||||
export function isJevEnabled(): boolean {
|
||||
return (
|
||||
config.AI_LLM_JEV_ENABLED === true && Boolean(config.AI_LLM_JEV_API_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze one text sub-batch with Jev. NEVER throws for API-level failures —
|
||||
* returns `{ error }` so the caller falls back to the LLM. Aborts (signal)
|
||||
* propagate as errors too (the caller's timeout must abort the SDK call and
|
||||
* fall back, not hang).
|
||||
*/
|
||||
export async function analyzeBatchWithJev(
|
||||
targets: JevTarget[],
|
||||
ctx: JevBatchContext,
|
||||
signal?: AbortSignal,
|
||||
correctedExamples = "",
|
||||
): Promise<JevBatchOutcome> {
|
||||
const outcome: JevBatchOutcome = {
|
||||
results: [],
|
||||
rejectedIds: [],
|
||||
raw: null,
|
||||
error: null,
|
||||
};
|
||||
if (targets.length === 0) return outcome;
|
||||
|
||||
const jevClient = getClient();
|
||||
if (!jevClient) {
|
||||
outcome.error = "Jev client unavailable (no API key)";
|
||||
return outcome;
|
||||
}
|
||||
|
||||
try {
|
||||
const questions = buildJevQuestions(targets);
|
||||
|
||||
// CONCURRENCY from the skill: one ownership layer owns retries — the SDK
|
||||
// gets retry: { maxRetries: 0 } and the pipeline's timeout/abort layer is
|
||||
// the only retry. The call is wrapped in withLlmConcurrency so Jev down
|
||||
// can't flood the router.
|
||||
const { withLlmConcurrency } = await import("./llmClient.js");
|
||||
const systemOneResult = await withLlmConcurrency(async () => {
|
||||
return await jevClient.systemOne(
|
||||
{
|
||||
model: config.AI_LLM_JEV_MODEL,
|
||||
state: buildJevState(targets, ctx, correctedExamples),
|
||||
questions,
|
||||
},
|
||||
{ signal, timeout: config.AI_LLM_JEV_TIMEOUT_MS },
|
||||
);
|
||||
});
|
||||
|
||||
outcome.raw = systemOneResult;
|
||||
const answers = systemOneResult.answers as unknown as JevAnswers;
|
||||
const minConfidence = config.AI_LLM_JEV_MIN_CONFIDENCE;
|
||||
|
||||
for (const t of targets) {
|
||||
if (isJevAccepted(answers, t.id, minConfidence)) {
|
||||
outcome.results.push(mapJevAnswersToResult(answers, t.id));
|
||||
incrementCounterBy("moderation_jev_decisions", 1, { type: "jev" });
|
||||
} else {
|
||||
outcome.rejectedIds.push(t.id);
|
||||
incrementCounterBy("moderation_jev_decisions", 1, {
|
||||
type: "llm_fallback",
|
||||
});
|
||||
log.debug(
|
||||
{ messageId: t.id, minConfidence },
|
||||
"Jev decision rejected — falling back to LLM",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Usage accounting (same counters as the LLM path).
|
||||
const usage = systemOneResult.usage;
|
||||
if (usage?.input_tokens || usage?.output_tokens) {
|
||||
if (usage.input_tokens) {
|
||||
incrementCounterBy("llm_tokens_total", usage.input_tokens, {
|
||||
model: config.AI_LLM_JEV_MODEL,
|
||||
type: "prompt",
|
||||
label: "jev-batch",
|
||||
});
|
||||
}
|
||||
if (usage.output_tokens) {
|
||||
incrementCounterBy("llm_tokens_total", usage.output_tokens, {
|
||||
model: config.AI_LLM_JEV_MODEL,
|
||||
type: "completion",
|
||||
label: "jev-batch",
|
||||
});
|
||||
}
|
||||
log.info(
|
||||
{
|
||||
targetCount: targets.length,
|
||||
accepted: outcome.results.length,
|
||||
rejected: outcome.rejectedIds.length,
|
||||
model: config.AI_LLM_JEV_MODEL,
|
||||
input_tokens: usage.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
},
|
||||
"Jev systemone batch usage",
|
||||
);
|
||||
}
|
||||
|
||||
return outcome;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "APIUserAbortError") throw err; // real abort — let caller decide
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
outcome.error = msg;
|
||||
log.warn(
|
||||
{ error: msg, targetCount: targets.length },
|
||||
"Jev systemone call failed — falling back to LLM for the whole batch",
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,6 @@ import type {
|
||||
} from "../message-capture/types.js";
|
||||
import { getChannelCulture } from "./channelCultureStore.js";
|
||||
import { estimateTokens } from "./conversationContext.js";
|
||||
import {
|
||||
analyzeBatchWithJev,
|
||||
isJevEnabled,
|
||||
JEV_POLICY_VERSION,
|
||||
type JevTarget,
|
||||
} from "./jevAnalyzer.js";
|
||||
import type { ModerationPromptContent, RetryState } from "./llmCaller.js";
|
||||
import { callModerationLLM } from "./llmCaller.js";
|
||||
import { analyzeSingleMediaImage } from "./mediaAnalysisClient.js";
|
||||
@@ -58,14 +52,14 @@ interface UrlFetchResult {
|
||||
title: Map<string, string>;
|
||||
}
|
||||
|
||||
/** Provider-reported token usage from a raw LLM/Jev payload (may be absent). */
|
||||
/** Provider-reported token usage from a raw LLM payload (may be absent). */
|
||||
interface TokenUsage {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
/** Read provider-reported token usage from either the LLM or Jev raw payload. */
|
||||
/** Read provider-reported token usage from the raw LLM payload. */
|
||||
function extractUsage(raw: unknown): TokenUsage | undefined {
|
||||
return (raw as { usage?: TokenUsage } | null)?.usage ?? undefined;
|
||||
}
|
||||
@@ -419,7 +413,7 @@ export async function runTextOnlyBatch(
|
||||
results: [],
|
||||
raw: null,
|
||||
};
|
||||
// Per-sub-batch verdicts before fan-out (Jev + LLM fallback merged).
|
||||
// Per-sub-batch verdicts before fan-out.
|
||||
let subBatchResults: AnalysisResult[] = [];
|
||||
try {
|
||||
// Output budget scales with the prompt: the JSON verdict block is
|
||||
@@ -440,78 +434,14 @@ export async function runTextOnlyBatch(
|
||||
Math.max(2048, Math.ceil(subBatchPromptEstimate * 1.5)),
|
||||
);
|
||||
|
||||
// ── Jev-first (TypeSafe System One) with LLM fallback ────────────────
|
||||
// Jev is the PRIMARY text analyzer: ONE systemOne call per sub-batch
|
||||
// (5 typed questions × N messages, evaluated in parallel by Jev).
|
||||
// Verdicts that pass the acceptance gate are used directly; anything
|
||||
// Jev rejects (low confidence / inconsistent) and any Jev API failure
|
||||
// falls back to the existing LLM call — fail-open, never dead.
|
||||
if (isJevEnabled()) {
|
||||
const jevTargets: JevTarget[] = batch.map((msg) => ({
|
||||
id: msg.id,
|
||||
user: resolveDisplayName(msg),
|
||||
content: analysisContentOf(msg),
|
||||
}));
|
||||
const jevOutcome = await analyzeBatchWithJev(
|
||||
jevTargets,
|
||||
{
|
||||
contextBlock,
|
||||
webSearchBlock: buildWebSearchBlock(webSearchResults),
|
||||
glossaryBlock,
|
||||
channelCulture: channelCultureObj?.culture_summary,
|
||||
},
|
||||
abortController.signal,
|
||||
correctedExamples,
|
||||
);
|
||||
|
||||
subBatchResults.push(...jevOutcome.results);
|
||||
if (jevOutcome.results.length > 0) {
|
||||
log.info(
|
||||
{
|
||||
subBatch: i + 1,
|
||||
accepted: jevOutcome.results.length,
|
||||
rejected: jevOutcome.rejectedIds.length,
|
||||
},
|
||||
"Jev analyzed sub-batch — accepted verdicts kept, rejected go to LLM",
|
||||
);
|
||||
}
|
||||
|
||||
// Which targets still need the LLM?
|
||||
const coveredIds = new Set(subBatchResults.map((r) => r.messageId));
|
||||
const llmTargets = batch.filter((m) => !coveredIds.has(m.id));
|
||||
|
||||
if (llmTargets.length > 0) {
|
||||
const llmResult = await callModerationLLM(
|
||||
(state) => buildContent(state, llmTargets),
|
||||
llmTargets.map((m) => m.id),
|
||||
`text-batch-${i + 1}-jev-fallback`,
|
||||
abortController.signal,
|
||||
dynamicMaxTokens,
|
||||
);
|
||||
subBatchResults.push(...llmResult.results);
|
||||
batchResult = llmResult;
|
||||
logModerationAnalysis(
|
||||
llmTargets.map((m) => m.id),
|
||||
config.AI_LLM_MODEL,
|
||||
llmResult.results,
|
||||
0,
|
||||
extractUsage(llmResult.raw),
|
||||
);
|
||||
} else {
|
||||
// Jev accepted everything — no LLM usage to attribute.
|
||||
batchResult = { results: subBatchResults, raw: null };
|
||||
}
|
||||
} else {
|
||||
// Jev disabled / unconfigured — pure LLM path (unchanged).
|
||||
batchResult = await callModerationLLM(
|
||||
buildContent,
|
||||
targetIds,
|
||||
`text-batch-${i + 1}`,
|
||||
abortController.signal,
|
||||
dynamicMaxTokens,
|
||||
);
|
||||
subBatchResults = batchResult.results;
|
||||
}
|
||||
batchResult = await callModerationLLM(
|
||||
buildContent,
|
||||
targetIds,
|
||||
`text-batch-${i + 1}`,
|
||||
abortController.signal,
|
||||
dynamicMaxTokens,
|
||||
);
|
||||
subBatchResults = batchResult.results;
|
||||
} catch (err: unknown) {
|
||||
const isAbort =
|
||||
(err instanceof Error && err.name === "AbortError") ||
|
||||
@@ -530,8 +460,7 @@ export async function runTextOnlyBatch(
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
// Fan-out results for deduplicated messages (applies to Jev + LLM
|
||||
// verdicts alike — they only consume AnalysisResult[]).
|
||||
// Fan-out results for deduplicated messages.
|
||||
const fannedOutResults =
|
||||
groupMapping.size > 0
|
||||
? subBatchResults.flatMap((result) => {
|
||||
@@ -548,9 +477,7 @@ export async function runTextOnlyBatch(
|
||||
if (subBatchResults.length > 0) {
|
||||
logModerationAnalysis(
|
||||
targetIds,
|
||||
subBatchResults.every((r) => r.policyVersion === JEV_POLICY_VERSION)
|
||||
? config.AI_LLM_JEV_MODEL
|
||||
: config.AI_LLM_MODEL,
|
||||
config.AI_LLM_MODEL,
|
||||
subBatchResults,
|
||||
0,
|
||||
extractUsage(batchResult.raw),
|
||||
|
||||
@@ -158,23 +158,6 @@ export const configSchema = z
|
||||
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
||||
// multimodal endpoint was removed.
|
||||
AI_LLM_VISION_MODEL: z.string().default("multimodal"),
|
||||
// ── Jev (TypeSafe System One) — primary text analyzer ────────────────
|
||||
// Jev evaluates typed questions against a state and returns calibrated
|
||||
// structured answers (no text generation). Runs on 9router's
|
||||
// /v1/systemone (free model oc/jev-1.13-free). The existing LLM remains
|
||||
// the fallback for anything Jev cannot decide confidently and for media.
|
||||
AI_LLM_JEV_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
AI_LLM_JEV_API_KEY: z.string().optional().default(""),
|
||||
AI_LLM_JEV_BASE_URL: z.string().url().default("http://127.0.0.1:4014"),
|
||||
AI_LLM_JEV_MODEL: z.string().default("oc/jev-1.13-free"),
|
||||
// Per-message acceptance threshold on the status choice confidence.
|
||||
// Below this the message falls back to the LLM (fail-open).
|
||||
AI_LLM_JEV_MIN_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.9),
|
||||
AI_LLM_JEV_TIMEOUT_MS: z.coerce.number().int().positive().default(45_000),
|
||||
AI_LLM_DISABLE_THINKING: z
|
||||
.string()
|
||||
.default("true")
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
import type { AnalysisResult } from "../src/modules/message-capture/types.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 (typed by the real pipeline shape)
|
||||
const verdicts = outcome.results as AnalysisResult[];
|
||||
for (const r of verdicts) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,382 +0,0 @@
|
||||
/**
|
||||
* jevAnalyzer.test.ts — pure unit tests for the Jev analyzer (no network).
|
||||
*
|
||||
* Tests the question builder, state builder, acceptance gate, and answer
|
||||
* mapper against hand-crafted `JevAnswers` objects (the shape `systemOne`
|
||||
* returns). `analyzeBatchWithJev`'s network path is exercised separately
|
||||
* by the live smoke harness (no gateway restart).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildJevQuestions,
|
||||
buildJevState,
|
||||
isJevAccepted,
|
||||
JEV_CATEGORIES,
|
||||
JEV_POLICY_VERSION,
|
||||
type JevAnswers,
|
||||
mapJevAnswersToResult,
|
||||
} from "../src/modules/ai-moderation/jevAnalyzer.js";
|
||||
|
||||
const MID = "m1";
|
||||
|
||||
const cleanAnswers: JevAnswers = {
|
||||
[`${MID}__v`]: { type: "noul", noul: 0.03 },
|
||||
[`${MID}__status`]: { type: "choice", choice: "clean", confidence: 0.99 },
|
||||
[`${MID}__severity`]: { type: "choice", choice: "none" },
|
||||
[`${MID}__category`]: { type: "choice", choice: "none" },
|
||||
[`${MID}__action`]: { type: "choice", choice: "none" },
|
||||
};
|
||||
|
||||
const flaggedAnswers: JevAnswers = {
|
||||
...cleanAnswers,
|
||||
[`${MID}__v`]: { type: "noul", noul: 0.98 },
|
||||
[`${MID}__status`]: { type: "choice", choice: "flagged", confidence: 0.99 },
|
||||
[`${MID}__severity`]: { type: "choice", choice: "high" },
|
||||
[`${MID}__category`]: { type: "choice", choice: "vulgar_language" },
|
||||
[`${MID}__action`]: { type: "choice", choice: "delete" },
|
||||
};
|
||||
|
||||
function answersFor(overrides: Partial<JevAnswers>): JevAnswers {
|
||||
return { ...cleanAnswers, ...overrides };
|
||||
}
|
||||
|
||||
describe("buildJevQuestions", () => {
|
||||
it("emits 5 id-keyed questions per target", () => {
|
||||
const q = buildJevQuestions([
|
||||
{ id: "a", user: "alice", content: "hi" },
|
||||
{ id: "b", user: "bob", content: "hey" },
|
||||
]);
|
||||
const keys = Object.keys(q);
|
||||
expect(keys).toHaveLength(10);
|
||||
expect(keys.sort()).toEqual(
|
||||
[
|
||||
"a__v",
|
||||
"a__status",
|
||||
"a__severity",
|
||||
"a__category",
|
||||
"a__action",
|
||||
"b__v",
|
||||
"b__status",
|
||||
"b__severity",
|
||||
"b__category",
|
||||
"b__action",
|
||||
].sort(),
|
||||
);
|
||||
for (const k of keys) {
|
||||
if (k.endsWith("__v")) expect(q[k].type).toBe("noul");
|
||||
else expect(q[k].type).toBe("choice");
|
||||
}
|
||||
// status question names the message id (not an index)
|
||||
expect(
|
||||
(q["a__status"] as { instructions?: string }).instructions,
|
||||
).toContain("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildJevState", () => {
|
||||
it("includes the distilled policy + messages + stripped context facts", () => {
|
||||
const state = buildJevState(
|
||||
[
|
||||
{ id: "m1", user: "alice", content: "kontol" },
|
||||
{ id: "m2", user: "bob", content: "halo <b>bro</b>" },
|
||||
],
|
||||
{
|
||||
contextBlock:
|
||||
"<location_context channel_id='1'/>\nLokasi: channel umum ramai.",
|
||||
webSearchBlock: "<web_searches/>\nHasil: tidak ada.",
|
||||
glossaryBlock: "<term_glossary/>\nIstilah: none.",
|
||||
channelCulture: "channel santai",
|
||||
},
|
||||
"## contoh koreksi",
|
||||
);
|
||||
expect(state).toContain("KEBIJAKAN SERVER");
|
||||
expect(state).toContain('- Pesan "m1" dari "alice": "kontol"');
|
||||
expect(state).toContain("KULTUR CHANNEL");
|
||||
expect(state).toContain("KONTEKS:");
|
||||
expect(state).toContain("HASIL PENCARIAN WEB:");
|
||||
expect(state).toContain("GLOSARIUM:");
|
||||
expect(state).toContain("KOREKSI SEBELUMNYA");
|
||||
// NO chat-scaffolding artifacts (this is the declarative contract)
|
||||
expect(state).not.toContain("<messages_to_analyze>");
|
||||
expect(state).not.toContain("<location_context");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isJevAccepted", () => {
|
||||
it("accepts a clean verdict with high confidence and noul<0.5", () => {
|
||||
expect(isJevAccepted(cleanAnswers, MID, 0.9)).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a flagged verdict with noul>=0.5", () => {
|
||||
expect(isJevAccepted(flaggedAnswers, MID, 0.9)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects low-confidence status", () => {
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__status`]: {
|
||||
type: "choice",
|
||||
choice: "clean",
|
||||
confidence: 0.5,
|
||||
},
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects contradictory clean-with-high-noul", () => {
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({ [`${MID}__v`]: { type: "noul", noul: 0.95 } }),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects flagged-with-low-noul", () => {
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__v`]: { type: "noul", noul: 0.1 },
|
||||
[`${MID}__status`]: {
|
||||
type: "choice",
|
||||
choice: "flagged",
|
||||
confidence: 0.99,
|
||||
},
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects clean with non-none severity/category/action", () => {
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__severity`]: { type: "choice", choice: "low" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__category`]: { type: "choice", choice: "spam" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__action`]: { type: "choice", choice: "monitor" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects flagged with none severity/category/action", () => {
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__v`]: { type: "noul", noul: 0.9 },
|
||||
[`${MID}__status`]: {
|
||||
type: "choice",
|
||||
choice: "flagged",
|
||||
confidence: 0.99,
|
||||
},
|
||||
[`${MID}__severity`]: { type: "choice", choice: "none" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__v`]: { type: "noul", noul: 0.9 },
|
||||
[`${MID}__status`]: {
|
||||
type: "choice",
|
||||
choice: "flagged",
|
||||
confidence: 0.99,
|
||||
},
|
||||
[`${MID}__category`]: { type: "choice", choice: "none" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__v`]: { type: "noul", noul: 0.9 },
|
||||
[`${MID}__status`]: {
|
||||
type: "choice",
|
||||
choice: "flagged",
|
||||
confidence: 0.99,
|
||||
},
|
||||
[`${MID}__action`]: { type: "choice", choice: "none" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects warn with delete/escalate action", () => {
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__v`]: { type: "noul", noul: 0.6 },
|
||||
[`${MID}__status`]: {
|
||||
type: "choice",
|
||||
choice: "warn",
|
||||
confidence: 0.92,
|
||||
},
|
||||
[`${MID}__severity`]: { type: "choice", choice: "low" },
|
||||
[`${MID}__category`]: { type: "choice", choice: "spam" },
|
||||
[`${MID}__action`]: { type: "choice", choice: "delete" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown status/severity/category/action labels", () => {
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__status`]: {
|
||||
type: "choice",
|
||||
choice: "banned",
|
||||
confidence: 0.99,
|
||||
},
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__severity`]: { type: "choice", choice: "severe" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__category`]: { type: "choice", choice: "doxxing" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isJevAccepted(
|
||||
answersFor({
|
||||
[`${MID}__action`]: { type: "choice", choice: "destroy" },
|
||||
}),
|
||||
MID,
|
||||
0.9,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects missing questions", () => {
|
||||
const { [`${MID}__action`]: _drop, ...partial } = answersFor({});
|
||||
expect(isJevAccepted(partial as JevAnswers, MID, 0.9)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapJevAnswersToResult", () => {
|
||||
it("maps a clean answer to a zero-score analysis result", () => {
|
||||
const r = mapJevAnswersToResult(cleanAnswers, MID);
|
||||
expect(r).toMatchObject({
|
||||
messageId: MID,
|
||||
status: "clean",
|
||||
flags: [],
|
||||
score: 0,
|
||||
categories: [],
|
||||
severity: "none",
|
||||
recommendedAction: "none",
|
||||
policyVersion: JEV_POLICY_VERSION,
|
||||
confidence: 0.99,
|
||||
});
|
||||
expect(r.analysis).toContain("status=clean");
|
||||
expect(r.analysis).toContain("[Jev]");
|
||||
});
|
||||
|
||||
it("maps a flagged answer with calibrated score + category flag", () => {
|
||||
const r = mapJevAnswersToResult(flaggedAnswers, MID);
|
||||
expect(r.status).toBe("flagged");
|
||||
expect(r.flags).toEqual(["vulgar_language"]);
|
||||
expect(r.categories).toEqual(["vulgar_language"]);
|
||||
expect(r.severity).toBe("high");
|
||||
expect(r.recommendedAction).toBe("delete");
|
||||
expect(r.score).toBeGreaterThanOrEqual(0.9); // clamped to >=0.7, noul 0.98
|
||||
expect(r.analysis).toContain("p_melanggar=0.98");
|
||||
expect(r.evidence).toEqual([]);
|
||||
});
|
||||
|
||||
it("warns become score 0.45 with no flags", () => {
|
||||
const r = mapJevAnswersToResult(
|
||||
answersFor({
|
||||
[`${MID}__status`]: {
|
||||
type: "choice",
|
||||
choice: "warn",
|
||||
confidence: 0.92,
|
||||
},
|
||||
[`${MID}__severity`]: { type: "choice", choice: "low" },
|
||||
[`${MID}__category`]: {
|
||||
type: "choice",
|
||||
choice: "conflict_instigation",
|
||||
},
|
||||
[`${MID}__action`]: { type: "choice", choice: "warn" },
|
||||
[`${MID}__v`]: { type: "noul", noul: 0.6 },
|
||||
}),
|
||||
MID,
|
||||
);
|
||||
expect(r.status).toBe("warn");
|
||||
expect(r.score).toBe(0.45);
|
||||
expect(r.flags).toEqual(["conflict_instigation"]);
|
||||
expect(r.recommendedAction).toBe("warn");
|
||||
});
|
||||
|
||||
it("every category label in the vocab is accepted (no unknown rejection)", () => {
|
||||
// A self-consistent FLAGGED base (flagged needs non-none severity/action).
|
||||
const flaggedBase = {
|
||||
[`${MID}__v`]: { type: "noul", noul: 0.9 },
|
||||
[`${MID}__status`]: {
|
||||
type: "choice",
|
||||
choice: "flagged",
|
||||
confidence: 0.99,
|
||||
},
|
||||
[`${MID}__severity`]: { type: "choice", choice: "medium" },
|
||||
[`${MID}__category`]: { type: "choice", choice: "none" },
|
||||
[`${MID}__action`]: { type: "choice", choice: "monitor" },
|
||||
} satisfies JevAnswers;
|
||||
for (const c of JEV_CATEGORIES) {
|
||||
// "none" is only valid with clean status (flagged+none is contradictory
|
||||
// and MUST be rejected — covered by the cross-consistency tests above).
|
||||
if (c === "none") continue;
|
||||
const answers = {
|
||||
...flaggedBase,
|
||||
[`${MID}__category`]: { type: "choice", choice: c },
|
||||
} satisfies JevAnswers;
|
||||
expect(isJevAccepted(answers, MID, 0.9)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ChevronRight,
|
||||
Hash,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { AreaActivity, RadialGauge } from "@/components/charts";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
@@ -29,7 +28,7 @@ import {
|
||||
} from "@/components/shared";
|
||||
import { useActivity, useStats, useTopReactions } from "@/hooks";
|
||||
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
||||
import type { DashboardStats } from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
|
||||
@@ -66,6 +65,7 @@ export function DashboardView({
|
||||
} = useStats(initialStats);
|
||||
const { data: activity } = useActivity(14, initialActivity);
|
||||
const { data: reactions } = useTopReactions();
|
||||
const [showAllReactions, setShowAllReactions] = useState(false);
|
||||
const ambient = useAmbient();
|
||||
|
||||
const hudRef = useStaggerReveal<HTMLDivElement>(".linear-tile", {
|
||||
@@ -160,12 +160,6 @@ export function DashboardView({
|
||||
hint={`${formatNumber(s.total_users)} total profiles`}
|
||||
icon={<Users className="size-4" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Voice Recordings"
|
||||
value={formatNumber(s.total_voice_recordings)}
|
||||
hint="Archived audio sessions"
|
||||
icon={<Mic className="size-4" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Mod Queue"
|
||||
value={formatNumber(mod.pending + mod.processing)}
|
||||
@@ -366,44 +360,64 @@ export function DashboardView({
|
||||
<SectionHeader
|
||||
eyebrow="Engagement"
|
||||
title="Top Reacted Messages"
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllReactions((v) => !v)}
|
||||
className="font-mono text-[10px] text-ink-muted transition-colors hover:text-ink"
|
||||
>
|
||||
{showAllReactions
|
||||
? "SHOW TOP 4"
|
||||
: `VIEW ALL ${reactions.length}`}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="mt-3 space-y-2">
|
||||
{reactions.slice(0, 4).map((r) => (
|
||||
<div
|
||||
key={r.message_id}
|
||||
className="hud-card flex items-start justify-between gap-2 p-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="line-clamp-1 block text-xs text-ink-soft">
|
||||
{r.content || "[Media/Attachment]"}
|
||||
</span>
|
||||
{r.username && (
|
||||
<span className="mt-0.5 block font-mono text-[10px] text-ink-muted">
|
||||
@{r.username}
|
||||
{r.channel_name && ` in #${r.channel_name}`}
|
||||
{(showAllReactions ? reactions : reactions.slice(0, 4)).map(
|
||||
(r) => (
|
||||
<div
|
||||
key={r.message_id}
|
||||
className="hud-card flex items-start justify-between gap-2 p-2.5"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="line-clamp-1 block text-xs text-ink-soft">
|
||||
{r.content || "[Media/Attachment]"}
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 block font-mono text-[10px] text-ink-muted">
|
||||
@{r.username || "unknown"}
|
||||
{r.channel_name && ` in #${r.channel_name}`}
|
||||
{r.created_at != null && (
|
||||
<>
|
||||
{" "}
|
||||
·{" "}
|
||||
<span suppressHydrationWarning>
|
||||
{formatRelativeTime(r.created_at)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<span className="font-mono text-xs font-semibold text-signal">
|
||||
{formatNumber(r.reaction_count)}
|
||||
</span>
|
||||
{r.top_emojis && r.top_emojis.length > 0 && (
|
||||
<div className="mt-0.5 flex gap-0.5">
|
||||
{r.top_emojis.slice(0, 2).map((e) => (
|
||||
<span
|
||||
key={e.emoji}
|
||||
className="text-[10px]"
|
||||
title={`${e.emoji} ×${e.count}`}
|
||||
>
|
||||
{e.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<span className="font-mono text-xs font-semibold text-signal">
|
||||
{formatNumber(r.reaction_count)}
|
||||
</span>
|
||||
{r.top_emojis && r.top_emojis.length > 0 && (
|
||||
<div className="mt-0.5 flex gap-0.5">
|
||||
{r.top_emojis.map((e) => (
|
||||
<span
|
||||
key={e.emoji}
|
||||
className="text-[10px]"
|
||||
title={`${e.emoji} ×${e.count}`}
|
||||
>
|
||||
{e.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
)}
|
||||
|
||||
@@ -567,11 +567,6 @@
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@keyframes eq {
|
||||
0%, 100% { transform: scaleY(0.35); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
@keyframes fade-up {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
@@ -634,12 +629,6 @@
|
||||
animation: shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Live equalizer bars (mini-player / recording player). */
|
||||
.animate-eq {
|
||||
transform-origin: bottom;
|
||||
animation: eq 0.9s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Entrance for popovers, toasts, mini-player. */
|
||||
.animate-fade-up { animation: fade-up 0.18s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||
.animate-toast-in { animation: toast-in 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
|
||||
@@ -694,7 +683,6 @@
|
||||
.animate-spin-disc,
|
||||
.animate-pulse-ring,
|
||||
.animate-shimmer,
|
||||
.animate-eq,
|
||||
.animate-fade-up,
|
||||
.animate-toast-in,
|
||||
.animate-stagger,
|
||||
|
||||
@@ -25,8 +25,7 @@ const bricolage = Bricolage_Grotesque({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "GMW — Discord Moderation Console",
|
||||
description:
|
||||
"AI-powered Discord moderation, voice monitoring, and media control console",
|
||||
description: "AI-powered Discord moderation console",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
||||
@@ -49,6 +49,15 @@ function severityTone(
|
||||
}
|
||||
}
|
||||
|
||||
function confidenceTone(confidence?: number | null) {
|
||||
if (confidence == null) return null;
|
||||
return confidence >= 0.75
|
||||
? "bg-signal"
|
||||
: confidence >= 0.5
|
||||
? "bg-amber"
|
||||
: "bg-vermilion";
|
||||
}
|
||||
|
||||
export function LiveModerationFeed({
|
||||
actions,
|
||||
}: {
|
||||
@@ -115,8 +124,27 @@ export function LiveModerationFeed({
|
||||
className="mod-feed-item hud-card animate-stagger flex items-start gap-3 p-3 transition-all"
|
||||
style={staggerDelay(i)}
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-[6px] border border-hairline bg-surface-2">
|
||||
<span
|
||||
className={`relative flex size-7 shrink-0 items-center justify-center rounded-[6px] border ${
|
||||
a.status === "failed"
|
||||
? "border-vermilion/40 bg-vermilion/10"
|
||||
: a.status === "pending"
|
||||
? "border-amber/40 bg-amber/10"
|
||||
: "border-hairline bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
{actionIcon(a.action_type)}
|
||||
{a.status && (
|
||||
<span
|
||||
className={`absolute -right-0.5 -top-0.5 size-2 rounded-full ring-2 ring-surface ${
|
||||
a.status === "failed"
|
||||
? "bg-vermilion"
|
||||
: a.status === "pending"
|
||||
? "bg-amber"
|
||||
: "bg-signal"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -151,6 +179,56 @@ export function LiveModerationFeed({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(a.confidence != null || a.flags?.length) && (
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
{a.confidence != null && (
|
||||
<span
|
||||
className="flex items-center gap-1.5"
|
||||
title={`Model confidence ${Math.round(a.confidence * 100)}%`}
|
||||
>
|
||||
<span className="font-mono text-[9px] tracking-wider text-ink-faint uppercase">
|
||||
conf
|
||||
</span>
|
||||
<span className="inline-flex h-1 w-14 overflow-hidden rounded-full bg-surface-2">
|
||||
<span
|
||||
className={`h-full rounded-full ${confidenceTone(a.confidence) ?? "bg-ink-faint"}`}
|
||||
style={{
|
||||
width: `${Math.min(100, Math.round(a.confidence * 100))}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span className="font-mono text-[9px] text-ink-faint">
|
||||
{Math.round(a.confidence * 100)}%
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{a.flags && a.flags.length > 0 && (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{a.flags.slice(0, 2).map((flag) => (
|
||||
<Badge
|
||||
key={flag}
|
||||
tone={tone ?? "neutral"}
|
||||
size="sm"
|
||||
className="uppercase"
|
||||
>
|
||||
{flag}
|
||||
</Badge>
|
||||
))}
|
||||
{a.flags.length > 2 && (
|
||||
<span className="font-mono text-[9px] text-ink-faint">
|
||||
+{a.flags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{a.error && (
|
||||
<span className="font-mono text-[9px] text-vermilion/80">
|
||||
{a.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-2 font-mono text-[10px] text-ink-faint">
|
||||
<span>TARGET:</span>
|
||||
<span className="text-ink-soft">
|
||||
|
||||
@@ -2,4 +2,3 @@ export { AreaActivity } from "./area-activity";
|
||||
export { Donut } from "./donut";
|
||||
export { RadialGauge } from "./radial-gauge";
|
||||
export { Sparkline } from "./sparkline";
|
||||
export { Equalizer } from "./waveform";
|
||||
@@ -1,41 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Live equalizer bars. `bars` are 0..1 levels. */
|
||||
export function Equalizer({
|
||||
bars,
|
||||
color = "var(--color-signal)",
|
||||
className,
|
||||
}: {
|
||||
bars: number[];
|
||||
color?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex h-10 items-end gap-[3px]", className)}>
|
||||
{bars.length === 0 ? (
|
||||
<div className="flex w-full items-end gap-[3px]">
|
||||
{Array.from({ length: 28 }).map((_, i) => (
|
||||
<span
|
||||
key={`eq-${i}`}
|
||||
className="flex-1 rounded-full bg-hairline"
|
||||
style={{ height: "12%" }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
bars.map((b, i) => (
|
||||
<span
|
||||
key={`bar-${i}`}
|
||||
className="flex-1 rounded-full"
|
||||
style={{
|
||||
height: `${Math.max(6, b * 100)}%`,
|
||||
background: color,
|
||||
boxShadow: b > 0.05 ? `0 0 8px ${color}` : "none",
|
||||
transition: "height 90ms linear",
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export function SkeletonRows({
|
||||
);
|
||||
}
|
||||
|
||||
/** Hero block placeholder (dashboard / media "now playing"). */
|
||||
/** Hero block placeholder (dashboard headline). */
|
||||
export function SkeletonHero({ className }: { className?: string }) {
|
||||
return (
|
||||
<GlassPanel glow className={cn("relative overflow-hidden", className)}>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import {
|
||||
Headphones,
|
||||
LayoutDashboard,
|
||||
type LucideIcon,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Music,
|
||||
Search,
|
||||
Shield,
|
||||
Users,
|
||||
@@ -40,24 +37,6 @@ export const navItems: NavItem[] = [
|
||||
icon: Users,
|
||||
matchPrefix: "/users",
|
||||
},
|
||||
{
|
||||
href: "/voice",
|
||||
label: "Voice",
|
||||
icon: Mic,
|
||||
matchPrefix: "/voice",
|
||||
},
|
||||
{
|
||||
href: "/media",
|
||||
label: "Media",
|
||||
icon: Music,
|
||||
matchPrefix: "/media",
|
||||
},
|
||||
{
|
||||
href: "/recordings",
|
||||
label: "Recordings",
|
||||
icon: Headphones,
|
||||
matchPrefix: "/recordings",
|
||||
},
|
||||
{
|
||||
href: "/moderation",
|
||||
label: "Moderation",
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface DashboardStats {
|
||||
total_clean: number;
|
||||
total_warned: number;
|
||||
total_error: number;
|
||||
total_voice_recordings: number;
|
||||
total_profiles: number;
|
||||
today_messages: number;
|
||||
today_flagged: number;
|
||||
|
||||
@@ -2,13 +2,9 @@ export type DashboardTab = "messages" | "live" | "dashboard";
|
||||
|
||||
export interface UiState {
|
||||
selected_guild?: string | null;
|
||||
selected_voice_guild?: string | null;
|
||||
selected_voice_channel?: string | null;
|
||||
selected_text_guild?: string | null;
|
||||
selected_text_channel?: string | null;
|
||||
active_tab?: DashboardTab | null;
|
||||
is_listening?: boolean | null;
|
||||
is_streaming?: boolean | null;
|
||||
}
|
||||
|
||||
export interface ChatbotResponse {
|
||||
|
||||
@@ -9,24 +9,20 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { toast } from "@/components/primitives";
|
||||
import { WsConnection } from "./connection";
|
||||
import type { PcmChunk, WsEventHandler, WsEventType, WsStatus } from "./types";
|
||||
import type { WsEventHandler, WsEventType, WsStatus } from "./types";
|
||||
|
||||
interface WsContextValue {
|
||||
status: WsStatus;
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
sendText: (text: string) => void;
|
||||
sendBinary: (data: ArrayBufferLike) => void;
|
||||
/** Subscribe to a typed WS event. Returns unsubscribe function. */
|
||||
on: <E extends WsEventType>(
|
||||
eventType: E,
|
||||
handler: WsEventHandler<E>,
|
||||
) => () => void;
|
||||
/** Subscribe to binary PCM events. Returns unsubscribe function. */
|
||||
onPcm: (handler: (chunk: PcmChunk) => void) => () => void;
|
||||
}
|
||||
|
||||
const WsContext = createContext<WsContextValue | null>(null);
|
||||
@@ -40,7 +36,6 @@ export function WsProvider({
|
||||
}) {
|
||||
const connRef = useRef<WsConnection | null>(null);
|
||||
const [status, setStatus] = useState<WsStatus>("disconnected");
|
||||
const { mutate } = useSWRConfig();
|
||||
// Tracks whether we've ever been connected — used to suppress the
|
||||
// "reconnecting" toast on initial page load.
|
||||
const wasConnected = useRef(false);
|
||||
@@ -48,7 +43,6 @@ export function WsProvider({
|
||||
// Event handler registry — Ref so listeners survive re-renders without reconnect
|
||||
// Using unknown as internal store; typed at the subscribe interface
|
||||
const handlersRef = useRef<Record<string, Set<(data: unknown) => void>>>({});
|
||||
const pcmHandlersRef = useRef<Set<(chunk: PcmChunk) => void>>(new Set());
|
||||
|
||||
const handleJsonEvent = useCallback((json: string) => {
|
||||
try {
|
||||
@@ -66,17 +60,6 @@ export function WsProvider({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBinaryEvent = useCallback((buffer: ArrayBuffer) => {
|
||||
if (buffer.byteLength < 4 || pcmHandlersRef.current.size === 0) return;
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const userIdHash = view.getUint32(0, true);
|
||||
const samples = new Int16Array(buffer, 4);
|
||||
|
||||
const chunk: PcmChunk = { userIdHash, samples };
|
||||
pcmHandlersRef.current.forEach((h) => h(chunk));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const conn = new WsConnection(url);
|
||||
connRef.current = conn;
|
||||
@@ -97,9 +80,6 @@ export function WsProvider({
|
||||
wasConnected.current = false;
|
||||
} else if (s === "connected") {
|
||||
wasConnected.current = true;
|
||||
// After reconnect, force-refetch voice status immediately so
|
||||
// the UI converges faster instead of waiting up to 4s for SWR poll.
|
||||
void mutate(["voice-status"]);
|
||||
} else if (s === "error" && !wasConnected.current) {
|
||||
toast({
|
||||
title: "Connection error",
|
||||
@@ -112,8 +92,6 @@ export function WsProvider({
|
||||
const unsubEvent = conn.onEvent((event) => {
|
||||
if (event.type === "text") {
|
||||
handleJsonEvent(event.data);
|
||||
} else {
|
||||
handleBinaryEvent(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -126,7 +104,7 @@ export function WsProvider({
|
||||
unsubEvent();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, handleBinaryEvent, handleJsonEvent, mutate]);
|
||||
}, [url, handleJsonEvent]);
|
||||
|
||||
const subscribe = useCallback(
|
||||
<E extends WsEventType>(_eventType: E, handler: WsEventHandler<E>) => {
|
||||
@@ -144,23 +122,12 @@ export function WsProvider({
|
||||
[],
|
||||
);
|
||||
|
||||
const subscribePcm = useCallback((handler: (chunk: PcmChunk) => void) => {
|
||||
pcmHandlersRef.current.add(handler);
|
||||
return () => {
|
||||
pcmHandlersRef.current.delete(handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => connRef.current?.connect(), []);
|
||||
const disconnect = useCallback(() => connRef.current?.disconnect(), []);
|
||||
const sendText = useCallback(
|
||||
(text: string) => connRef.current?.sendText(text),
|
||||
[],
|
||||
);
|
||||
const sendBinary = useCallback(
|
||||
(data: ArrayBufferLike) => connRef.current?.sendBinary(data),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<WsContext.Provider
|
||||
@@ -169,9 +136,7 @@ export function WsProvider({
|
||||
connect,
|
||||
disconnect,
|
||||
sendText,
|
||||
sendBinary,
|
||||
on: subscribe,
|
||||
onPcm: subscribePcm,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -62,10 +62,3 @@ export type WsEventType = keyof WsEventMap;
|
||||
export type WsEventHandler<E extends WsEventType = WsEventType> = (
|
||||
data: WsEventMap[E],
|
||||
) => void;
|
||||
|
||||
// ── Binary PCM ─────────────────────────────────────────────
|
||||
|
||||
export interface PcmChunk {
|
||||
userIdHash: number;
|
||||
samples: Int16Array;
|
||||
}
|
||||
Reference in new issue
Block a user