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 <noreply@anthropic.com>
This commit is contained in:
+130
-30
@@ -1,5 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
|
|
||||||
@@ -8,6 +12,105 @@ from cogs.sentiment.state import save_user_state
|
|||||||
|
|
||||||
logger = logging.getLogger("bcs.sentiment")
|
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(
|
async def handle_topic_drift(
|
||||||
bot, message: discord.Message, topic_category: str, topic_reasoning: str,
|
bot, message: discord.Message, topic_category: str, topic_reasoning: str,
|
||||||
@@ -33,46 +136,43 @@ async def handle_topic_drift(
|
|||||||
return
|
return
|
||||||
|
|
||||||
count = tracker.record_off_topic(user_id)
|
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:
|
# Generate the redirect message
|
||||||
nudge_text = messages_config.get(
|
use_llm = config.get("use_llm", False)
|
||||||
"topic_nudge",
|
redirect_text = None
|
||||||
"{username}, let's keep it to gaming talk in here.",
|
if use_llm:
|
||||||
).format(username=message.author.display_name)
|
redirect_text = await _generate_llm_redirect(
|
||||||
await message.channel.send(nudge_text)
|
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(
|
await log_action(
|
||||||
message.guild,
|
message.guild,
|
||||||
f"**TOPIC NUDGE** | {message.author.mention} | "
|
f"**TOPIC NUDGE** | {message.author.mention} | "
|
||||||
f"Off-topic count: {count} | Category: {topic_category}",
|
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:
|
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(
|
await log_action(
|
||||||
message.guild,
|
message.guild,
|
||||||
f"**TOPIC REMIND** | {message.author.mention} | "
|
f"**TOPIC REMIND** | {message.author.mention} | "
|
||||||
f"Category: {topic_category} | {topic_reasoning}",
|
f"Category: {topic_category} | {topic_reasoning}",
|
||||||
)
|
)
|
||||||
logger.info("Topic remind for %s (count %d)", message.author, count)
|
|
||||||
|
|
||||||
asyncio.create_task(bot.db.save_action(
|
logger.info("Topic %s for %s (count %d)", action_type.replace("topic_", ""), message.author, count)
|
||||||
guild_id=message.guild.id, user_id=user_id,
|
|
||||||
username=message.author.display_name,
|
asyncio.create_task(bot.db.save_action(
|
||||||
action_type="topic_remind", message_id=db_message_id,
|
guild_id=message.guild.id, user_id=user_id,
|
||||||
details=f"off_topic_count={count} category={topic_category} reasoning={topic_reasoning}",
|
username=message.author.display_name,
|
||||||
))
|
action_type=action_type, message_id=db_message_id,
|
||||||
save_user_state(bot, dirty_users, user_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)
|
||||||
|
|||||||
+14
-2
@@ -29,6 +29,7 @@ game_channels:
|
|||||||
|
|
||||||
topic_drift:
|
topic_drift:
|
||||||
enabled: true
|
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
|
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
|
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
|
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"
|
warning: "Easy there, {username}. The Breehavior Monitor is watching. \U0001F440"
|
||||||
mute_title: "\U0001F6A8 BREEHAVIOR ALERT \U0001F6A8"
|
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."
|
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_reminds:
|
||||||
topic_nudge: "{username}, we've chatted about this before — let's keep it to gaming talk in here. Personal drama belongs in DMs."
|
- "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."
|
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!"
|
channel_redirect: "Hey {username}, that sounds like {game} talk — head over to {channel} for that!"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user