feat: add /drama-leaderboard command with historical composite scoring

Queries Messages, AnalysisResults, and Actions tables to rank users by a
composite drama score (weighted avg toxicity, peak toxicity, and action rate).
Public command with configurable time period (7d/30d/90d/all-time).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:08:39 -05:00
parent 0ff962c95e
commit 1d653ec216
3 changed files with 217 additions and 0 deletions

View File

@@ -713,6 +713,94 @@ class Database:
finally:
conn.close()
# ------------------------------------------------------------------
# Drama Leaderboard (historical stats from Messages + AnalysisResults + Actions)
# ------------------------------------------------------------------
async def get_drama_leaderboard(self, guild_id: int, days: int | None = None) -> list[dict]:
"""Get per-user drama stats for the leaderboard.
days=None means all-time. Returns list of dicts sorted by user_id."""
if not self._available:
return []
try:
return await asyncio.to_thread(self._get_drama_leaderboard_sync, guild_id, days)
except Exception:
logger.exception("Failed to get drama leaderboard")
return []
def _get_drama_leaderboard_sync(self, guild_id: int, days: int | None) -> list[dict]:
conn = self._connect()
try:
cursor = conn.cursor()
date_filter = ""
params: list = [guild_id]
if days is not None:
date_filter = "AND m.CreatedAt >= DATEADD(DAY, ?, SYSUTCDATETIME())"
params.append(-days)
# Analysis stats from Messages + AnalysisResults
cursor.execute(f"""
SELECT
m.UserId,
MAX(m.Username) AS Username,
AVG(ar.ToxicityScore) AS AvgToxicity,
MAX(ar.ToxicityScore) AS MaxToxicity,
COUNT(*) AS MessagesAnalyzed
FROM Messages m
INNER JOIN AnalysisResults ar ON ar.MessageId = m.Id
WHERE m.GuildId = ? {date_filter}
GROUP BY m.UserId
""", *params)
analysis_rows = cursor.fetchall()
# Action counts
action_date_filter = ""
action_params: list = [guild_id]
if days is not None:
action_date_filter = "AND CreatedAt >= DATEADD(DAY, ?, SYSUTCDATETIME())"
action_params.append(-days)
cursor.execute(f"""
SELECT
UserId,
SUM(CASE WHEN ActionType = 'warning' THEN 1 ELSE 0 END) AS Warnings,
SUM(CASE WHEN ActionType = 'mute' THEN 1 ELSE 0 END) AS Mutes,
SUM(CASE WHEN ActionType IN ('topic_remind', 'topic_nudge') THEN 1 ELSE 0 END) AS OffTopic
FROM Actions
WHERE GuildId = ? {action_date_filter}
GROUP BY UserId
""", *action_params)
action_map = {}
for row in cursor.fetchall():
action_map[row[0]] = {
"warnings": row[1],
"mutes": row[2],
"off_topic": row[3],
}
cursor.close()
results = []
for row in analysis_rows:
user_id = row[0]
actions = action_map.get(user_id, {"warnings": 0, "mutes": 0, "off_topic": 0})
results.append({
"user_id": user_id,
"username": row[1],
"avg_toxicity": float(row[2]),
"max_toxicity": float(row[3]),
"messages_analyzed": row[4],
"warnings": actions["warnings"],
"mutes": actions["mutes"],
"off_topic": actions["off_topic"],
})
return results
finally:
conn.close()
async def close(self):
"""No persistent connection to close (connections are per-operation)."""
pass