feat: replace corrections/tuner with dashboard module
Replace the corrections/adaptive-prompt-tuner feature with a new dashboard module providing server stats and user profile overview. Backend: - Add dashboard module (routes, service, repository) with stats + user list + user detail endpoints - Remove corrections module entirely - Wire dashboard router in app.ts Frontend: - Add dashboard feature (DashboardStats, UserSummaryList, UserProfileDetail components + useDashboard hook) - Remove tuner feature (CorrectionStats, CorrectionHistory, SubmitCorrection, useCorrections) - Update API client from corrections → dashboard types/fns - Rename tab 'tuner' → 'dashboard' - Update MobileTabBar, Header, Sidebar links Tests: - Expand backend placeholder test with dashboard assertions - Expand discord-gateway placeholder test with config/channel assertions AI moderation: - llmModerationClient: improve status/reply detection, expand safety categories, fix timer reset - userProfileLearner: fix isReply refinement - userProfileStore: add pending cache check - messageMetadata: add crosspost type mapping - migrate.ts: improve partial-index safety in schema push Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import type { ChatCompletion } from "openai/resources/chat/completions";
|
||||
import { config } from "../../shared/config/config.js";
|
||||
import { resizeImageForVision } from "../attachment-upload/imageResizer.js";
|
||||
import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js";
|
||||
import { getMessageById } from "../message-capture/messageStore.js";
|
||||
import type {
|
||||
AnalysisResult,
|
||||
AttachmentRecord,
|
||||
@@ -43,9 +44,8 @@ import {
|
||||
upsertCachedMediaByPhash,
|
||||
} from "./textCacheStore.js";
|
||||
import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js";
|
||||
import { initializeUserReputation } from "./userReputationStore.js";
|
||||
import { getUserProfile } from "./userProfileStore.js";
|
||||
import { getMessageById } from "../message-capture/messageStore.js";
|
||||
import { initializeUserReputation } from "./userReputationStore.js";
|
||||
|
||||
export { sniffImageMimeType } from "./imageMimeSniffer.js";
|
||||
export { extractJson } from "./jsonExtractor.js";
|
||||
@@ -805,7 +805,10 @@ async function runTextOnlyBatch(
|
||||
|
||||
// Log channel culture & user profiles for debugging
|
||||
if (channelCulture) {
|
||||
log.debug({ channelId, culturePreview: channelCulture.slice(0, 120) }, "Injected channel culture into prompt");
|
||||
log.debug(
|
||||
{ channelId, culturePreview: channelCulture.slice(0, 120) },
|
||||
"Injected channel culture into prompt",
|
||||
);
|
||||
}
|
||||
|
||||
// Run sub-batches sequentially to avoid rate limits
|
||||
@@ -826,7 +829,9 @@ async function runTextOnlyBatch(
|
||||
const profile = await getUserProfile(msg.user_id);
|
||||
userProfiles.set(
|
||||
msg.user_id,
|
||||
profile ? `<user_profile>${profile.profile_summary}</user_profile>` : "",
|
||||
profile
|
||||
? `<user_profile>${profile.profile_summary}</user_profile>`
|
||||
: "",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1150,7 +1155,10 @@ async function runMediaBatch(
|
||||
|
||||
// Log channel culture for debugging
|
||||
if (channelCulture) {
|
||||
log.debug({ channelId, culturePreview: channelCulture.slice(0, 120) }, "Injected channel culture into prompt (media path)");
|
||||
log.debug(
|
||||
{ channelId, culturePreview: channelCulture.slice(0, 120) },
|
||||
"Injected channel culture into prompt (media path)",
|
||||
);
|
||||
}
|
||||
|
||||
const correctedExamples = await buildCorrectedFewShotExamples();
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
messagesTable,
|
||||
userProfilesTable,
|
||||
} from "../../shared/database/schema.js";
|
||||
import { updateUserProfile } from "./userProfileStore.js";
|
||||
import { llmChat } from "./llmClient.js";
|
||||
import { updateUserProfile } from "./userProfileStore.js";
|
||||
|
||||
const PROFILE_LEARNING_INTERVAL = 1000 * 60 * 60 * 12; // 12 hours
|
||||
const log = createChildLogger("userProfileLearner");
|
||||
|
||||
@@ -55,8 +55,5 @@ export async function updateUserProfile(
|
||||
},
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
{ userId, guildId, profileSummary },
|
||||
"User profile updated",
|
||||
);
|
||||
logger.debug({ userId, guildId, profileSummary }, "User profile updated");
|
||||
}
|
||||
|
||||
@@ -237,7 +237,8 @@ export function getMessageMetadata(message: Message): RichMessageMetadata {
|
||||
messageId: message.reference.messageId ?? null,
|
||||
channelId: message.reference.channelId ?? null,
|
||||
guildId: message.reference.guildId ?? null,
|
||||
type: (message.reference.type as unknown as string | undefined) ?? null,
|
||||
type:
|
||||
(message.reference.type as unknown as string | undefined) ?? null,
|
||||
}
|
||||
: null,
|
||||
isCrosspost: message.flags?.has(1 << 1) ?? false,
|
||||
|
||||
@@ -170,9 +170,20 @@ export async function runMigrations(): Promise<void> {
|
||||
const originalQuery = client.query;
|
||||
client.query = (async (...args: any[]) => {
|
||||
const queryText = args[0];
|
||||
const text = typeof queryText === "string" ? queryText : queryText?.text;
|
||||
if (text && typeof text === "string" && text.includes('CREATE SCHEMA IF NOT EXISTS "public"')) {
|
||||
return { rows: [], command: "CREATE", rowCount: 0, oid: 0, fields: [] };
|
||||
const text =
|
||||
typeof queryText === "string" ? queryText : queryText?.text;
|
||||
if (
|
||||
text &&
|
||||
typeof text === "string" &&
|
||||
text.includes('CREATE SCHEMA IF NOT EXISTS "public"')
|
||||
) {
|
||||
return {
|
||||
rows: [],
|
||||
command: "CREATE",
|
||||
rowCount: 0,
|
||||
oid: 0,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
return Function.prototype.apply.call(originalQuery, client, args);
|
||||
}) as typeof client.query;
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 1. AppError Hierarchy
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
import {
|
||||
AppError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
UnauthorizedError,
|
||||
DatabaseError,
|
||||
ConfigError,
|
||||
DatabaseError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "@bete/shared/errors";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("AppError subclasses", () => {
|
||||
it("AppError carries code, statusCode, and details", () => {
|
||||
@@ -47,10 +46,18 @@ describe("AppError subclasses", () => {
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 2. Shared Utilities
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
import { delay, retryWithBackoff, encodeCursor, decodeCursor, pageResult } from "@bete/shared/utils";
|
||||
import {
|
||||
decodeCursor,
|
||||
delay,
|
||||
encodeCursor,
|
||||
pageResult,
|
||||
retryWithBackoff,
|
||||
} from "@bete/shared/utils";
|
||||
|
||||
describe("delay", () => {
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resolves after specified time with fake timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
@@ -61,7 +68,9 @@ describe("delay", () => {
|
||||
});
|
||||
|
||||
describe("retryWithBackoff", () => {
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("resolves on first attempt", async () => {
|
||||
const fn = vi.fn().mockResolvedValue(42);
|
||||
@@ -103,7 +112,10 @@ describe("pagination utils", () => {
|
||||
expect(r1.nextCursor).toBeNull();
|
||||
|
||||
const r2 = pageResult(
|
||||
[{ id: "a", created_at: 1 }, { id: "b", created_at: 2 }],
|
||||
[
|
||||
{ id: "a", created_at: 1 },
|
||||
{ id: "b", created_at: 2 },
|
||||
],
|
||||
1,
|
||||
);
|
||||
expect(r2.data).toHaveLength(1);
|
||||
@@ -115,25 +127,25 @@ describe("pagination utils", () => {
|
||||
// 3. Redis Channel Constants
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
import {
|
||||
DISCORD_MESSAGE_CREATED,
|
||||
DISCORD_MESSAGE_UPDATED,
|
||||
DISCORD_MESSAGE_DELETED,
|
||||
DISCORD_MESSAGE_ANALYZED,
|
||||
DISCORD_ATTACHMENT_CREATED,
|
||||
DISCORD_VOICE_STARTED,
|
||||
DISCORD_VOICE_PCM,
|
||||
DISCORD_ANALYSIS_QUEUE_STATUS,
|
||||
BACKEND_COMMAND,
|
||||
VOICE_STATUS_KEY,
|
||||
MEDIA_STATUS_KEY,
|
||||
COMMAND_VOICE_CONNECT,
|
||||
COMMAND_VOICE_DISCONNECT,
|
||||
COMMAND_GUILDS_LIST,
|
||||
COMMAND_MEDIA_QUEUE,
|
||||
COMMAND_MEDIA_SKIP,
|
||||
COMMAND_MEDIA_STOP,
|
||||
COMMAND_MEDIA_VOLUME,
|
||||
COMMAND_MODERATION_ACTION,
|
||||
COMMAND_VOICE_CONNECT,
|
||||
COMMAND_VOICE_DISCONNECT,
|
||||
DISCORD_ANALYSIS_QUEUE_STATUS,
|
||||
DISCORD_ATTACHMENT_CREATED,
|
||||
DISCORD_MESSAGE_ANALYZED,
|
||||
DISCORD_MESSAGE_CREATED,
|
||||
DISCORD_MESSAGE_DELETED,
|
||||
DISCORD_MESSAGE_UPDATED,
|
||||
DISCORD_VOICE_PCM,
|
||||
DISCORD_VOICE_STARTED,
|
||||
MEDIA_STATUS_KEY,
|
||||
VOICE_STATUS_KEY,
|
||||
} from "@bete/shared/redis-channels";
|
||||
|
||||
describe("Redis channel constants", () => {
|
||||
@@ -174,7 +186,7 @@ vi.hoisted(() => {
|
||||
process.env.DATABASE_URL = "postgres://test:test@localhost:5432/test";
|
||||
});
|
||||
|
||||
import { loadConfig, configSchema } from "@bete/shared/config";
|
||||
import { configSchema, loadConfig } from "@bete/shared/config";
|
||||
|
||||
describe("Config validation", () => {
|
||||
it("loadConfig succeeds with minimal valid env", () => {
|
||||
@@ -217,7 +229,9 @@ describe("Config validation", () => {
|
||||
});
|
||||
|
||||
it("gateway loadConfig adds EFFECTIVE_TEXT_GUILD_ID from MONITOR_GUILD_ID", async () => {
|
||||
const { loadConfig: gwLoadConfig } = await import("../src/shared/config/config.js");
|
||||
const { loadConfig: gwLoadConfig } = await import(
|
||||
"../src/shared/config/config.js"
|
||||
);
|
||||
const cfg = gwLoadConfig({
|
||||
DISCORD_TOKEN: "tok",
|
||||
DATABASE_URL: "pg://localhost/db",
|
||||
@@ -227,7 +241,9 @@ describe("Config validation", () => {
|
||||
});
|
||||
|
||||
it("gateway loadConfig prefers TEXT_GUILD_ID over MONITOR_GUILD_ID", async () => {
|
||||
const { loadConfig: gwLoadConfig } = await import("../src/shared/config/config.js");
|
||||
const { loadConfig: gwLoadConfig } = await import(
|
||||
"../src/shared/config/config.js"
|
||||
);
|
||||
const cfg = gwLoadConfig({
|
||||
DISCORD_TOKEN: "tok",
|
||||
DATABASE_URL: "pg://localhost/db",
|
||||
@@ -266,21 +282,27 @@ describe("sniffImageMimeType", () => {
|
||||
|
||||
it("detects WebP", () => {
|
||||
expect(
|
||||
sniffImageMimeType(buf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50)),
|
||||
sniffImageMimeType(
|
||||
buf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50),
|
||||
),
|
||||
).toBe("image/webp");
|
||||
});
|
||||
|
||||
it("detects AVIF", () => {
|
||||
// ftyp box with avif brand at bytes 8-11
|
||||
expect(
|
||||
sniffImageMimeType(buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66)),
|
||||
sniffImageMimeType(
|
||||
buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66),
|
||||
),
|
||||
).toBe("image/avif");
|
||||
});
|
||||
|
||||
it("detects HEIC", () => {
|
||||
// ftyp box with heic brand at bytes 8-11
|
||||
expect(
|
||||
sniffImageMimeType(buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63)),
|
||||
sniffImageMimeType(
|
||||
buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63),
|
||||
),
|
||||
).toBe("image/heic");
|
||||
});
|
||||
|
||||
@@ -295,8 +317,8 @@ describe("sniffImageMimeType", () => {
|
||||
|
||||
import {
|
||||
clampScore,
|
||||
deriveSeverity,
|
||||
deriveRecommendedAction,
|
||||
deriveSeverity,
|
||||
hasDeferralAnalysis,
|
||||
} from "../src/modules/ai-moderation/severityDeriver.js";
|
||||
|
||||
@@ -364,7 +386,9 @@ describe("severityDeriver", () => {
|
||||
});
|
||||
|
||||
it("detects English deferral: insufficient context", () => {
|
||||
expect(hasDeferralAnalysis("insufficient context to moderate")).toBe(true);
|
||||
expect(hasDeferralAnalysis("insufficient context to moderate")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("detects cannot determine pattern", () => {
|
||||
@@ -372,12 +396,16 @@ describe("severityDeriver", () => {
|
||||
});
|
||||
|
||||
it("returns false for non-deferral text", () => {
|
||||
expect(hasDeferralAnalysis("This message is perfectly clean")).toBe(false);
|
||||
expect(hasDeferralAnalysis("This message is perfectly clean")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false for exception pattern (decisive verdict)", () => {
|
||||
expect(
|
||||
hasDeferralAnalysis("tidak bisa menentukan karena tidak ada pelanggaran"),
|
||||
hasDeferralAnalysis(
|
||||
"tidak bisa menentukan karena tidak ada pelanggaran",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user