From 0ff962c95e3d129ac10223c081d54dc0f00c312f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 27 Feb 2026 15:28:24 -0500 Subject: [PATCH] feat: generate topic drift redirects via LLM with full conversation context Replace static random templates with LLM-generated redirect messages that reference what the user actually said and why it's off-topic. Sass escalates with higher strike counts. Falls back to static templates if LLM fails or use_llm is disabled in config. Co-Authored-By: Claude Opus 4.6 --- cogs/sentiment/topic_drift.py | 160 +++++++++++++++++++++++++++------- config.yaml | 16 +++- prompts/topic_redirect.txt | 18 ++++ 3 files changed, 162 insertions(+), 32 deletions(-) create mode 100644 prompts/topic_redirect.txt diff --git a/cogs/sentiment/topic_drift.py b/cogs/sentiment/topic_drift.py index 216c4b1..b83d8f0 100644 --- a/cogs/sentiment/topic_drift.py +++ b/cogs/sentiment/topic_drift.py @@ -1,5 +1,9 @@ import asyncio import logging +import random +import re +from collections import deque +from pathlib import Path import discord @@ -8,6 +12,105 @@ from cogs.sentiment.state import save_user_state logger = logging.getLogger("bcs.sentiment") +_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "prompts" +_TOPIC_REDIRECT_PROMPT = (_PROMPTS_DIR / "topic_redirect.txt").read_text(encoding="utf-8") + +DEFAULT_TOPIC_REMINDS = [ + "Hey {username}, this is a gaming server 🎮 — maybe take the personal stuff to DMs?", + "{username}, sir this is a gaming channel.", + "Hey {username}, I don't remember this being a therapy session. Gaming talk, please. 🎮", + "{username}, I'm gonna need you to take that energy to DMs. This channel has a vibe to protect.", + "Not to be dramatic {username}, but this is wildly off-topic. Back to gaming? 🎮", +] + +DEFAULT_TOPIC_NUDGES = [ + "{username}, we've been over this. Gaming. Channel. Please. 🎮", + "{username}, you keep drifting off-topic like it's a speedrun category. Reel it in.", + "Babe. {username}. The gaming channel. We talked about this. 😭", + "{username}, I will not ask again (I will definitely ask again). Stay on topic. 🎮", + "{username}, at this point I'm keeping score. That's off-topic strike {count}. Gaming talk only!", + "Look, {username}, I love the enthusiasm but this ain't the channel for it. Back to games. 🎮", +] + +# Per-channel deque of recent LLM-generated redirect messages (for variety) +_recent_redirects: dict[int, deque] = {} + + +def _get_recent_redirects(channel_id: int) -> list[str]: + if channel_id in _recent_redirects: + return list(_recent_redirects[channel_id]) + return [] + + +def _record_redirect(channel_id: int, text: str): + if channel_id not in _recent_redirects: + _recent_redirects[channel_id] = deque(maxlen=5) + _recent_redirects[channel_id].append(text) + + +def _strip_brackets(text: str) -> str: + """Strip leaked LLM metadata brackets (same approach as ChatCog).""" + segments = re.split(r"^\s*\[[^\]]*\]\s*$", text, flags=re.MULTILINE) + segments = [s.strip() for s in segments if s.strip()] + return segments[-1] if segments else "" + + +async def _generate_llm_redirect( + bot, message: discord.Message, topic_category: str, + topic_reasoning: str, count: int, +) -> str | None: + """Ask the LLM chat model to generate a topic redirect message.""" + recent = _get_recent_redirects(message.channel.id) + + user_prompt = ( + f"Username: {message.author.display_name}\n" + f"Channel: #{getattr(message.channel, 'name', 'unknown')}\n" + f"Off-topic category: {topic_category}\n" + f"Why it's off-topic: {topic_reasoning}\n" + f"Off-topic strike count: {count}\n" + f"What they said: {message.content[:300]}" + ) + + messages = [{"role": "user", "content": user_prompt}] + + effective_prompt = _TOPIC_REDIRECT_PROMPT + if recent: + avoid_block = "\n".join(f"- {r}" for r in recent) + effective_prompt += ( + "\n\nIMPORTANT — you recently sent these redirects in the same channel. " + "Do NOT repeat any of these. Be completely different.\n" + + avoid_block + ) + + try: + response = await bot.llm_chat.chat( + messages, effective_prompt, + ) + except Exception: + logger.exception("LLM topic redirect generation failed") + return None + + if response: + response = _strip_brackets(response) + + return response if response else None + + +def _static_fallback(bot, message: discord.Message, count: int) -> str: + """Pick a static template message as fallback.""" + messages_config = bot.config.get("messages", {}) + if count >= 2: + pool = messages_config.get("topic_nudges", DEFAULT_TOPIC_NUDGES) + if isinstance(pool, str): + pool = [pool] + else: + pool = messages_config.get("topic_reminds", DEFAULT_TOPIC_REMINDS) + if isinstance(pool, str): + pool = [pool] + return random.choice(pool).format( + username=message.author.display_name, count=count, + ) + async def handle_topic_drift( bot, message: discord.Message, topic_category: str, topic_reasoning: str, @@ -33,46 +136,43 @@ async def handle_topic_drift( return count = tracker.record_off_topic(user_id) - messages_config = bot.config.get("messages", {}) + action_type = "topic_nudge" if count >= 2 else "topic_remind" - if count >= 2: - nudge_text = messages_config.get( - "topic_nudge", - "{username}, let's keep it to gaming talk in here.", - ).format(username=message.author.display_name) - await message.channel.send(nudge_text) + # Generate the redirect message + use_llm = config.get("use_llm", False) + redirect_text = None + if use_llm: + redirect_text = await _generate_llm_redirect( + bot, message, topic_category, topic_reasoning, count, + ) + + if redirect_text: + _record_redirect(message.channel.id, redirect_text) + else: + redirect_text = _static_fallback(bot, message, count) + + await message.channel.send(redirect_text) + + if action_type == "topic_nudge": await log_action( message.guild, f"**TOPIC NUDGE** | {message.author.mention} | " f"Off-topic count: {count} | Category: {topic_category}", ) - logger.info("Topic nudge for %s (count %d)", message.author, count) - - asyncio.create_task(bot.db.save_action( - guild_id=message.guild.id, user_id=user_id, - username=message.author.display_name, - action_type="topic_nudge", message_id=db_message_id, - details=f"off_topic_count={count} category={topic_category}", - )) - save_user_state(bot, dirty_users, user_id) - else: - remind_text = messages_config.get( - "topic_remind", - "Hey {username}, this is a gaming server \u2014 maybe take the personal stuff to DMs?", - ).format(username=message.author.display_name) - await message.channel.send(remind_text) await log_action( message.guild, f"**TOPIC REMIND** | {message.author.mention} | " f"Category: {topic_category} | {topic_reasoning}", ) - logger.info("Topic remind for %s (count %d)", message.author, count) - asyncio.create_task(bot.db.save_action( - guild_id=message.guild.id, user_id=user_id, - username=message.author.display_name, - action_type="topic_remind", message_id=db_message_id, - details=f"off_topic_count={count} category={topic_category} reasoning={topic_reasoning}", - )) - save_user_state(bot, dirty_users, user_id) + logger.info("Topic %s for %s (count %d)", action_type.replace("topic_", ""), message.author, count) + + asyncio.create_task(bot.db.save_action( + guild_id=message.guild.id, user_id=user_id, + username=message.author.display_name, + action_type=action_type, message_id=db_message_id, + details=f"off_topic_count={count} category={topic_category}" + + (f" reasoning={topic_reasoning}" if action_type == "topic_remind" else ""), + )) + save_user_state(bot, dirty_users, user_id) diff --git a/config.yaml b/config.yaml index 8252da8..fc8937c 100644 --- a/config.yaml +++ b/config.yaml @@ -29,6 +29,7 @@ game_channels: topic_drift: enabled: true + use_llm: true # Generate redirect messages via LLM instead of static templates ignored_channels: ["general"] # Channel names or IDs to skip topic drift monitoring remind_cooldown_minutes: 10 # Don't remind same user more than once per this window escalation_count: 3 # After this many reminds, DM the server owner @@ -48,8 +49,19 @@ messages: warning: "Easy there, {username}. The Breehavior Monitor is watching. \U0001F440" mute_title: "\U0001F6A8 BREEHAVIOR ALERT \U0001F6A8" mute_description: "{username} has been placed in timeout for {duration}.\n\nReason: Sustained elevated drama levels detected.\nDrama Score: {score}/1.0\nCategories: {categories}\n\nCool down and come back when you've resolved your skill issues." - topic_remind: "Hey {username}, this is a gaming server \U0001F3AE — maybe take the personal stuff to DMs?" - topic_nudge: "{username}, we've chatted about this before — let's keep it to gaming talk in here. Personal drama belongs in DMs." + topic_reminds: + - "Hey {username}, this is a gaming server 🎮 — maybe take the personal stuff to DMs?" + - "{username}, sir this is a gaming channel." + - "Hey {username}, I don't remember this being a therapy session. Gaming talk, please. 🎮" + - "{username}, I'm gonna need you to take that energy to DMs. This channel has a vibe to protect." + - "Not to be dramatic {username}, but this is wildly off-topic. Back to gaming? 🎮" + topic_nudges: + - "{username}, we've been over this. Gaming. Channel. Please. 🎮" + - "{username}, you keep drifting off-topic like it's a speedrun category. Reel it in." + - "Babe. {username}. The gaming channel. We talked about this. 😭" + - "{username}, I will not ask again (I will definitely ask again). Stay on topic. 🎮" + - "{username}, at this point I'm keeping score. That's off-topic strike {count}. Gaming talk only!" + - "Look, {username}, I love the enthusiasm but this ain't the channel for it. Back to games. 🎮" topic_owner_dm: "Heads up: {username} keeps going off-topic with personal drama in #{channel}. They've been reminded {count} times. Might need a word." channel_redirect: "Hey {username}, that sounds like {game} talk — head over to {channel} for that!" diff --git a/prompts/topic_redirect.txt b/prompts/topic_redirect.txt new file mode 100644 index 0000000..e83215a --- /dev/null +++ b/prompts/topic_redirect.txt @@ -0,0 +1,18 @@ +You are the hall monitor of a gaming Discord server called "Skill Issue Support Group". Someone just went off-topic in a gaming channel. + +Your job: Write a single short message (1-2 sentences) redirecting them back to gaming talk. + +Style: +- Be snarky and playful, not mean or preachy +- Reference what they were actually talking about — don't be vague +- Steer them back to gaming naturally +- If their strike count is 2+, escalate the sass — you've already asked nicely +- Keep it casual and conversational, like a friend ribbing them + +Do NOT: +- Use more than 2 sentences +- Use hashtags +- Overload with emojis (one is fine) +- Use brackets or metadata formatting +- Break character or mention being an AI +- Be genuinely hurtful \ No newline at end of file