From 2a8f6d90628fdfea3ece2ce5cd75b39e1dec411d Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 18 Aug 2026 18:27:15 +0700 Subject: [PATCH] refactor(gateway): remove user reputation feature entirely Drop trust-score/infraction system: delete userReputationStore, remove call sites in fallback/batch processors, drop formatReputationAttrs, drop user_reputations table (migration 0016), delete trust-model test, update docs. --- services/discord-gateway/ARCHITECTURE.md | 2 +- services/discord-gateway/MODULE_STRUCTURE.md | 2 +- .../migrations/0016_drop_user_reputations.sql | 3 + .../drizzle/migrations/meta/_journal.json | 7 + .../modules/ai-moderation/batchProcessor.ts | 39 --- .../individualFallbackProcessor.ts | 27 -- .../ai-moderation/moderationBuilders.ts | 50 +-- .../ai-moderation/userReputationStore.ts | 324 ------------------ .../src/shared/config/index.ts | 3 +- .../src/shared/database/schema.ts | 30 -- .../src/shared/database/schema/analytics.ts | 11 +- .../tests/contextEnrichment.test.ts | 71 +--- .../discord-gateway/tests/trust-model.test.ts | 93 ----- 13 files changed, 19 insertions(+), 643 deletions(-) create mode 100644 services/discord-gateway/drizzle/migrations/0016_drop_user_reputations.sql delete mode 100644 services/discord-gateway/src/modules/ai-moderation/userReputationStore.ts delete mode 100644 services/discord-gateway/tests/trust-model.test.ts diff --git a/services/discord-gateway/ARCHITECTURE.md b/services/discord-gateway/ARCHITECTURE.md index dec177e..3cbb3d1 100644 --- a/services/discord-gateway/ARCHITECTURE.md +++ b/services/discord-gateway/ARCHITECTURE.md @@ -71,7 +71,7 @@ handles a whole batch (text + media split internally, parallel paths). - `embeddingClient.ts` + `qdrantClient.ts` — semantic cache (one embed call + one batched Qdrant search for all uncached targets). - `textCacheStore.ts` / `channelCultureStore.ts` / `userProfileStore.ts` / - `userReputationStore.ts` — caches & learned per-channel/user state. + `userProfileStore.ts` — caches learned user profile summaries (optional). ### Concurrency model diff --git a/services/discord-gateway/MODULE_STRUCTURE.md b/services/discord-gateway/MODULE_STRUCTURE.md index 1d6b7b6..0de830f 100644 --- a/services/discord-gateway/MODULE_STRUCTURE.md +++ b/services/discord-gateway/MODULE_STRUCTURE.md @@ -49,7 +49,7 @@ Orchestration/caching: `moderationOrchestrator.ts` (exact hash → batched semantic Qdrant → LLM), `textBatchProcessor.ts` / `mediaBatchProcessor.ts` (one LLM call per sub-batch), `llmClient.ts` (central streaming client), `embeddingClient.ts` + `qdrantClient.ts` (semantic cache), plus -`channelCultureStore.ts` / `userProfileStore.ts` / `userReputationStore.ts`. +`channelCultureStore.ts` / `userProfileStore.ts`. ### voice-recording `voiceController.ts` (connect/disconnect/list) + `recorder.ts` (orchestration) diff --git a/services/discord-gateway/drizzle/migrations/0016_drop_user_reputations.sql b/services/discord-gateway/drizzle/migrations/0016_drop_user_reputations.sql new file mode 100644 index 0000000..3e06995 --- /dev/null +++ b/services/discord-gateway/drizzle/migrations/0016_drop_user_reputations.sql @@ -0,0 +1,3 @@ +-- Remove the user reputation feature entirely (trust scores, infractions). +-- The feature was removed from the codebase; this drops the orphaned table. +DROP TABLE IF EXISTS "user_reputations"; diff --git a/services/discord-gateway/drizzle/migrations/meta/_journal.json b/services/discord-gateway/drizzle/migrations/meta/_journal.json index 3cc57c4..e56282f 100644 --- a/services/discord-gateway/drizzle/migrations/meta/_journal.json +++ b/services/discord-gateway/drizzle/migrations/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1787184000000, "tag": "0015_add_moderation_explainability", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1787185000000, + "tag": "0016_drop_user_reputations", + "breakpoints": true } ] } \ No newline at end of file diff --git a/services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts b/services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts index f27327c..3190b85 100644 --- a/services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts +++ b/services/discord-gateway/src/modules/ai-moderation/batchProcessor.ts @@ -128,30 +128,6 @@ export async function skipAgeRestrictedMessages( // Batch pipeline // --------------------------------------------------------------------------- -async function postBatchReputationUpdate(rows: MessageRecord[]): Promise { - for (const row of rows) { - if (row.ai_status === "clean") { - import("./userReputationStore.js") - .then((store) => store.recordCleanMessage(row.user_id, row.guild_id)) - .catch((e) => - logger.error({ error: e }, "Failed to record clean message streak"), - ); - } else if (row.ai_status === "flagged" && row.ai_severity !== "none") { - import("./userReputationStore.js") - .then((store) => - store.recordInfraction( - row.user_id, - row.guild_id, - row.ai_severity as "low" | "medium" | "high" | "critical", - ), - ) - .catch((e) => - logger.error({ error: e }, "Failed to record infraction penalty"), - ); - } - } -} - export async function processBatch( conversationKey: string, messages: MessageRecord[], @@ -196,21 +172,6 @@ export async function processBatch( } } - // Post-batch reputation updates (fire-and-forget) - postBatchReputationUpdate( - result.rows.filter((r) => { - if (r.ai_status === "error") { - try { - const flags = JSON.parse(r.ai_moderation_flags ?? "[]") as string[]; - return !flags.includes("analysis_api_failed"); - } catch { - return false; - } - } - return true; - }), - ); - if (!result.ok) { recordConversationBatchFailure(conversationKey); diff --git a/services/discord-gateway/src/modules/ai-moderation/individualFallbackProcessor.ts b/services/discord-gateway/src/modules/ai-moderation/individualFallbackProcessor.ts index 97c0e6c..df32c69 100644 --- a/services/discord-gateway/src/modules/ai-moderation/individualFallbackProcessor.ts +++ b/services/discord-gateway/src/modules/ai-moderation/individualFallbackProcessor.ts @@ -123,33 +123,6 @@ async function processIndividualFallback( for (const row of rows) { broadcastAnalysisCompleted(row); scheduleAutoDelete(row); - - // Update reputation autonomously - if (row.ai_status === "clean") { - import("./userReputationStore.js") - .then((store) => store.recordCleanMessage(row.user_id, row.guild_id)) - .catch((e) => - logger.error( - { error: e }, - "Failed to record clean message streak in fallback", - ), - ); - } else if (row.ai_status === "flagged" && row.ai_severity !== "none") { - import("./userReputationStore.js") - .then((store) => - store.recordInfraction( - row.user_id, - row.guild_id, - row.ai_severity as "low" | "medium" | "high" | "critical", - ), - ) - .catch((e) => - logger.error( - { error: e }, - "Failed to record infraction penalty in fallback", - ), - ); - } } const resultSummary = analysisResult.results[0]; diff --git a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts index 0d0840d..9b57989 100644 --- a/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts +++ b/services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts @@ -127,56 +127,10 @@ export function buildUserProfileRef(userId: string): string { } // --------------------------------------------------------------------------- -// User reputation — richer than a bare trust score. -// -// The trust model tracks total_infractions, a clean-message streak and the -// last infraction timestamp. Feeding all of it to the LLM lets it tell a -// first-timer (same score, 1 infraction) from a repeat offender (score 50, -// 3 infractions, last one yesterday) — the same score means very different -// things in those two contexts. +// Per-user history context (last flagged messages only — no trust model). +// context to AI moderation. // --------------------------------------------------------------------------- - -export interface ReputationAttrsSource { - trust_score: number; - total_infractions: number; - clean_message_streak: number; - last_infraction_at: number | null; -} - const DAY_MS = 24 * 60 * 60 * 1000; -const REPEAT_OFFENSE_WINDOW_MS = 7 * DAY_MS; - -/** - * Formats reputation fields into XML attributes for ``. - * Derived signals: last_offense_days_ago (0 = today) and repeat_offender - * (infraction within the last 7 days) are computed here so both the text and - * media paths emit the exact same shape. - */ -export function formatReputationAttrs( - rep: ReputationAttrsSource, - now: number = Date.now(), -): string { - const attrs = [ - `trust_score="${rep.trust_score}"`, - `total_infractions="${rep.total_infractions}"`, - `clean_streak="${rep.clean_message_streak}"`, - ]; - if ( - typeof rep.last_infraction_at === "number" && - rep.last_infraction_at > 0 - ) { - const daysAgo = Math.max( - 0, - Math.floor((now - rep.last_infraction_at) / DAY_MS), - ); - attrs.push(`last_offense_days_ago="${daysAgo}"`); - const isRepeat = - rep.total_infractions > 0 && - now - rep.last_infraction_at <= REPEAT_OFFENSE_WINDOW_MS; - if (isRepeat) attrs.push(`repeat_offender="true"`); - } - return attrs.join(" "); -} /** * Builds an optional `` block (last flagged messages) from diff --git a/services/discord-gateway/src/modules/ai-moderation/userReputationStore.ts b/services/discord-gateway/src/modules/ai-moderation/userReputationStore.ts deleted file mode 100644 index c0b9667..0000000 --- a/services/discord-gateway/src/modules/ai-moderation/userReputationStore.ts +++ /dev/null @@ -1,324 +0,0 @@ -import { and, desc, eq } from "drizzle-orm"; -import { createChildLogger } from "@/shared/logger/index"; -import { getDatabase } from "../../shared/database/drizzle.js"; -import { - messagesTable, - type UserReputation, - userReputationsTable, -} from "../../shared/database/schema.js"; - -const logger = createChildLogger("userReputationStore"); - -// --------------------------------------------------------------------------- -// Trust model v2 — fair, recoverable, escalation-aware -// --------------------------------------------------------------------------- -// -// Problems with v1 that this fixes: -// 1. Trust practically could NOT rise: +2 per 100 clean messages meant a -// single -15 "high" penalty required 750 clean messages to repay. -// 2. Flat penalties regardless of history: first-timers and repeat -// offenders were punished identically. -// 3. Minor infractions could zero out a user (low=-2 at score 2 → 0), -// which is disproportionate. -// -// v2 model: -// - GAIN: +1 trust per 15 consecutive clean messages (cap 100). Recovery -// is real but earned — consistent good behavior rebuilds trust. -// - PENALTY: severity table low=3 / medium=6 / high=12 / critical=25. -// - FIRST OFFENSE: penalty halved (leniency for a single slip). -// - REPEAT OFFENDER: infraction within the last 7 days → ×1.5 (escalation). -// - FLOOR: low/medium infractions cannot push trust below 10/5 — minor -// offenses never permanently cripple a user; high/critical can still -// zero out (severe behavior has severe consequences). -// - Streak resets on infraction; time-based recovery still happens through -// the clean-message gain (no arbitrary idle-decay). -// --------------------------------------------------------------------------- - -export const TRUST_DEFAULTS = { - DEFAULT_TRUST: 50, - MAX_TRUST: 100, - MIN_TRUST: 0, - CLEAN_MESSAGES_PER_POINT: 15, - REPEAT_OFFENSE_WINDOW_MS: 7 * 24 * 60 * 60 * 1000, // 7 days - REPEAT_OFFENSE_MULTIPLIER: 1.5, -} as const; - -export const INFRACTION_PENALTIES: Record< - "low" | "medium" | "high" | "critical", - number -> = { - low: 3, - medium: 6, - high: 12, - critical: 25, -}; - -/** Trust floors per severity — minor offenses can't tank a user to zero. */ -export const INFRACTION_FLOORS: Record< - "low" | "medium" | "high" | "critical", - number -> = { - low: 10, - medium: 5, - high: 0, - critical: 0, -}; - -function clampTrust(score: number): number { - return Math.min( - TRUST_DEFAULTS.MAX_TRUST, - Math.max(TRUST_DEFAULTS.MIN_TRUST, Math.round(score)), - ); -} - -export interface InfractionContext { - totalInfractions: number; - lastInfractionAt: number | null; - severity: "low" | "medium" | "high" | "critical"; - now?: number; -} - -export interface InfractionOutcome { - penalty: number; - appliedRules: { - firstOffense: boolean; - repeatEscalation: boolean; - }; -} - -/** - * Pure penalty computation for the trust model (unit-testable, no DB). - * - First offense ever → halved (leniency for a single slip). - * - Repeat offense within the 7-day window → ×1.5 (escalation). - */ -export function computeInfractionPenalty( - ctx: InfractionContext, -): InfractionOutcome { - const basePenalty = INFRACTION_PENALTIES[ctx.severity]; - let penalty = basePenalty; - const isFirstOffense = ctx.totalInfractions === 0; - - if (isFirstOffense) { - penalty = Math.ceil(basePenalty / 2); - } else if ( - ctx.lastInfractionAt && - (ctx.now ?? Date.now()) - ctx.lastInfractionAt <= - TRUST_DEFAULTS.REPEAT_OFFENSE_WINDOW_MS - ) { - penalty = Math.ceil(basePenalty * TRUST_DEFAULTS.REPEAT_OFFENSE_MULTIPLIER); - } - - return { - penalty, - appliedRules: { - firstOffense: isFirstOffense, - repeatEscalation: !isFirstOffense && penalty > basePenalty, - }, - }; -} - -export interface CleanGainOutcome { - newStreak: number; - trustGain: number; -} - -/** - * Pure clean-message gain computation (unit-testable, no DB). - * +1 trust every CLEAN_MESSAGES_PER_POINT consecutive clean messages; - * the streak keeps counting past the threshold (gains compound). - */ -export function computeCleanTrustGain(currentStreak: number): CleanGainOutcome { - const newStreak = currentStreak + 1; - const trustGain = - newStreak % TRUST_DEFAULTS.CLEAN_MESSAGES_PER_POINT === 0 ? 1 : 0; - return { newStreak, trustGain }; -} - -/** - * Ensures a user reputation record exists. - */ -export async function initializeUserReputation( - userId: string, - guildId: string, -): Promise { - const db = getDatabase(); - const existing = await db - .select() - .from(userReputationsTable) - .where(eq(userReputationsTable.user_id, userId)) - .limit(1); - - if (existing.length > 0) { - logger.debug({ userId }, "Reputation record already exists"); - return existing[0]; - } - - const [inserted] = await db - .insert(userReputationsTable) - .values({ - user_id: userId, - guild_id: guildId, - trust_score: TRUST_DEFAULTS.DEFAULT_TRUST, - clean_message_streak: 0, - total_infractions: 0, - created_at: Date.now(), - updated_at: Date.now(), - }) - .onConflictDoNothing() - .returning(); - - if (!inserted) { - // If concurrent insert happened - logger.debug({ userId }, "Concurrent reputation insert detected, retrying"); - const retry = await db - .select() - .from(userReputationsTable) - .where(eq(userReputationsTable.user_id, userId)) - .limit(1); - return retry[0]; - } - - logger.debug( - { userId, trustScore: inserted.trust_score }, - "Initialized user reputation", - ); - return inserted; -} - -/** - * Fetch a user's reputation score. Returns default 50 if none exists. - */ -export async function getUserReputation( - userId: string, -): Promise { - const db = getDatabase(); - const existing = await db - .select() - .from(userReputationsTable) - .where(eq(userReputationsTable.user_id, userId)) - .limit(1); - - if (existing[0]) { - logger.debug( - { userId, trustScore: existing[0].trust_score }, - "Fetched user reputation", - ); - } else { - logger.debug({ userId }, "No reputation record found, returning null"); - } - return existing[0] || null; -} - -/** - * Increment the clean message streak and grow trust — +1 per - * CLEAN_MESSAGES_PER_POINT consecutive clean messages (cap 100). The streak - * keeps counting past the threshold so gains compound with continued good - * behavior (no more wasted progress at 100, and recovery is genuinely - * reachable after an infraction). - */ -export async function recordCleanMessage( - userId: string, - guildId: string, -): Promise { - const rep = await initializeUserReputation(userId, guildId); - const db = getDatabase(); - const { newStreak, trustGain } = computeCleanTrustGain( - rep.clean_message_streak, - ); - const newScore = - trustGain > 0 ? clampTrust(rep.trust_score + trustGain) : rep.trust_score; - - await db - .update(userReputationsTable) - .set({ - clean_message_streak: newStreak, - trust_score: newScore, - updated_at: Date.now(), - }) - .where(eq(userReputationsTable.user_id, userId)); - - logger.debug( - { userId, previousScore: rep.trust_score, newScore, newStreak }, - "Clean message recorded, reputation updated", - ); -} - -/** - * Apply an infraction penalty to a user. - * - * Fairness rules: - * - First offense ever → penalty halved (leniency, rounded up). - * - Repeat offense within the 7-day window → ×1.5 (escalation). - * - Severity floor prevents minor infractions from zeroing a user. - * - Streak resets — trust must be re-earned through clean behavior. - */ -export async function recordInfraction( - userId: string, - guildId: string, - severity: "low" | "medium" | "high" | "critical", -): Promise { - const rep = await initializeUserReputation(userId, guildId); - const db = getDatabase(); - - const outcome = computeInfractionPenalty({ - totalInfractions: rep.total_infractions, - lastInfractionAt: rep.last_infraction_at, - severity, - }); - const { penalty } = outcome; - - const floor = INFRACTION_FLOORS[severity]; - const newScore = Math.max(floor, clampTrust(rep.trust_score - penalty)); - - await db - .update(userReputationsTable) - .set({ - trust_score: newScore, - clean_message_streak: 0, // Reset streak on infraction - total_infractions: rep.total_infractions + 1, - last_infraction_at: Date.now(), - updated_at: Date.now(), - }) - .where(eq(userReputationsTable.user_id, userId)); - - logger.info( - { - userId, - severity, - basePenalty: INFRACTION_PENALTIES[severity], - penalty, - appliedRules: outcome.appliedRules, - previousScore: rep.trust_score, - newScore, - floor, - totalInfractions: rep.total_infractions + 1, - }, - "Infraction recorded", - ); -} - -/** - * Fetch a user's past N flagged messages for context injection. - */ -export async function getUserRecentInfractions( - userId: string, - limit: number = 3, -) { - const db = getDatabase(); - return await db - .select({ - content: messagesTable.content, - flags: messagesTable.ai_moderation_flags, - severity: messagesTable.ai_severity, - created_at: messagesTable.created_at, - }) - .from(messagesTable) - .where( - and( - eq(messagesTable.user_id, userId), - eq(messagesTable.ai_status, "flagged"), - ), - ) - .orderBy(desc(messagesTable.created_at)) - .limit(limit); -} diff --git a/services/discord-gateway/src/shared/config/index.ts b/services/discord-gateway/src/shared/config/index.ts index 619311e..cfab52a 100644 --- a/services/discord-gateway/src/shared/config/index.ts +++ b/services/discord-gateway/src/shared/config/index.ts @@ -222,7 +222,8 @@ export const configSchema = z AI_GLOSSARY_MAX_TERMS: z.coerce.number().int().min(1).max(20).default(6), // Per-user personal profile summaries (userProfileLearner). Disabled by // default: profiles bloat the analysis context and add LLM/DB cost for - // little moderation signal — only history is injected. + // little moderation signal — user history context (last flagged messages) + // is injected via instead of a numeric trust score. AI_USER_PROFILE_LEARNING_ENABLED: z .string() .optional() diff --git a/services/discord-gateway/src/shared/database/schema.ts b/services/discord-gateway/src/shared/database/schema.ts index 33e2b28..a544ece 100644 --- a/services/discord-gateway/src/shared/database/schema.ts +++ b/services/discord-gateway/src/shared/database/schema.ts @@ -337,32 +337,6 @@ export const pgUserProfilesTable = pgTable( export const userProfilesTable = pgUserProfilesTable; -/** - * User Reputations Table (PostgreSQL) - * Tracks user trust score and infractions to provide context to AI. - */ -export const pgUserReputationsTable = pgTable( - "user_reputations", - { - user_id: pgText("user_id").primaryKey(), - guild_id: pgText("guild_id").notNull(), - trust_score: pgInteger("trust_score").notNull().default(50), - clean_message_streak: pgInteger("clean_message_streak") - .notNull() - .default(0), - total_infractions: pgInteger("total_infractions").notNull().default(0), - last_infraction_at: pgBigint("last_infraction_at", { mode: "number" }), - created_at: pgBigint("created_at", { mode: "number" }).notNull(), - updated_at: pgBigint("updated_at", { mode: "number" }).notNull(), - }, - (table) => ({ - guildIdx: pgIndex("idx_user_reputations_guild_id").on(table.guild_id), - scoreIdx: pgIndex("idx_user_reputations_trust_score").on(table.trust_score), - }), -); - -export const userReputationsTable = pgUserReputationsTable; - /** * Channel Cultures Table (PostgreSQL) * Stores AI-generated summaries of channel norms and slang to inject as context. @@ -592,10 +566,6 @@ export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert; export type UserProfile = typeof userProfilesTable.$inferSelect; export type UserProfileInsert = typeof userProfilesTable.$inferInsert; -// User Reputations -export type UserReputation = typeof userReputationsTable.$inferSelect; -export type UserReputationInsert = typeof userReputationsTable.$inferInsert; - // Channel Cultures export type ChannelCulture = typeof channelCulturesTable.$inferSelect; export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert; diff --git a/services/discord-gateway/src/shared/database/schema/analytics.ts b/services/discord-gateway/src/shared/database/schema/analytics.ts index d950528..2649153 100644 --- a/services/discord-gateway/src/shared/database/schema/analytics.ts +++ b/services/discord-gateway/src/shared/database/schema/analytics.ts @@ -2,26 +2,17 @@ import { pgAIAnalysisRunsTable, pgChannelCulturesTable, pgUserProfilesTable, - pgUserReputationsTable, } from "../../../shared/index.js"; // Re-export shared tables -export { - pgAIAnalysisRunsTable, - pgChannelCulturesTable, - pgUserProfilesTable, - pgUserReputationsTable, -}; +export { pgAIAnalysisRunsTable, pgChannelCulturesTable, pgUserProfilesTable }; export const aiAnalysisRunsTable = pgAIAnalysisRunsTable; export const channelCulturesTable = pgChannelCulturesTable; export const userProfilesTable = pgUserProfilesTable; -export const userReputationsTable = pgUserReputationsTable; // Types export type AIAnalysisRun = typeof aiAnalysisRunsTable.$inferSelect; export type AIAnalysisRunInsert = typeof aiAnalysisRunsTable.$inferInsert; -export type UserReputation = typeof userReputationsTable.$inferSelect; -export type UserReputationInsert = typeof userReputationsTable.$inferInsert; export type ChannelCulture = typeof channelCulturesTable.$inferSelect; export type ChannelCultureInsert = typeof channelCulturesTable.$inferInsert; export type UserProfile = typeof userProfilesTable.$inferSelect; diff --git a/services/discord-gateway/tests/contextEnrichment.test.ts b/services/discord-gateway/tests/contextEnrichment.test.ts index b539fc2..e7a5eea 100644 --- a/services/discord-gateway/tests/contextEnrichment.test.ts +++ b/services/discord-gateway/tests/contextEnrichment.test.ts @@ -1,12 +1,11 @@ // ═══════════════════════════════════════════════════════════════════════════ -// Context enrichment builders — rich attrs, , -// as_of, bot/edited detection (pure, no DB) +// Context enrichment builders — , as_of, +// bot/edited detection (pure, no DB) // ═══════════════════════════════════════════════════════════════════════════ import { describe, expect, it } from "vitest"; import { buildUserHistoryXml, buildUserProfilesBlock, - formatReputationAttrs, resolveIsBot, resolveIsEdited, } from "../src/modules/ai-moderation/moderationBuilders.js"; @@ -42,72 +41,6 @@ function msg(overrides: Partial = {}): MessageRecord { const DAY_MS = 24 * 60 * 60 * 1000; -describe("formatReputationAttrs — rich reputation signal", () => { - it("emits trust, infraction count and clean streak", () => { - const attrs = formatReputationAttrs({ - trust_score: 62, - total_infractions: 3, - clean_message_streak: 45, - last_infraction_at: null, - }); - expect(attrs).toContain('trust_score="62"'); - expect(attrs).toContain('total_infractions="3"'); - expect(attrs).toContain('clean_streak="45"'); - }); - - it("derives last_offense_days_ago and marks repeat offenders (7-day window)", () => { - const attrs = formatReputationAttrs( - { - trust_score: 50, - total_infractions: 2, - clean_message_streak: 0, - last_infraction_at: NOW - 2 * DAY_MS, - }, - NOW, - ); - expect(attrs).toContain('last_offense_days_ago="2"'); - expect(attrs).toContain('repeat_offender="true"'); - }); - - it("does NOT mark repeat offender when the last offense is older than 7 days", () => { - const attrs = formatReputationAttrs( - { - trust_score: 50, - total_infractions: 2, - clean_message_streak: 10, - last_infraction_at: NOW - 30 * DAY_MS, - }, - NOW, - ); - expect(attrs).toContain('last_offense_days_ago="30"'); - expect(attrs).not.toContain("repeat_offender"); - }); - - it("omits offense-derived attrs when the user has no recorded infraction date", () => { - const attrs = formatReputationAttrs({ - trust_score: 85, - total_infractions: 0, - clean_message_streak: 120, - last_infraction_at: null, - }); - expect(attrs).not.toContain("last_offense_days_ago"); - expect(attrs).not.toContain("repeat_offender"); - }); - - it("clamps a future/skewed timestamp to days_ago=0", () => { - const attrs = formatReputationAttrs( - { - trust_score: 50, - total_infractions: 1, - clean_message_streak: 0, - last_infraction_at: NOW + 5 * DAY_MS, - }, - NOW, - ); - expect(attrs).toContain('last_offense_days_ago="0"'); - }); -}); - describe("buildUserHistoryXml — last flagged messages for repeat offenders", () => { it("returns empty when there is no real history", () => { expect(buildUserHistoryXml([])).toBe(""); diff --git a/services/discord-gateway/tests/trust-model.test.ts b/services/discord-gateway/tests/trust-model.test.ts deleted file mode 100644 index 2eb99a6..0000000 --- a/services/discord-gateway/tests/trust-model.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -// ═══════════════════════════════════════════════════════════════════════════ -// Trust model v2 — pure math tests (no DB required) -// ═══════════════════════════════════════════════════════════════════════════ -import { describe, expect, it } from "vitest"; -import { - computeCleanTrustGain, - computeInfractionPenalty, - INFRACTION_FLOORS, - INFRACTION_PENALTIES, - TRUST_DEFAULTS, -} from "../src/modules/ai-moderation/userReputationStore.js"; - -describe("computeCleanTrustGain — trust CAN rise", () => { - it("grants +1 every CLEAN_MESSAGES_PER_POINT clean messages", () => { - const before = computeCleanTrustGain(14); - expect(before.newStreak).toBe(15); - expect(before.trustGain).toBe(1); - - const after = computeCleanTrustGain(15); - expect(after.newStreak).toBe(16); - expect(after.trustGain).toBe(0); - }); - - it("keeps compounding past the threshold (no wasted progress)", () => { - expect(computeCleanTrustGain(29).trustGain).toBe(1); - expect(computeCleanTrustGain(44).trustGain).toBe(1); - // 45 clean messages from a fresh start → 3 points of recovery - let gain = 0; - let streak = 0; - for (let i = 0; i < 45; i++) { - const r = computeCleanTrustGain(streak); - streak = r.newStreak; - gain += r.trustGain; - } - expect(gain).toBe(3); - }); -}); - -describe("computeInfractionPenalty — fair and escalating", () => { - const NOW = Date.now(); - - it("applies base penalty for a repeat offender outside the window", () => { - const r = computeInfractionPenalty({ - totalInfractions: 3, - lastInfractionAt: NOW - TRUST_DEFAULTS.REPEAT_OFFENSE_WINDOW_MS - 1000, - severity: "medium", - now: NOW, - }); - expect(r.penalty).toBe(INFRACTION_PENALTIES.medium); // 6 - expect(r.appliedRules.firstOffense).toBe(false); - expect(r.appliedRules.repeatEscalation).toBe(false); - }); - - it("halves the penalty for a first offense (leniency)", () => { - const r = computeInfractionPenalty({ - totalInfractions: 0, - lastInfractionAt: null, - severity: "high", - now: NOW, - }); - expect(r.penalty).toBe(Math.ceil(INFRACTION_PENALTIES.high / 2)); // 6 - expect(r.appliedRules.firstOffense).toBe(true); - }); - - it("escalates ×1.5 for a repeat offense within 7 days", () => { - const r = computeInfractionPenalty({ - totalInfractions: 2, - lastInfractionAt: NOW - 60 * 60 * 1000, // 1h ago - severity: "medium", - now: NOW, - }); - expect(r.penalty).toBe(Math.ceil(INFRACTION_PENALTIES.medium * 1.5)); // 9 - expect(r.appliedRules.repeatEscalation).toBe(true); - }); - - it("critical first offense still hurts but is halved", () => { - const r = computeInfractionPenalty({ - totalInfractions: 0, - lastInfractionAt: null, - severity: "critical", - now: NOW, - }); - expect(r.penalty).toBe(Math.ceil(INFRACTION_PENALTIES.critical / 2)); // 13 - }); - - it("severity floors prevent minor offenses from zeroing a user", () => { - expect(INFRACTION_FLOORS.low).toBeGreaterThan(0); - expect(INFRACTION_FLOORS.medium).toBeGreaterThan(0); - // high/critical can still reach zero — severe behavior has consequences - expect(INFRACTION_FLOORS.high).toBe(0); - expect(INFRACTION_FLOORS.critical).toBe(0); - }); -});