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

@@ -669,6 +669,78 @@ class CommandsCog(commands.Cog):
old_mode, mode, interaction.user.display_name,
)
@app_commands.command(
name="drama-leaderboard",
description="Show the all-time drama leaderboard for the server.",
)
@app_commands.describe(period="Time period to rank (default: 30d)")
@app_commands.choices(period=[
app_commands.Choice(name="Last 7 days", value="7d"),
app_commands.Choice(name="Last 30 days", value="30d"),
app_commands.Choice(name="Last 90 days", value="90d"),
app_commands.Choice(name="All time", value="all"),
])
async def drama_leaderboard(
self, interaction: discord.Interaction, period: app_commands.Choice[str] | None = None,
):
await interaction.response.defer()
period_val = period.value if period else "30d"
if period_val == "all":
days = None
period_label = "All Time"
else:
days = int(period_val.rstrip("d"))
period_label = f"Last {days} Days"
rows = await self.bot.db.get_drama_leaderboard(interaction.guild.id, days)
if not rows:
await interaction.followup.send(
f"No drama data for **{period_label}**. Everyone's been suspiciously well-behaved."
)
return
# Compute composite score for each user
scored = []
for r in rows:
avg_tox = r["avg_toxicity"]
max_tox = r["max_toxicity"]
msg_count = r["messages_analyzed"]
action_weight = r["warnings"] + r["mutes"] * 2 + r["off_topic"] * 0.5
action_rate = min(1.0, action_weight / msg_count * 10) if msg_count > 0 else 0.0
composite = avg_tox * 0.4 + max_tox * 0.2 + action_rate * 0.4
scored.append({**r, "composite": composite, "action_rate": action_rate})
scored.sort(key=lambda x: x["composite"], reverse=True)
top = scored[:10]
medals = ["🥇", "🥈", "🥉"]
lines = []
for i, entry in enumerate(top):
rank = medals[i] if i < 3 else f"`{i + 1}.`"
# Resolve display name from guild if possible
member = interaction.guild.get_member(entry["user_id"])
name = member.display_name if member else entry["username"]
lines.append(
f"{rank} **{entry['composite']:.2f}** — {name}\n"
f" Avg: {entry['avg_toxicity']:.2f} | "
f"Peak: {entry['max_toxicity']:.2f} | "
f"⚠️ {entry['warnings']} | "
f"🔇 {entry['mutes']} | "
f"📢 {entry['off_topic']}"
)
embed = discord.Embed(
title=f"Drama Leaderboard — {period_label}",
description="\n".join(lines),
color=discord.Color.orange(),
)
embed.set_footer(text=f"{len(rows)} users tracked | {sum(r['messages_analyzed'] for r in rows)} messages analyzed")
await interaction.followup.send(embed=embed)
@bcs_mode.autocomplete("mode")
async def _mode_autocomplete(
self, interaction: discord.Interaction, current: str,

View File

@@ -0,0 +1,57 @@
# Drama Leaderboard Design
## Overview
Public `/drama-leaderboard` slash command that ranks server members by historical drama levels using a composite score derived from DB data. Configurable time period (7d, 30d, 90d, all-time; default 30d).
## Data Sources
All from existing tables — no schema changes needed:
- **Messages + AnalysisResults** (JOIN on MessageId): per-user avg/peak toxicity, message count
- **Actions**: warning, mute, topic_remind, topic_nudge counts per user
## Composite Score Formula
```
score = (avg_toxicity * 0.4) + (peak_toxicity * 0.2) + (action_rate * 0.4)
```
Where `action_rate = min(1.0, (warnings + mutes*2 + off_topic*0.5) / messages_analyzed * 10)`
Normalizes actions relative to message volume so low-volume high-drama users rank appropriately.
## Embed Format
Top 10 users, ranked by composite score:
```
🥇 0.47 — Username
Avg: 0.32 | Peak: 0.81 | ⚠️ 3 | 🔇 1 | 📢 5
```
## Files to Modify
- `utils/database.py` — add `get_drama_leaderboard(guild_id, days)` query method
- `cogs/commands.py` — add `/drama-leaderboard` slash command with `period` choice parameter
## Implementation Plan
### Step 1: Database query method
Add `get_drama_leaderboard(guild_id, days=None)` to `Database`:
- Single SQL query joining Messages, AnalysisResults, Actions
- Returns list of dicts with: user_id, username, avg_toxicity, max_toxicity, warnings, mutes, off_topic, messages_analyzed
- `days=None` means all-time (no date filter)
- Filter by GuildId to scope to the server
### Step 2: Slash command
Add `/drama-leaderboard` to `CommandsCog`:
- Public command (no admin restriction)
- `period` parameter with choices: 7d, 30d, 90d, all-time
- Defer response (DB query may take a moment)
- Compute composite score in Python from query results
- Sort by composite score descending, take top 10
- Build embed with ranked list and per-user stat breakdown
- Handle empty results gracefully

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