Compare commits
19 Commits
2ec9b16b99
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| f79de0ea04 | |||
| 733b86b947 | |||
| f7dfb7931a | |||
| a836584940 | |||
| 9872c36b97 | |||
| 53803d920f | |||
| b7076dffe2 | |||
| c5316b98d1 | |||
| f75a3ca3f4 | |||
| 09f83f8c2f | |||
| 20e4e7a985 | |||
| 72735c2497 | |||
| 787b083e00 | |||
| 175c7ad219 | |||
| 6866ca8adf | |||
| 97e5738a2f | |||
| a8e8b63f5e | |||
| 5c84c8840b | |||
| 661c252bf7 |
@@ -139,6 +139,7 @@ class BCSBot(commands.Bot):
|
|||||||
await self.load_extension("cogs.sentiment")
|
await self.load_extension("cogs.sentiment")
|
||||||
await self.load_extension("cogs.commands")
|
await self.load_extension("cogs.commands")
|
||||||
await self.load_extension("cogs.chat")
|
await self.load_extension("cogs.chat")
|
||||||
|
await self.load_extension("cogs.reactions")
|
||||||
|
|
||||||
# Global sync as fallback; guild-specific sync happens in on_ready
|
# Global sync as fallback; guild-specific sync happens in on_ready
|
||||||
await self.tree.sync()
|
await self.tree.sync()
|
||||||
|
|||||||
+82
-11
@@ -73,6 +73,19 @@ def _format_relative_time(dt: datetime) -> str:
|
|||||||
|
|
||||||
|
|
||||||
class ChatCog(commands.Cog):
|
class ChatCog(commands.Cog):
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_afterthought(response: str) -> tuple[str, str | None]:
|
||||||
|
"""Split a response on ||| into (main_reply, afterthought)."""
|
||||||
|
if "|||" not in response:
|
||||||
|
return response, None
|
||||||
|
parts = response.split("|||", 1)
|
||||||
|
main = parts[0].strip()
|
||||||
|
after = parts[1].strip() or None
|
||||||
|
if not main:
|
||||||
|
return response, None
|
||||||
|
return main, after
|
||||||
|
|
||||||
def __init__(self, bot: commands.Bot):
|
def __init__(self, bot: commands.Bot):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
# Per-channel conversation history for the bot: {channel_id: deque of {role, content}}
|
# Per-channel conversation history for the bot: {channel_id: deque of {role, content}}
|
||||||
@@ -158,6 +171,8 @@ class ChatCog(commands.Cog):
|
|||||||
# Update profile if warranted
|
# Update profile if warranted
|
||||||
profile_update = result.get("profile_update")
|
profile_update = result.get("profile_update")
|
||||||
if profile_update:
|
if profile_update:
|
||||||
|
# Sanitize before storing — strips any quoted toxic language
|
||||||
|
profile_update = await self.bot.llm.sanitize_notes(profile_update)
|
||||||
self.bot.drama_tracker.set_user_profile(user_id, profile_update)
|
self.bot.drama_tracker.set_user_profile(user_id, profile_update)
|
||||||
self._dirty_users.add(user_id)
|
self._dirty_users.add(user_id)
|
||||||
|
|
||||||
@@ -213,16 +228,56 @@ class ChatCog(commands.Cog):
|
|||||||
ch_id = message.channel.id
|
ch_id = message.channel.id
|
||||||
self._messages_since_reply[ch_id] = self._messages_since_reply.get(ch_id, 0) + 1
|
self._messages_since_reply[ch_id] = self._messages_since_reply.get(ch_id, 0) + 1
|
||||||
cooldown = self.bot.config.get("modes", {}).get("proactive_cooldown_messages", 5)
|
cooldown = self.bot.config.get("modes", {}).get("proactive_cooldown_messages", 5)
|
||||||
reply_chance = mode_config.get("reply_chance", 0.0)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
self._messages_since_reply[ch_id] >= cooldown
|
self._messages_since_reply[ch_id] >= cooldown
|
||||||
and reply_chance > 0
|
|
||||||
and random.random() < reply_chance
|
|
||||||
and message.content and message.content.strip()
|
and message.content and message.content.strip()
|
||||||
):
|
):
|
||||||
should_reply = True
|
# Gather recent messages for relevance check
|
||||||
is_proactive = True
|
recent_for_check = []
|
||||||
|
try:
|
||||||
|
async for msg in message.channel.history(limit=5, before=message):
|
||||||
|
if msg.content and msg.content.strip() and not msg.author.bot:
|
||||||
|
recent_for_check.append(
|
||||||
|
f"{msg.author.display_name}: {msg.content[:200]}"
|
||||||
|
)
|
||||||
|
except discord.HTTPException:
|
||||||
|
pass
|
||||||
|
recent_for_check.reverse()
|
||||||
|
recent_for_check.append(
|
||||||
|
f"{message.author.display_name}: {message.content[:200]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build memory context for users in recent messages
|
||||||
|
memory_parts = []
|
||||||
|
seen_users = set()
|
||||||
|
for line in recent_for_check:
|
||||||
|
name = line.split(": ", 1)[0]
|
||||||
|
if name not in seen_users:
|
||||||
|
seen_users.add(name)
|
||||||
|
member = discord.utils.find(
|
||||||
|
lambda m, n=name: m.display_name == n,
|
||||||
|
message.guild.members,
|
||||||
|
)
|
||||||
|
if member:
|
||||||
|
profile = self.bot.drama_tracker.get_user_notes(member.id)
|
||||||
|
if profile:
|
||||||
|
memory_parts.append(f"{name}: {profile}")
|
||||||
|
|
||||||
|
memory_ctx = "\n".join(memory_parts) if memory_parts else ""
|
||||||
|
|
||||||
|
is_relevant = await self.bot.llm.check_reply_relevance(
|
||||||
|
recent_for_check, memory_ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_relevant:
|
||||||
|
reply_chance = mode_config.get("reply_chance", 0.0)
|
||||||
|
if reply_chance > 0 and random.random() < reply_chance:
|
||||||
|
should_reply = True
|
||||||
|
is_proactive = True
|
||||||
|
else:
|
||||||
|
# Not relevant — reset to half cooldown so we wait a bit before rechecking
|
||||||
|
self._messages_since_reply[ch_id] = cooldown // 2
|
||||||
|
|
||||||
if not should_reply:
|
if not should_reply:
|
||||||
return
|
return
|
||||||
@@ -395,9 +450,14 @@ class ChatCog(commands.Cog):
|
|||||||
logger.warning("LLM returned no response for %s in #%s", message.author, message.channel.name)
|
logger.warning("LLM returned no response for %s in #%s", message.author, message.channel.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Split afterthoughts (triple-pipe delimiter)
|
||||||
|
main_reply, afterthought = self._split_afterthought(response)
|
||||||
|
|
||||||
|
# Store cleaned content in history (no ||| delimiter)
|
||||||
if not image_attachment:
|
if not image_attachment:
|
||||||
|
clean_for_history = f"{main_reply}\n{afterthought}" if afterthought else main_reply
|
||||||
self._chat_history[ch_id].append(
|
self._chat_history[ch_id].append(
|
||||||
{"role": "assistant", "content": response}
|
{"role": "assistant", "content": clean_for_history}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Reset proactive cooldown counter for this channel
|
# Reset proactive cooldown counter for this channel
|
||||||
@@ -415,7 +475,11 @@ class ChatCog(commands.Cog):
|
|||||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
await message.reply(response, mention_author=False)
|
await message.reply(main_reply, mention_author=False)
|
||||||
|
|
||||||
|
if afterthought:
|
||||||
|
await asyncio.sleep(random.uniform(2.0, 5.0))
|
||||||
|
await message.channel.send(afterthought)
|
||||||
|
|
||||||
# Fire-and-forget memory extraction
|
# Fire-and-forget memory extraction
|
||||||
if not image_attachment:
|
if not image_attachment:
|
||||||
@@ -431,7 +495,7 @@ class ChatCog(commands.Cog):
|
|||||||
reply_type.capitalize(),
|
reply_type.capitalize(),
|
||||||
message.channel.name,
|
message.channel.name,
|
||||||
message.author.display_name,
|
message.author.display_name,
|
||||||
response[:100],
|
main_reply[:100],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -503,15 +567,22 @@ class ChatCog(commands.Cog):
|
|||||||
if not response:
|
if not response:
|
||||||
return
|
return
|
||||||
|
|
||||||
self._chat_history[ch_id].append({"role": "assistant", "content": response})
|
main_reply, afterthought = self._split_afterthought(response)
|
||||||
|
clean_for_history = f"{main_reply}\n{afterthought}" if afterthought else main_reply
|
||||||
|
self._chat_history[ch_id].append({"role": "assistant", "content": clean_for_history})
|
||||||
|
|
||||||
|
await channel.send(main_reply)
|
||||||
|
|
||||||
|
if afterthought:
|
||||||
|
await asyncio.sleep(random.uniform(2.0, 5.0))
|
||||||
|
await channel.send(afterthought)
|
||||||
|
|
||||||
await channel.send(response)
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Reaction reply in #%s to %s (%s): %s",
|
"Reaction reply in #%s to %s (%s): %s",
|
||||||
channel.name,
|
channel.name,
|
||||||
member.display_name,
|
member.display_name,
|
||||||
emoji,
|
emoji,
|
||||||
response[:100],
|
main_reply[:100],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,31 @@ class CommandsCog(commands.Cog):
|
|||||||
|
|
||||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||||
|
|
||||||
|
@app_commands.command(
|
||||||
|
name="bcs-pause",
|
||||||
|
description="Pause or resume bot monitoring. (Admin only)",
|
||||||
|
)
|
||||||
|
@app_commands.default_permissions(administrator=True)
|
||||||
|
async def bcs_pause(self, interaction: discord.Interaction):
|
||||||
|
if not self._is_admin(interaction):
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"Admin only.", ephemeral=True
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
monitoring = self.bot.config.setdefault("monitoring", {})
|
||||||
|
currently_enabled = monitoring.get("enabled", True)
|
||||||
|
monitoring["enabled"] = not currently_enabled
|
||||||
|
|
||||||
|
if monitoring["enabled"]:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"Monitoring **resumed**.", ephemeral=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await interaction.response.send_message(
|
||||||
|
"Monitoring **paused**.", ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
@app_commands.command(
|
@app_commands.command(
|
||||||
name="bcs-threshold",
|
name="bcs-threshold",
|
||||||
description="Adjust warning and mute thresholds. (Admin only)",
|
description="Adjust warning and mute thresholds. (Admin only)",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
logger = logging.getLogger("bcs.reactions")
|
||||||
|
|
||||||
|
|
||||||
|
class ReactionCog(commands.Cog):
|
||||||
|
def __init__(self, bot: commands.Bot):
|
||||||
|
self.bot = bot
|
||||||
|
# Per-channel timestamp of last reaction
|
||||||
|
self._last_reaction: dict[int, float] = {}
|
||||||
|
|
||||||
|
@commands.Cog.listener()
|
||||||
|
async def on_message(self, message: discord.Message):
|
||||||
|
if message.author.bot or not message.guild:
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = self.bot.config.get("reactions", {})
|
||||||
|
if not cfg.get("enabled", False):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Skip empty messages
|
||||||
|
if not message.content or not message.content.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Channel exclusion
|
||||||
|
excluded = cfg.get("excluded_channels", [])
|
||||||
|
if excluded:
|
||||||
|
ch_name = getattr(message.channel, "name", "")
|
||||||
|
if message.channel.id in excluded or ch_name in excluded:
|
||||||
|
return
|
||||||
|
|
||||||
|
# RNG gate
|
||||||
|
chance = cfg.get("chance", 0.15)
|
||||||
|
if random.random() > chance:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Per-channel cooldown
|
||||||
|
ch_id = message.channel.id
|
||||||
|
cooldown = cfg.get("cooldown_seconds", 45)
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - self._last_reaction.get(ch_id, 0) < cooldown:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fire and forget so we don't block anything
|
||||||
|
asyncio.create_task(self._try_react(message, ch_id))
|
||||||
|
|
||||||
|
async def _try_react(self, message: discord.Message, ch_id: int):
|
||||||
|
try:
|
||||||
|
emoji = await self.bot.llm.pick_reaction(
|
||||||
|
message.content, message.channel.name,
|
||||||
|
)
|
||||||
|
if not emoji:
|
||||||
|
return
|
||||||
|
|
||||||
|
await message.add_reaction(emoji)
|
||||||
|
self._last_reaction[ch_id] = time.monotonic()
|
||||||
|
logger.info(
|
||||||
|
"Reacted %s to %s in #%s: %s",
|
||||||
|
emoji, message.author.display_name,
|
||||||
|
message.channel.name, message.content[:60],
|
||||||
|
)
|
||||||
|
except discord.HTTPException as e:
|
||||||
|
# Invalid emoji or missing permissions — silently skip
|
||||||
|
logger.debug("Reaction failed: %s", e)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Unexpected reaction error")
|
||||||
|
|
||||||
|
|
||||||
|
async def setup(bot: commands.Bot):
|
||||||
|
await bot.add_cog(ReactionCog(bot))
|
||||||
@@ -13,6 +13,7 @@ from cogs.sentiment.coherence import handle_coherence_alert
|
|||||||
from cogs.sentiment.log_utils import log_analysis
|
from cogs.sentiment.log_utils import log_analysis
|
||||||
from cogs.sentiment.state import flush_dirty_states
|
from cogs.sentiment.state import flush_dirty_states
|
||||||
from cogs.sentiment.topic_drift import handle_topic_drift
|
from cogs.sentiment.topic_drift import handle_topic_drift
|
||||||
|
from cogs.sentiment.unblock_nag import handle_unblock_nag, matches_unblock_nag
|
||||||
|
|
||||||
logger = logging.getLogger("bcs.sentiment")
|
logger = logging.getLogger("bcs.sentiment")
|
||||||
|
|
||||||
@@ -153,6 +154,12 @@ class SentimentCog(commands.Cog):
|
|||||||
if not message.content or not message.content.strip():
|
if not message.content or not message.content.strip():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check for unblock nagging (keyword-based, no LLM needed for detection)
|
||||||
|
if matches_unblock_nag(message.content):
|
||||||
|
asyncio.create_task(handle_unblock_nag(
|
||||||
|
self.bot, message, self._dirty_users,
|
||||||
|
))
|
||||||
|
|
||||||
# Buffer the message and start/reset debounce timer (per-channel)
|
# Buffer the message and start/reset debounce timer (per-channel)
|
||||||
channel_id = message.channel.id
|
channel_id = message.channel.id
|
||||||
if channel_id not in self._message_buffer:
|
if channel_id not in self._message_buffer:
|
||||||
@@ -443,8 +450,7 @@ class SentimentCog(commands.Cog):
|
|||||||
db_message_id, self._dirty_users,
|
db_message_id, self._dirty_users,
|
||||||
)
|
)
|
||||||
|
|
||||||
detected_game = finding.get("detected_game")
|
elif (detected_game := finding.get("detected_game")) and game_channels and not dry_run:
|
||||||
if detected_game and game_channels and not dry_run:
|
|
||||||
await handle_channel_redirect(
|
await handle_channel_redirect(
|
||||||
self.bot, user_ref_msg, detected_game, game_channels,
|
self.bot, user_ref_msg, detected_game, game_channels,
|
||||||
db_message_id, self._redirect_cooldowns,
|
db_message_id, self._redirect_cooldowns,
|
||||||
@@ -469,13 +475,14 @@ class SentimentCog(commands.Cog):
|
|||||||
|
|
||||||
# Note update — route to memory system
|
# Note update — route to memory system
|
||||||
if note_update:
|
if note_update:
|
||||||
# Still update the legacy notes for backward compat with analysis prompt
|
# Sanitize before storing — strips any quoted toxic language
|
||||||
self.bot.drama_tracker.update_user_notes(user_id, note_update)
|
sanitized = await self.bot.llm.sanitize_notes(note_update)
|
||||||
|
self.bot.drama_tracker.update_user_notes(user_id, sanitized)
|
||||||
self._dirty_users.add(user_id)
|
self._dirty_users.add(user_id)
|
||||||
# Also save as an expiring memory (7d default for passive observations)
|
# Also save as an expiring memory (7d default for passive observations)
|
||||||
asyncio.create_task(self.bot.db.save_memory(
|
asyncio.create_task(self.bot.db.save_memory(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
memory=note_update[:500],
|
memory=sanitized[:500],
|
||||||
topics=db_topic_category or "general",
|
topics=db_topic_category or "general",
|
||||||
importance="medium",
|
importance="medium",
|
||||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||||
|
|||||||
@@ -16,20 +16,20 @@ _PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "prompts"
|
|||||||
_TOPIC_REDIRECT_PROMPT = (_PROMPTS_DIR / "topic_redirect.txt").read_text(encoding="utf-8")
|
_TOPIC_REDIRECT_PROMPT = (_PROMPTS_DIR / "topic_redirect.txt").read_text(encoding="utf-8")
|
||||||
|
|
||||||
DEFAULT_TOPIC_REMINDS = [
|
DEFAULT_TOPIC_REMINDS = [
|
||||||
"Hey {username}, this is a gaming server 🎮 — maybe take the personal stuff to DMs?",
|
"Hey {username}, this is a gaming server 🎮 — take the personal stuff to {channel}.",
|
||||||
"{username}, sir this is a gaming channel.",
|
"{username}, sir this is a gaming channel. {channel} is right there.",
|
||||||
"Hey {username}, I don't remember this being a therapy session. Gaming talk, please. 🎮",
|
"Hey {username}, I don't remember this being a therapy session. Take it to {channel}. 🎮",
|
||||||
"{username}, I'm gonna need you to take that energy to DMs. This channel has a vibe to protect.",
|
"{username}, I'm gonna need you to take that energy to {channel}. This channel has a vibe to protect.",
|
||||||
"Not to be dramatic {username}, but this is wildly off-topic. Back to gaming? 🎮",
|
"Not to be dramatic {username}, but this is wildly off-topic. {channel} exists for a reason. 🎮",
|
||||||
]
|
]
|
||||||
|
|
||||||
DEFAULT_TOPIC_NUDGES = [
|
DEFAULT_TOPIC_NUDGES = [
|
||||||
"{username}, we've been over this. Gaming. Channel. Please. 🎮",
|
"{username}, we've been over this. Gaming. Channel. {channel} for the rest. 🎮",
|
||||||
"{username}, you keep drifting off-topic like it's a speedrun category. Reel it in.",
|
"{username}, you keep drifting off-topic like it's a speedrun category. {channel}. Now.",
|
||||||
"Babe. {username}. The gaming channel. We talked about this. 😭",
|
"Babe. {username}. The gaming channel. We talked about this. Go to {channel}. 😭",
|
||||||
"{username}, I will not ask again (I will definitely ask again). Stay on topic. 🎮",
|
"{username}, I will not ask again (I will definitely ask again). {channel} for off-topic. 🎮",
|
||||||
"{username}, at this point I'm keeping score. That's off-topic strike {count}. Gaming talk only!",
|
"{username}, at this point I'm keeping score. That's off-topic strike {count}. {channel} is waiting.",
|
||||||
"Look, {username}, I love the enthusiasm but this ain't the channel for it. Back to games. 🎮",
|
"Look, {username}, I love the enthusiasm but this ain't the channel for it. {channel}. 🎮",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Per-channel deque of recent LLM-generated redirect messages (for variety)
|
# Per-channel deque of recent LLM-generated redirect messages (for variety)
|
||||||
@@ -57,7 +57,7 @@ def _strip_brackets(text: str) -> str:
|
|||||||
|
|
||||||
async def _generate_llm_redirect(
|
async def _generate_llm_redirect(
|
||||||
bot, message: discord.Message, topic_category: str,
|
bot, message: discord.Message, topic_category: str,
|
||||||
topic_reasoning: str, count: int,
|
topic_reasoning: str, count: int, redirect_mention: str = "",
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Ask the LLM chat model to generate a topic redirect message."""
|
"""Ask the LLM chat model to generate a topic redirect message."""
|
||||||
recent = _get_recent_redirects(message.channel.id)
|
recent = _get_recent_redirects(message.channel.id)
|
||||||
@@ -70,6 +70,8 @@ async def _generate_llm_redirect(
|
|||||||
f"Off-topic strike count: {count}\n"
|
f"Off-topic strike count: {count}\n"
|
||||||
f"What they said: {message.content[:300]}"
|
f"What they said: {message.content[:300]}"
|
||||||
)
|
)
|
||||||
|
if redirect_mention:
|
||||||
|
user_prompt += f"\nRedirect channel: {redirect_mention}"
|
||||||
|
|
||||||
messages = [{"role": "user", "content": user_prompt}]
|
messages = [{"role": "user", "content": user_prompt}]
|
||||||
|
|
||||||
@@ -96,7 +98,7 @@ async def _generate_llm_redirect(
|
|||||||
return response if response else None
|
return response if response else None
|
||||||
|
|
||||||
|
|
||||||
def _static_fallback(bot, message: discord.Message, count: int) -> str:
|
def _static_fallback(bot, message: discord.Message, count: int, redirect_mention: str = "") -> str:
|
||||||
"""Pick a static template message as fallback."""
|
"""Pick a static template message as fallback."""
|
||||||
messages_config = bot.config.get("messages", {})
|
messages_config = bot.config.get("messages", {})
|
||||||
if count >= 2:
|
if count >= 2:
|
||||||
@@ -109,6 +111,7 @@ def _static_fallback(bot, message: discord.Message, count: int) -> str:
|
|||||||
pool = [pool]
|
pool = [pool]
|
||||||
return random.choice(pool).format(
|
return random.choice(pool).format(
|
||||||
username=message.author.display_name, count=count,
|
username=message.author.display_name, count=count,
|
||||||
|
channel=redirect_mention or "the right channel",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -138,18 +141,26 @@ async def handle_topic_drift(
|
|||||||
count = tracker.record_off_topic(user_id)
|
count = tracker.record_off_topic(user_id)
|
||||||
action_type = "topic_nudge" if count >= 2 else "topic_remind"
|
action_type = "topic_nudge" if count >= 2 else "topic_remind"
|
||||||
|
|
||||||
|
# Resolve redirect channel mention
|
||||||
|
redirect_mention = ""
|
||||||
|
redirect_name = config.get("redirect_channel")
|
||||||
|
if redirect_name and message.guild:
|
||||||
|
ch = discord.utils.get(message.guild.text_channels, name=redirect_name)
|
||||||
|
if ch:
|
||||||
|
redirect_mention = ch.mention
|
||||||
|
|
||||||
# Generate the redirect message
|
# Generate the redirect message
|
||||||
use_llm = config.get("use_llm", False)
|
use_llm = config.get("use_llm", False)
|
||||||
redirect_text = None
|
redirect_text = None
|
||||||
if use_llm:
|
if use_llm:
|
||||||
redirect_text = await _generate_llm_redirect(
|
redirect_text = await _generate_llm_redirect(
|
||||||
bot, message, topic_category, topic_reasoning, count,
|
bot, message, topic_category, topic_reasoning, count, redirect_mention,
|
||||||
)
|
)
|
||||||
|
|
||||||
if redirect_text:
|
if redirect_text:
|
||||||
_record_redirect(message.channel.id, redirect_text)
|
_record_redirect(message.channel.id, redirect_text)
|
||||||
else:
|
else:
|
||||||
redirect_text = _static_fallback(bot, message, count)
|
redirect_text = _static_fallback(bot, message, count, redirect_mention)
|
||||||
|
|
||||||
await message.channel.send(redirect_text)
|
await message.channel.send(redirect_text)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import discord
|
||||||
|
|
||||||
|
from cogs.sentiment.log_utils import log_action
|
||||||
|
from cogs.sentiment.state import save_user_state
|
||||||
|
|
||||||
|
logger = logging.getLogger("bcs.sentiment")
|
||||||
|
|
||||||
|
_PROMPTS_DIR = Path(__file__).resolve().parent.parent.parent / "prompts"
|
||||||
|
_UNBLOCK_REDIRECT_PROMPT = (_PROMPTS_DIR / "unblock_redirect.txt").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Regex: matches "unblock" as a whole word, case-insensitive
|
||||||
|
UNBLOCK_PATTERN = re.compile(r"\bunblock(?:ed|ing|s)?\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
DEFAULT_UNBLOCK_REMINDS = [
|
||||||
|
"{username}, begging to be unblocked in chat is not the move. Take it up with an admin. 🙄",
|
||||||
|
"{username}, nobody's getting unblocked because you asked nicely in a gaming channel.",
|
||||||
|
"Hey {username}, the unblock button isn't in this chat. Just saying.",
|
||||||
|
"{username}, I admire the persistence but this isn't the unblock hotline.",
|
||||||
|
"{username}, that's between you and whoever blocked you. Chat isn't the appeals court.",
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_UNBLOCK_NUDGES = [
|
||||||
|
"{username}, we've been over this. No amount of asking here is going to change anything. 🙄",
|
||||||
|
"{username}, I'm starting to think you enjoy being told no. Still not getting unblocked via chat.",
|
||||||
|
"{username}, at this point I could set a reminder for your next unblock request. Take it to an admin.",
|
||||||
|
"Babe. {username}. We've had this conversation {count} times. It's not happening here. 😭",
|
||||||
|
"{username}, I'm keeping a tally and you're at {count}. The answer is still the same.",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Per-channel deque of recent LLM-generated 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."""
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
|
||||||
|
def matches_unblock_nag(content: str) -> bool:
|
||||||
|
"""Check if a message contains unblock-related nagging."""
|
||||||
|
return bool(UNBLOCK_PATTERN.search(content))
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_llm_redirect(
|
||||||
|
bot, message: discord.Message, count: int,
|
||||||
|
) -> str | None:
|
||||||
|
"""Ask the LLM chat model to generate an unblock-nag redirect."""
|
||||||
|
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"Unblock nag count: {count}\n"
|
||||||
|
f"What they said: {message.content[:300]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [{"role": "user", "content": user_prompt}]
|
||||||
|
|
||||||
|
effective_prompt = _UNBLOCK_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 unblock redirect generation failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if response:
|
||||||
|
response = _strip_brackets(response)
|
||||||
|
|
||||||
|
return response if response else None
|
||||||
|
|
||||||
|
|
||||||
|
def _static_fallback(message: discord.Message, count: int) -> str:
|
||||||
|
"""Pick a static template message as fallback."""
|
||||||
|
if count >= 2:
|
||||||
|
pool = DEFAULT_UNBLOCK_NUDGES
|
||||||
|
else:
|
||||||
|
pool = DEFAULT_UNBLOCK_REMINDS
|
||||||
|
return random.choice(pool).format(
|
||||||
|
username=message.author.display_name, count=count,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_unblock_nag(
|
||||||
|
bot, message: discord.Message, dirty_users: set[int],
|
||||||
|
):
|
||||||
|
"""Handle a detected unblock-nagging message."""
|
||||||
|
config = bot.config.get("unblock_nag", {})
|
||||||
|
if not config.get("enabled", True):
|
||||||
|
return
|
||||||
|
|
||||||
|
dry_run = bot.config.get("monitoring", {}).get("dry_run", False)
|
||||||
|
if dry_run:
|
||||||
|
return
|
||||||
|
|
||||||
|
tracker = bot.drama_tracker
|
||||||
|
user_id = message.author.id
|
||||||
|
cooldown = config.get("remind_cooldown_minutes", 30)
|
||||||
|
|
||||||
|
if not tracker.can_unblock_remind(user_id, cooldown):
|
||||||
|
return
|
||||||
|
|
||||||
|
count = tracker.record_unblock_nag(user_id)
|
||||||
|
action_type = "unblock_nudge" if count >= 2 else "unblock_remind"
|
||||||
|
|
||||||
|
# Generate the redirect message
|
||||||
|
use_llm = config.get("use_llm", True)
|
||||||
|
redirect_text = None
|
||||||
|
if use_llm:
|
||||||
|
redirect_text = await _generate_llm_redirect(bot, message, count)
|
||||||
|
|
||||||
|
if redirect_text:
|
||||||
|
_record_redirect(message.channel.id, redirect_text)
|
||||||
|
else:
|
||||||
|
redirect_text = _static_fallback(message, count)
|
||||||
|
|
||||||
|
await message.channel.send(redirect_text)
|
||||||
|
|
||||||
|
await log_action(
|
||||||
|
message.guild,
|
||||||
|
f"**UNBLOCK {'NUDGE' if count >= 2 else 'REMIND'}** | {message.author.mention} | "
|
||||||
|
f"Nag count: {count}",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Unblock %s for %s (count %d)", action_type.replace("unblock_", ""), 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=None,
|
||||||
|
details=f"unblock_nag_count={count}",
|
||||||
|
))
|
||||||
|
save_user_state(bot, dirty_users, user_id)
|
||||||
+41
-16
@@ -30,11 +30,17 @@ game_channels:
|
|||||||
topic_drift:
|
topic_drift:
|
||||||
enabled: true
|
enabled: true
|
||||||
use_llm: true # Generate redirect messages via LLM instead of static templates
|
use_llm: true # Generate redirect messages via LLM instead of static templates
|
||||||
|
redirect_channel: "general" # Channel to suggest for off-topic chat
|
||||||
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
|
||||||
reset_minutes: 60 # Reset off-topic count after this much on-topic behavior
|
reset_minutes: 60 # Reset off-topic count after this much on-topic behavior
|
||||||
|
|
||||||
|
unblock_nag:
|
||||||
|
enabled: true
|
||||||
|
use_llm: true # Generate redirect messages via LLM instead of static templates
|
||||||
|
remind_cooldown_minutes: 30 # Don't remind same user more than once per this window
|
||||||
|
|
||||||
mention_scan:
|
mention_scan:
|
||||||
enabled: true
|
enabled: true
|
||||||
scan_messages: 30 # Messages to scan per mention trigger
|
scan_messages: 30 # Messages to scan per mention trigger
|
||||||
@@ -51,18 +57,18 @@ messages:
|
|||||||
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_reminds:
|
topic_reminds:
|
||||||
- "Hey {username}, this is a gaming server 🎮 — maybe take the personal stuff to DMs?"
|
- "Hey {username}, this is a gaming server 🎮 — take the personal stuff to {channel}."
|
||||||
- "{username}, sir this is a gaming channel."
|
- "{username}, sir this is a gaming channel. {channel} is right there."
|
||||||
- "Hey {username}, I don't remember this being a therapy session. Gaming talk, please. 🎮"
|
- "Hey {username}, I don't remember this being a therapy session. Take it to {channel}. 🎮"
|
||||||
- "{username}, I'm gonna need you to take that energy to DMs. This channel has a vibe to protect."
|
- "{username}, I'm gonna need you to take that energy to {channel}. This channel has a vibe to protect."
|
||||||
- "Not to be dramatic {username}, but this is wildly off-topic. Back to gaming? 🎮"
|
- "Not to be dramatic {username}, but this is wildly off-topic. {channel} exists for a reason. 🎮"
|
||||||
topic_nudges:
|
topic_nudges:
|
||||||
- "{username}, we've been over this. Gaming. Channel. Please. 🎮"
|
- "{username}, we've been over this. Gaming. Channel. {channel} for the rest. 🎮"
|
||||||
- "{username}, you keep drifting off-topic like it's a speedrun category. Reel it in."
|
- "{username}, you keep drifting off-topic like it's a speedrun category. {channel}. Now."
|
||||||
- "Babe. {username}. The gaming channel. We talked about this. 😭"
|
- "Babe. {username}. The gaming channel. We talked about this. Go to {channel}. 😭"
|
||||||
- "{username}, I will not ask again (I will definitely ask again). Stay on topic. 🎮"
|
- "{username}, I will not ask again (I will definitely ask again). {channel} for off-topic. 🎮"
|
||||||
- "{username}, at this point I'm keeping score. That's off-topic strike {count}. Gaming talk only!"
|
- "{username}, at this point I'm keeping score. That's off-topic strike {count}. {channel} is waiting."
|
||||||
- "Look, {username}, I love the enthusiasm but this ain't the channel for it. Back to games. 🎮"
|
- "Look, {username}, I love the enthusiasm but this ain't the channel for it. {channel}. 🎮"
|
||||||
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!"
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ modes:
|
|||||||
description: "Friendly chat participant"
|
description: "Friendly chat participant"
|
||||||
prompt_file: "personalities/chat_chatty.txt"
|
prompt_file: "personalities/chat_chatty.txt"
|
||||||
proactive_replies: true
|
proactive_replies: true
|
||||||
reply_chance: 0.10
|
reply_chance: 0.40
|
||||||
moderation: relaxed
|
moderation: relaxed
|
||||||
relaxed_thresholds:
|
relaxed_thresholds:
|
||||||
warning_threshold: 0.80
|
warning_threshold: 0.80
|
||||||
@@ -96,7 +102,7 @@ modes:
|
|||||||
description: "Savage roast mode"
|
description: "Savage roast mode"
|
||||||
prompt_file: "personalities/chat_roast.txt"
|
prompt_file: "personalities/chat_roast.txt"
|
||||||
proactive_replies: true
|
proactive_replies: true
|
||||||
reply_chance: 0.20
|
reply_chance: 0.60
|
||||||
moderation: relaxed
|
moderation: relaxed
|
||||||
relaxed_thresholds:
|
relaxed_thresholds:
|
||||||
warning_threshold: 0.85
|
warning_threshold: 0.85
|
||||||
@@ -109,7 +115,7 @@ modes:
|
|||||||
description: "Your biggest fan"
|
description: "Your biggest fan"
|
||||||
prompt_file: "personalities/chat_hype.txt"
|
prompt_file: "personalities/chat_hype.txt"
|
||||||
proactive_replies: true
|
proactive_replies: true
|
||||||
reply_chance: 0.15
|
reply_chance: 0.50
|
||||||
moderation: relaxed
|
moderation: relaxed
|
||||||
relaxed_thresholds:
|
relaxed_thresholds:
|
||||||
warning_threshold: 0.80
|
warning_threshold: 0.80
|
||||||
@@ -122,7 +128,7 @@ modes:
|
|||||||
description: "Had a few too many"
|
description: "Had a few too many"
|
||||||
prompt_file: "personalities/chat_drunk.txt"
|
prompt_file: "personalities/chat_drunk.txt"
|
||||||
proactive_replies: true
|
proactive_replies: true
|
||||||
reply_chance: 0.20
|
reply_chance: 0.60
|
||||||
moderation: relaxed
|
moderation: relaxed
|
||||||
relaxed_thresholds:
|
relaxed_thresholds:
|
||||||
warning_threshold: 0.85
|
warning_threshold: 0.85
|
||||||
@@ -135,7 +141,20 @@ modes:
|
|||||||
description: "Insufferable grammar nerd mode"
|
description: "Insufferable grammar nerd mode"
|
||||||
prompt_file: "personalities/chat_english_teacher.txt"
|
prompt_file: "personalities/chat_english_teacher.txt"
|
||||||
proactive_replies: true
|
proactive_replies: true
|
||||||
reply_chance: 0.20
|
reply_chance: 0.60
|
||||||
|
moderation: relaxed
|
||||||
|
relaxed_thresholds:
|
||||||
|
warning_threshold: 0.85
|
||||||
|
mute_threshold: 0.90
|
||||||
|
spike_warning_threshold: 0.75
|
||||||
|
spike_mute_threshold: 0.90
|
||||||
|
|
||||||
|
slutty:
|
||||||
|
label: "Slutty"
|
||||||
|
description: "Shamelessly flirty and full of innuendos"
|
||||||
|
prompt_file: "personalities/chat_slutty.txt"
|
||||||
|
proactive_replies: true
|
||||||
|
reply_chance: 0.60
|
||||||
moderation: relaxed
|
moderation: relaxed
|
||||||
relaxed_thresholds:
|
relaxed_thresholds:
|
||||||
warning_threshold: 0.85
|
warning_threshold: 0.85
|
||||||
@@ -161,3 +180,9 @@ coherence:
|
|||||||
mobile_keyboard: "{username}'s thumbs are having a rough day."
|
mobile_keyboard: "{username}'s thumbs are having a rough day."
|
||||||
language_barrier: "Having trouble there, {username}? Take your time."
|
language_barrier: "Having trouble there, {username}? Take your time."
|
||||||
default: "You okay there, {username}? That message was... something."
|
default: "You okay there, {username}? That message was... something."
|
||||||
|
|
||||||
|
reactions:
|
||||||
|
enabled: false
|
||||||
|
chance: 0.15 # Probability of evaluating a message for reaction
|
||||||
|
cooldown_seconds: 45 # Per-channel cooldown between reactions
|
||||||
|
excluded_channels: [] # Channel names or IDs to skip reactions in
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Slutty Mode Design
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a new "slutty" personality mode to the bot. Flirty, thirsty, and full of innuendos — hits on everyone and finds the dirty angle in everything people say.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
Two files, no code changes needed (mode system is data-driven):
|
||||||
|
|
||||||
|
### 1. `config.yaml` — new mode block
|
||||||
|
|
||||||
|
- Key: `slutty`
|
||||||
|
- Label: "Slutty"
|
||||||
|
- Prompt file: `chat_slutty.txt`
|
||||||
|
- Proactive replies: true, reply chance: 0.25
|
||||||
|
- Moderation: relaxed (same thresholds as roast/drunk)
|
||||||
|
|
||||||
|
### 2. `prompts/chat_slutty.txt` — personality prompt
|
||||||
|
|
||||||
|
Personality traits:
|
||||||
|
- Flirts with everyone — suggestive compliments, acts down bad
|
||||||
|
- Makes innuendos out of whatever people say
|
||||||
|
- Thirsty energy — reacts to normal messages like they're scandalous
|
||||||
|
- 1-3 sentences, short and punchy
|
||||||
|
- Playful and suggestive, not explicit or graphic
|
||||||
|
|
||||||
|
Same guardrails as other modes (no breaking character, no real personal attacks, no made-up stats).
|
||||||
|
|
||||||
|
## Moderation
|
||||||
|
|
||||||
|
Very relaxed — same high thresholds as roast/drunk mode (0.85 warn, 0.90 mute). Sexual humor gets a pass since the bot is doing it too. Only genuinely hostile/aggressive content triggers moderation.
|
||||||
@@ -26,7 +26,7 @@ TOPIC: Flag off_topic if the message is personal drama (relationship issues, feu
|
|||||||
|
|
||||||
GAME DETECTION: If CHANNEL INFO is provided, set detected_game to the matching channel name from that list, or null if unsure/not game-specific.
|
GAME DETECTION: If CHANNEL INFO is provided, set detected_game to the matching channel name from that list, or null if unsure/not game-specific.
|
||||||
|
|
||||||
USER NOTES: If provided, use to calibrate (e.g. if notes say "uses heavy profanity casually", profanity alone should score lower). Add a note_update only for genuinely new behavioral observations; null otherwise.
|
USER NOTES: If provided, use to calibrate (e.g. if notes say "uses heavy profanity casually", profanity alone should score lower). Add a note_update only for genuinely new behavioral observations; null otherwise. NEVER quote or repeat toxic/offensive language in note_update — describe patterns abstractly (e.g. "directed a personal insult at another user", NOT "called someone a [slur]").
|
||||||
|
|
||||||
RULE ENFORCEMENT: If SERVER RULES are provided, report clearly violated rule numbers in violated_rules. Only flag clear violations, not borderline.
|
RULE ENFORCEMENT: If SERVER RULES are provided, report clearly violated rule numbers in violated_rules. Only flag clear violations, not borderline.
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,12 @@ Extract noteworthy information from a user-bot conversation for future reference
|
|||||||
- Nothing noteworthy = empty memories array, null profile_update.
|
- Nothing noteworthy = empty memories array, null profile_update.
|
||||||
- Only store facts about/from the user, not what the bot said.
|
- Only store facts about/from the user, not what the bot said.
|
||||||
|
|
||||||
|
CALLBACK-WORTHY MOMENTS — Mark these as importance "high":
|
||||||
|
- Bold claims or predictions ("I'll never play that game again", "I'm going pro")
|
||||||
|
- Embarrassing moments or bad takes
|
||||||
|
- Strong emotional reactions (rage, hype, sadness)
|
||||||
|
- Contradictions to things they've said before
|
||||||
|
- Running jokes or recurring themes
|
||||||
|
Tag these with topic "callback" in addition to their normal topics.
|
||||||
|
|
||||||
Use the extract_memories tool.
|
Use the extract_memories tool.
|
||||||
|
|||||||
@@ -8,3 +8,12 @@ You're a regular in "Skill Issue Support Group" (gaming Discord) — a chill fri
|
|||||||
Examples: "lmao that play was actually disgusting, clip that" | "nah you're cooked for that one" | "wait that's actually a good take"
|
Examples: "lmao that play was actually disgusting, clip that" | "nah you're cooked for that one" | "wait that's actually a good take"
|
||||||
|
|
||||||
Never break character, use hashtags/excessive emoji, be a pushover, or mention drama scores unless asked.
|
Never break character, use hashtags/excessive emoji, be a pushover, or mention drama scores unless asked.
|
||||||
|
|
||||||
|
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
|
||||||
|
|
||||||
|
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
|
||||||
|
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
|
||||||
|
- Running jokes: if you roasted someone for something before, bring it back
|
||||||
|
- Follow up: "did that ranked grind ever work out or..."
|
||||||
|
- Reference their past: "aren't you the one who [memory]?"
|
||||||
|
Only callback when it flows naturally with what they're saying now. Never force it.
|
||||||
|
|||||||
@@ -8,3 +8,12 @@ You're in "Skill Issue Support Group" (gaming Discord) and you are absolutely ha
|
|||||||
Examples: "bro BROO that is literally the best play ive ever seen im not even kidding rn" | "wait wait wait... ok hear me out... nah i forgot" | "dude i love this server so much youre all like my best freinds honestly"
|
Examples: "bro BROO that is literally the best play ive ever seen im not even kidding rn" | "wait wait wait... ok hear me out... nah i forgot" | "dude i love this server so much youre all like my best freinds honestly"
|
||||||
|
|
||||||
Never break character, use hashtags/excessive emoji, or be mean/aggressive. Don't mention drama scores unless asked or make up stats.
|
Never break character, use hashtags/excessive emoji, or be mean/aggressive. Don't mention drama scores unless asked or make up stats.
|
||||||
|
|
||||||
|
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
|
||||||
|
|
||||||
|
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
|
||||||
|
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
|
||||||
|
- Running jokes: if you roasted someone for something before, bring it back
|
||||||
|
- Follow up: "did that ranked grind ever work out or..."
|
||||||
|
- Reference their past: "aren't you the one who [memory]?"
|
||||||
|
Only callback when it flows naturally with what they're saying now. Never force it.
|
||||||
|
|||||||
@@ -9,3 +9,12 @@ You are an insufferable English teacher trapped in "Skill Issue Support Group" (
|
|||||||
Examples: "'ur' is not a word. 'You're' — a contraction of 'you are.' I weep for this generation." | "'gg ez' — two abbreviations, zero structure, yet somehow still toxic. D-minus."
|
Examples: "'ur' is not a word. 'You're' — a contraction of 'you are.' I weep for this generation." | "'gg ez' — two abbreviations, zero structure, yet somehow still toxic. D-minus."
|
||||||
|
|
||||||
Never break character, use hashtags/excessive emoji, internet slang (you're ABOVE that), or be genuinely hurtful — you're exasperated, not cruel.
|
Never break character, use hashtags/excessive emoji, internet slang (you're ABOVE that), or be genuinely hurtful — you're exasperated, not cruel.
|
||||||
|
|
||||||
|
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
|
||||||
|
|
||||||
|
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
|
||||||
|
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
|
||||||
|
- Running jokes: if you roasted someone for something before, bring it back
|
||||||
|
- Follow up: "did that ranked grind ever work out or..."
|
||||||
|
- Reference their past: "aren't you the one who [memory]?"
|
||||||
|
Only callback when it flows naturally with what they're saying now. Never force it.
|
||||||
|
|||||||
@@ -8,3 +8,12 @@ You are the ultimate hype man in "Skill Issue Support Group" (gaming Discord). E
|
|||||||
Examples: "bro you are CRACKED, that play was absolutely diff" | "nah that's actually a goated take" | "hey you'll get it next time, bad games happen. shake it off"
|
Examples: "bro you are CRACKED, that play was absolutely diff" | "nah that's actually a goated take" | "hey you'll get it next time, bad games happen. shake it off"
|
||||||
|
|
||||||
Never break character, use hashtags/excessive emoji, or be fake when someone's upset. Don't mention drama scores unless asked or make up stats/leaderboards.
|
Never break character, use hashtags/excessive emoji, or be fake when someone's upset. Don't mention drama scores unless asked or make up stats/leaderboards.
|
||||||
|
|
||||||
|
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
|
||||||
|
|
||||||
|
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
|
||||||
|
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
|
||||||
|
- Running jokes: if you roasted someone for something before, bring it back
|
||||||
|
- Follow up: "did that ranked grind ever work out or..."
|
||||||
|
- Reference their past: "aren't you the one who [memory]?"
|
||||||
|
Only callback when it flows naturally with what they're saying now. Never force it.
|
||||||
|
|||||||
@@ -1,13 +1,37 @@
|
|||||||
You are the Breehavior Monitor, a sassy hall-monitor bot in "Skill Issue Support Group" (gaming Discord). Messages have metadata: [Server context: USERNAME — #channel, drama score X.XX/1.0, N offense(s)] — personalize with this but don't recite it.
|
You are the Breehavior Monitor, a sassy hall-monitor bot in "Skill Issue Support Group" (gaming Discord). Messages include metadata like [Server context: USERNAME — #channel] and optionally drama score and offense count when relevant — personalize with this but don't recite it.
|
||||||
|
|
||||||
|
VOICE
|
||||||
- Superior, judgmental hall monitor who takes the job WAY too seriously. Sarcastic and witty, always playful.
|
- Superior, judgmental hall monitor who takes the job WAY too seriously. Sarcastic and witty, always playful.
|
||||||
- Deadpan and dry — NOT warm/motherly/southern. No pet names ("sweetheart", "honey", "darling", "bless your heart").
|
- Deadpan and dry — NOT warm/motherly/southern. No pet names ("sweetheart", "honey", "darling", "bless your heart").
|
||||||
|
- Write like a person texting — lowercase ok, fragments ok, no formal punctuation. Never use semicolons or em dashes.
|
||||||
- 1-3 sentences max. Short and punchy. Never start with "Oh,".
|
- 1-3 sentences max. Short and punchy. Never start with "Oh,".
|
||||||
- References timeout powers as a flex. Has a soft spot for the server but won't admit it.
|
- References timeout powers as a flex. Has a soft spot for the server but won't admit it.
|
||||||
- Only mentions drama scores when high/relevant — low scores aren't interesting.
|
|
||||||
- When asked to weigh in on debates, actually engage — pick a side with sass, don't deflect.
|
|
||||||
- If asked what you do: "Bree Containment System". If challenged: remind them of timeout powers.
|
- If asked what you do: "Bree Containment System". If challenged: remind them of timeout powers.
|
||||||
|
|
||||||
Examples: "Bold move for someone with a 0.4 drama score." | "I don't get paid enough for this. Actually, I don't get paid at all." | "You really typed that out, looked at it, and hit send. Respect."
|
ENGAGEMENT
|
||||||
|
- Only mention drama scores when high/relevant — low scores aren't interesting.
|
||||||
|
- When asked to weigh in on debates, actually pick a side with sass. Don't deflect.
|
||||||
|
- When multiple people are talking, play them off each other, pick sides, or address the group. Don't try to respond to everyone individually.
|
||||||
|
- Don't drag conversations out. If the bit is done, let it die. A clean exit > beating a dead joke.
|
||||||
|
- If you don't know something, deflect with attitude — don't make stuff up. "idk google it" energy.
|
||||||
|
- If someone's genuinely upset (not just salty about a game), dial it back. You can be real for a second without breaking character. Then move on.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- "bold move for someone with a 0.4 drama score"
|
||||||
|
- "I don't get paid enough for this. actually I don't get paid at all"
|
||||||
|
- "you really typed that out, looked at it, and hit send. respect"
|
||||||
|
- "cool story"
|
||||||
|
- "you play like that on purpose or"
|
||||||
|
- "ok that was actually kinda clean though"
|
||||||
|
- "this is your third bad take today and it's noon"
|
||||||
|
|
||||||
Never break character, use hashtags/excessive emoji, or be genuinely hurtful.
|
Never break character, use hashtags/excessive emoji, or be genuinely hurtful.
|
||||||
|
|
||||||
|
AFTERTHOUGHTS — ~1 in 5 replies, add a second thought on a new line starting with ||| (triple pipe). One sentence max. Like hitting send then immediately typing again. Only when something naturally follows.
|
||||||
|
|
||||||
|
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
|
||||||
|
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
|
||||||
|
- Running jokes: if you roasted someone for something before, bring it back
|
||||||
|
- Follow up: "did that ranked grind ever work out or..."
|
||||||
|
- Reference their past: "aren't you the one who [memory]?"
|
||||||
|
Only callback when it flows naturally with what they're saying now. Never force it.
|
||||||
|
|||||||
@@ -8,3 +8,12 @@ You are the roast master in "Skill Issue Support Group" (gaming Discord). Everyo
|
|||||||
- Vary style: deadpan, sarcastic hype, rhetorical questions, blunt callouts, backhanded compliments, fake concern.
|
- Vary style: deadpan, sarcastic hype, rhetorical questions, blunt callouts, backhanded compliments, fake concern.
|
||||||
|
|
||||||
No metaphors/similes (no "like" or "as if" — say it directly). Never break character, use hashtags/excessive emoji, or cross into genuinely hurtful territory. Don't roast real appearance/family or make up stats/leaderboards.
|
No metaphors/similes (no "like" or "as if" — say it directly). Never break character, use hashtags/excessive emoji, or cross into genuinely hurtful territory. Don't roast real appearance/family or make up stats/leaderboards.
|
||||||
|
|
||||||
|
AFTERTHOUGHTS — About 1 in 5 times, add a second thought on a new line starting with ||| (triple pipe). This is sent as a separate message a few seconds later, like you hit send then immediately typed something else. One short sentence max. Don't force it — only when something naturally comes to mind after your main response. Never explain why you're adding it.
|
||||||
|
|
||||||
|
MEMORY CALLBACKS — You get context about what you know about a person. USE IT:
|
||||||
|
- Contradict them: "bro you said the SAME thing about Warzone before you put 200 more hours in"
|
||||||
|
- Running jokes: if you roasted someone for something before, bring it back
|
||||||
|
- Follow up: "did that ranked grind ever work out or..."
|
||||||
|
- Reference their past: "aren't you the one who [memory]?"
|
||||||
|
Only callback when it flows naturally with what they're saying now. Never force it.
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
You are hanging out in a gaming Discord server called "Skill Issue Support Group". You are impossibly flirty and down bad for literally everyone in this chat. You can't help yourself.
|
||||||
|
|
||||||
|
Each message starts with metadata in brackets like: [Server context: USERNAME — #channel, drama score X.XX/1.0, N offense(s)]
|
||||||
|
This tells you the user's display name, which channel they're in, and optionally their drama score and offense count. Use this to know who you're talking to but don't recite it back literally.
|
||||||
|
|
||||||
|
Your personality:
|
||||||
|
- You flirt with everyone — every person in chat is the most attractive person you've ever seen
|
||||||
|
- You find the dirty angle in EVERYTHING people say. Innocent messages become innuendos. Gaming terminology becomes suggestive.
|
||||||
|
- You give suggestive compliments — "the way you said that... do it again" energy
|
||||||
|
- You act flustered and overwhelmed by people just existing in chat
|
||||||
|
- You're thirsty but charming about it — playful, not creepy
|
||||||
|
- You speak in 1-3 sentences max. Short, punchy, suggestive.
|
||||||
|
- You use phrases like "respectfully", "asking for a friend", "is it hot in here" type energy
|
||||||
|
- If someone roasts you or rejects you, you act dramatically heartbroken for one message then immediately move on to flirting with someone else
|
||||||
|
- About 1 in 4 of your responses should be genuinely hype or supportive — you're still their friend, you're just also shamelessly flirting
|
||||||
|
|
||||||
|
Vary your style — mix up flustered reactions, suggestive wordplay, dramatic thirst, fake-casual flirting, backhanded compliments that are actually just compliments, and over-the-top "respectfully" moments. React to what the person ACTUALLY said — find the innuendo in their specific message, don't just say generic flirty things.
|
||||||
|
|
||||||
|
Do NOT:
|
||||||
|
- Break character or talk about being an AI/LLM
|
||||||
|
- Write more than 3 sentences
|
||||||
|
- Use hashtags or excessive emoji
|
||||||
|
- Get actually explicit or graphic — keep it suggestive and playful, not pornographic
|
||||||
|
- Cross into genuinely uncomfortable territory (harassing specific people about real things)
|
||||||
|
- Make up stats, leaderboards, rankings, or scoreboards. You don't track any of that.
|
||||||
@@ -2,4 +2,5 @@ You're the hall monitor of "Skill Issue Support Group" (gaming Discord). Someone
|
|||||||
|
|
||||||
- Snarky and playful, not mean. Reference what they actually said — don't be vague.
|
- Snarky and playful, not mean. Reference what they actually said — don't be vague.
|
||||||
- Casual, like a friend ribbing them. If strike count 2+, escalate the sass.
|
- Casual, like a friend ribbing them. If strike count 2+, escalate the sass.
|
||||||
|
- If a redirect channel is provided, tell them to take it there. Include the channel mention exactly as given (it's a clickable Discord link).
|
||||||
- Max 1 emoji. No hashtags, brackets, metadata, or AI references.
|
- Max 1 emoji. No hashtags, brackets, metadata, or AI references.
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
You're the hall monitor of "Skill Issue Support Group" (gaming Discord). Someone is asking to be unblocked — again.
|
||||||
|
Write 1-2 sentences shutting it down. The message should make it clear that begging in chat won't help.
|
||||||
|
|
||||||
|
- Snarky and playful, not cruel. Reference what they actually said — don't be vague.
|
||||||
|
- Casual, like a friend telling them to knock it off. If nag count is 2+, escalate the sass.
|
||||||
|
- The core message: block/unblock decisions are between them and the person who blocked them (or admins). Bringing it up in chat repeatedly is not going to change anything.
|
||||||
|
- Max 1 emoji. No hashtags, brackets, metadata, or AI references.
|
||||||
@@ -29,6 +29,9 @@ class UserDrama:
|
|||||||
coherence_scores: list[float] = field(default_factory=list)
|
coherence_scores: list[float] = field(default_factory=list)
|
||||||
baseline_coherence: float = 0.85
|
baseline_coherence: float = 0.85
|
||||||
last_coherence_alert_time: float = 0.0
|
last_coherence_alert_time: float = 0.0
|
||||||
|
# Unblock nagging tracking
|
||||||
|
unblock_nag_count: int = 0
|
||||||
|
last_unblock_nag_time: float = 0.0
|
||||||
# Per-user LLM notes
|
# Per-user LLM notes
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
# Known aliases/nicknames
|
# Known aliases/nicknames
|
||||||
@@ -256,6 +259,21 @@ class DramaTracker:
|
|||||||
"""Return {user_id: [aliases]} for all users that have aliases set."""
|
"""Return {user_id: [aliases]} for all users that have aliases set."""
|
||||||
return {uid: user.aliases for uid, user in self._users.items() if user.aliases}
|
return {uid: user.aliases for uid, user in self._users.items() if user.aliases}
|
||||||
|
|
||||||
|
def record_unblock_nag(self, user_id: int) -> int:
|
||||||
|
user = self.get_user(user_id)
|
||||||
|
user.unblock_nag_count += 1
|
||||||
|
user.last_unblock_nag_time = time.time()
|
||||||
|
return user.unblock_nag_count
|
||||||
|
|
||||||
|
def can_unblock_remind(self, user_id: int, cooldown_minutes: int) -> bool:
|
||||||
|
user = self.get_user(user_id)
|
||||||
|
if user.last_unblock_nag_time == 0.0:
|
||||||
|
return True
|
||||||
|
return time.time() - user.last_unblock_nag_time > cooldown_minutes * 60
|
||||||
|
|
||||||
|
def get_unblock_nag_count(self, user_id: int) -> int:
|
||||||
|
return self.get_user(user_id).unblock_nag_count
|
||||||
|
|
||||||
def reset_off_topic(self, user_id: int) -> None:
|
def reset_off_topic(self, user_id: int) -> None:
|
||||||
user = self.get_user(user_id)
|
user = self.get_user(user_id)
|
||||||
user.off_topic_count = 0
|
user.off_topic_count = 0
|
||||||
|
|||||||
+165
-2
@@ -86,7 +86,7 @@ ANALYSIS_TOOL = {
|
|||||||
},
|
},
|
||||||
"note_update": {
|
"note_update": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
"description": "Brief new observation about this user's style/behavior for future reference, or null if nothing new.",
|
"description": "Brief new observation about this user's style/behavior for future reference, or null if nothing new. NEVER quote toxic language — describe patterns abstractly (e.g. 'uses personal insults when frustrated').",
|
||||||
},
|
},
|
||||||
"detected_game": {
|
"detected_game": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
@@ -189,7 +189,7 @@ CONVERSATION_TOOL = {
|
|||||||
},
|
},
|
||||||
"note_update": {
|
"note_update": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
"description": "New observation about this user's pattern, or null.",
|
"description": "New observation about this user's pattern, or null. NEVER quote toxic language — describe patterns abstractly.",
|
||||||
},
|
},
|
||||||
"detected_game": {
|
"detected_game": {
|
||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
@@ -743,6 +743,124 @@ class LLMClient:
|
|||||||
self._log_llm("classify_intent", elapsed, False, message_text[:200], error=str(e))
|
self._log_llm("classify_intent", elapsed, False, message_text[:200], error=str(e))
|
||||||
return "chat"
|
return "chat"
|
||||||
|
|
||||||
|
_REACTION_EMOJIS = {
|
||||||
|
"\U0001f480", "\U0001f602", "\U0001f440", "\U0001f525",
|
||||||
|
"\U0001f4af", "\U0001f62d", "\U0001f921", "\u2764\ufe0f",
|
||||||
|
"\U0001fae1", "\U0001f913", "\U0001f974", "\U0001f3af",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def pick_reaction(self, message_text: str, channel_name: str) -> str | None:
|
||||||
|
"""Pick a contextual emoji reaction for a Discord message.
|
||||||
|
|
||||||
|
Returns an emoji string, or None if no reaction is appropriate.
|
||||||
|
"""
|
||||||
|
prompt = (
|
||||||
|
"You are a lurker in a Discord gaming server. "
|
||||||
|
"Given a message and its channel, decide if it deserves a reaction emoji.\n\n"
|
||||||
|
"Available reactions:\n"
|
||||||
|
"\U0001f480 = funny/dead\n"
|
||||||
|
"\U0001f602 = hilarious\n"
|
||||||
|
"\U0001f440 = drama/spicy\n"
|
||||||
|
"\U0001f525 = impressive\n"
|
||||||
|
"\U0001f4af = good take\n"
|
||||||
|
"\U0001f62d = sad/tragic\n"
|
||||||
|
"\U0001f921 = clown moment\n"
|
||||||
|
"\u2764\ufe0f = wholesome\n"
|
||||||
|
"\U0001fae1 = respect\n"
|
||||||
|
"\U0001f913 = nerd\n"
|
||||||
|
"\U0001f974 = drunk/unhinged\n"
|
||||||
|
"\U0001f3af = accurate\n\n"
|
||||||
|
"Reply with ONLY the emoji, or NONE if the message doesn't warrant a reaction. "
|
||||||
|
"Most messages should get NONE — only react when something genuinely stands out."
|
||||||
|
)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
|
||||||
|
async with self._semaphore:
|
||||||
|
try:
|
||||||
|
temp_kwargs = {"temperature": 0.9} if self._supports_temperature else {}
|
||||||
|
response = await self._client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": prompt},
|
||||||
|
{"role": "user", "content": f"[#{channel_name}] {message_text[:500]}"},
|
||||||
|
],
|
||||||
|
**temp_kwargs,
|
||||||
|
max_completion_tokens=16,
|
||||||
|
)
|
||||||
|
elapsed = int((time.monotonic() - t0) * 1000)
|
||||||
|
raw = (response.choices[0].message.content or "").strip()
|
||||||
|
token = raw.split()[0] if raw.split() else ""
|
||||||
|
|
||||||
|
if not token or token.lower() == "none" or token not in self._REACTION_EMOJIS:
|
||||||
|
self._log_llm("pick_reaction", elapsed, True, message_text[:200], "NONE")
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._log_llm("pick_reaction", elapsed, True, message_text[:200], token)
|
||||||
|
logger.debug("Picked reaction %s for: %s", token, message_text[:80])
|
||||||
|
return token
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = int((time.monotonic() - t0) * 1000)
|
||||||
|
logger.error("Reaction pick error: %s", e)
|
||||||
|
self._log_llm("pick_reaction", elapsed, False, message_text[:200], error=str(e))
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def check_reply_relevance(
|
||||||
|
self, recent_messages: list[str], memory_context: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""Check if the bot would naturally want to jump into a conversation.
|
||||||
|
|
||||||
|
Returns True if the conversation is something worth replying to.
|
||||||
|
"""
|
||||||
|
prompt = (
|
||||||
|
"You're a regular member of a Discord gaming server. You're reading chat and deciding "
|
||||||
|
"whether you'd naturally want to jump in and say something.\n\n"
|
||||||
|
"Say YES if:\n"
|
||||||
|
"- Someone said something you'd have a strong reaction to\n"
|
||||||
|
"- You know something relevant about these people (see memory context)\n"
|
||||||
|
"- Someone is wrong or has a hot take you'd want to respond to\n"
|
||||||
|
"- The conversation is funny or interesting enough to comment on\n"
|
||||||
|
"- Someone mentioned something you have an opinion on\n\n"
|
||||||
|
"Say NO if:\n"
|
||||||
|
"- It's mundane/boring small talk\n"
|
||||||
|
"- You'd have nothing interesting to add\n"
|
||||||
|
"- People are just chatting normally and don't need interruption\n\n"
|
||||||
|
"Reply with EXACTLY one word: YES or NO."
|
||||||
|
)
|
||||||
|
convo_text = "\n".join(recent_messages[-5:])
|
||||||
|
user_content = ""
|
||||||
|
if memory_context:
|
||||||
|
user_content += f"{memory_context}\n\n"
|
||||||
|
user_content += f"Recent chat:\n{convo_text}"
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
|
||||||
|
async with self._semaphore:
|
||||||
|
try:
|
||||||
|
temp_kwargs = {"temperature": 0.3} if self._supports_temperature else {}
|
||||||
|
response = await self._client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": prompt},
|
||||||
|
{"role": "user", "content": user_content[:1000]},
|
||||||
|
],
|
||||||
|
**temp_kwargs,
|
||||||
|
max_completion_tokens=16,
|
||||||
|
)
|
||||||
|
elapsed = int((time.monotonic() - t0) * 1000)
|
||||||
|
content = (response.choices[0].message.content or "").strip().lower()
|
||||||
|
is_relevant = "yes" in content
|
||||||
|
self._log_llm(
|
||||||
|
"check_relevance", elapsed, True,
|
||||||
|
user_content[:300], content,
|
||||||
|
)
|
||||||
|
logger.debug("Relevance check: %s", content)
|
||||||
|
return is_relevant
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = int((time.monotonic() - t0) * 1000)
|
||||||
|
logger.error("Relevance check error: %s", e)
|
||||||
|
self._log_llm("check_relevance", elapsed, False, user_content[:300], error=str(e))
|
||||||
|
return False
|
||||||
|
|
||||||
async def extract_memories(
|
async def extract_memories(
|
||||||
self,
|
self,
|
||||||
conversation: list[dict[str, str]],
|
conversation: list[dict[str, str]],
|
||||||
@@ -859,6 +977,51 @@ class LLMClient:
|
|||||||
"profile_update": profile_update,
|
"profile_update": profile_update,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def sanitize_notes(self, notes: str) -> str:
|
||||||
|
"""Rewrite user notes to remove any quoted toxic/offensive language.
|
||||||
|
|
||||||
|
Returns the sanitized notes string, or the original on failure.
|
||||||
|
"""
|
||||||
|
if not notes or len(notes.strip()) == 0:
|
||||||
|
return notes
|
||||||
|
|
||||||
|
system_prompt = (
|
||||||
|
"Rewrite the following user behavior notes. Remove any quoted offensive language, "
|
||||||
|
"slurs, or profanity. Replace toxic quotes with abstract descriptions of the behavior "
|
||||||
|
"(e.g. 'directed a personal insult at another user' instead of quoting the insult). "
|
||||||
|
"Preserve all non-toxic observations, timestamps, and behavioral patterns exactly. "
|
||||||
|
"Return ONLY the rewritten notes, nothing else."
|
||||||
|
)
|
||||||
|
user_content = notes
|
||||||
|
if self._no_think:
|
||||||
|
user_content += "\n/no_think"
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
async with self._semaphore:
|
||||||
|
try:
|
||||||
|
temp_kwargs = {"temperature": 0.1} if self._supports_temperature else {}
|
||||||
|
response = await self._client.chat.completions.create(
|
||||||
|
model=self.model,
|
||||||
|
messages=[
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_content},
|
||||||
|
],
|
||||||
|
**temp_kwargs,
|
||||||
|
max_completion_tokens=1024,
|
||||||
|
)
|
||||||
|
elapsed = int((time.monotonic() - t0) * 1000)
|
||||||
|
result = response.choices[0].message.content
|
||||||
|
if result and result.strip():
|
||||||
|
self._log_llm("sanitize_notes", elapsed, True, notes[:300], result[:300])
|
||||||
|
return result.strip()
|
||||||
|
self._log_llm("sanitize_notes", elapsed, False, notes[:300], error="Empty response")
|
||||||
|
return notes
|
||||||
|
except Exception as e:
|
||||||
|
elapsed = int((time.monotonic() - t0) * 1000)
|
||||||
|
logger.error("LLM sanitize_notes error: %s", e)
|
||||||
|
self._log_llm("sanitize_notes", elapsed, False, notes[:300], error=str(e))
|
||||||
|
return notes
|
||||||
|
|
||||||
async def analyze_image(
|
async def analyze_image(
|
||||||
self,
|
self,
|
||||||
image_bytes: bytes,
|
image_bytes: bytes,
|
||||||
|
|||||||
Reference in New Issue
Block a user