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.
This commit is contained in:
@@ -128,30 +128,6 @@ export async function skipAgeRestrictedMessages(
|
||||
// Batch pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function postBatchReputationUpdate(rows: MessageRecord[]): Promise<void> {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 `<user_reputation .../>`.
|
||||
* 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 `<user_history>` block (last flagged messages) from
|
||||
|
||||
@@ -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<UserReputation> {
|
||||
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<UserReputation | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
@@ -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 <user_reputation> history is injected.
|
||||
// little moderation signal — user history context (last flagged messages)
|
||||
// is injected via <user_history> instead of a numeric trust score.
|
||||
AI_USER_PROFILE_LEARNING_ENABLED: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user