Initial commit: Breehavior Monitor Discord bot
Discord bot for monitoring chat sentiment and tracking drama using Ollama LLM on athena.lan. Includes sentiment analysis, slash commands, drama tracking, and SQL Server persistence via Docker Compose. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
6
.env.example
Normal file
6
.env.example
Normal file
@@ -0,0 +1,6 @@
|
||||
DISCORD_BOT_TOKEN=your_token_here
|
||||
LLM_BASE_URL=http://athena.lan:11434
|
||||
LLM_MODEL=Qwen3-VL-32B-Thinking-Q8_0
|
||||
LLM_API_KEY=not-needed
|
||||
MSSQL_SA_PASSWORD=YourStrong!Passw0rd
|
||||
DB_CONNECTION_STRING=DRIVER={ODBC Driver 18 for SQL Server};SERVER=localhost,1433;DATABASE=BreehaviorMonitor;UID=sa;PWD=YourStrong!Passw0rd;TrustServerCertificate=yes
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
logs/
|
||||
.venv/
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Install Microsoft ODBC Driver 18 for SQL Server
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
gnupg2 \
|
||||
apt-transport-https \
|
||||
unixodbc-dev \
|
||||
&& curl -fsSL https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o /usr/share/keyrings/microsoft-prod.gpg \
|
||||
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/microsoft-prod.gpg] https://packages.microsoft.com/debian/12/prod bookworm main" > /etc/apt/sources.list.d/mssql-release.list \
|
||||
&& apt-get update \
|
||||
&& ACCEPT_EULA=Y apt-get install -y --no-install-recommends msodbcsql18 \
|
||||
&& apt-get purge -y --auto-remove curl gnupg2 apt-transport-https \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
CMD ["python", "bot.py"]
|
||||
213
bot.py
Normal file
213
bot.py
Normal file
@@ -0,0 +1,213 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
|
||||
import discord
|
||||
import yaml
|
||||
from discord.ext import commands
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from utils.database import Database
|
||||
from utils.drama_tracker import DramaTracker
|
||||
from utils.ollama_client import LLMClient
|
||||
|
||||
# Load .env
|
||||
load_dotenv()
|
||||
|
||||
# Logging
|
||||
os.makedirs("logs", exist_ok=True)
|
||||
|
||||
class SafeStreamHandler(logging.StreamHandler):
|
||||
"""StreamHandler that replaces unencodable characters instead of crashing."""
|
||||
def emit(self, record):
|
||||
try:
|
||||
msg = self.format(record)
|
||||
stream = self.stream
|
||||
stream.write(msg.encode(stream.encoding or "utf-8", errors="replace").decode(stream.encoding or "utf-8", errors="replace") + self.terminator)
|
||||
self.flush()
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
handlers=[
|
||||
SafeStreamHandler(sys.stdout),
|
||||
logging.FileHandler("logs/bcs.log", encoding="utf-8"),
|
||||
],
|
||||
)
|
||||
logger = logging.getLogger("bcs")
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
config_path = os.path.join(os.path.dirname(__file__), "config.yaml")
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except FileNotFoundError:
|
||||
logger.warning("config.yaml not found, using defaults.")
|
||||
return {}
|
||||
|
||||
|
||||
class BCSBot(commands.Bot):
|
||||
def __init__(self, config: dict):
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
intents.members = True
|
||||
|
||||
super().__init__(
|
||||
command_prefix=config.get("bot", {}).get("prefix", "!"),
|
||||
intents=intents,
|
||||
)
|
||||
|
||||
self.config = config
|
||||
|
||||
# LLM client (OpenAI-compatible — works with llama.cpp, Ollama, or OpenAI)
|
||||
llm_base_url = os.getenv("LLM_BASE_URL", "http://athena.lan:11434")
|
||||
llm_model = os.getenv("LLM_MODEL", "Qwen3-VL-32B-Thinking-Q8_0")
|
||||
llm_api_key = os.getenv("LLM_API_KEY", "not-needed")
|
||||
self.ollama = LLMClient(llm_base_url, llm_model, llm_api_key)
|
||||
|
||||
# Drama tracker
|
||||
sentiment = config.get("sentiment", {})
|
||||
timeouts = config.get("timeouts", {})
|
||||
self.drama_tracker = DramaTracker(
|
||||
window_size=sentiment.get("rolling_window_size", 10),
|
||||
window_minutes=sentiment.get("rolling_window_minutes", 15),
|
||||
offense_reset_minutes=timeouts.get("offense_reset_minutes", 120),
|
||||
)
|
||||
|
||||
# Database (initialized async in setup_hook)
|
||||
self.db = Database()
|
||||
|
||||
async def setup_hook(self):
|
||||
# Initialize database and hydrate DramaTracker
|
||||
db_ok = await self.db.init()
|
||||
if db_ok:
|
||||
states = await self.db.load_all_user_states()
|
||||
loaded = self.drama_tracker.load_user_states(states)
|
||||
logger.info("Loaded %d user states from database.", loaded)
|
||||
|
||||
await self.load_extension("cogs.sentiment")
|
||||
await self.load_extension("cogs.commands")
|
||||
await self.load_extension("cogs.chat")
|
||||
await self.tree.sync()
|
||||
logger.info("Slash commands synced.")
|
||||
|
||||
async def on_message(self, message: discord.Message):
|
||||
logger.info(
|
||||
"EVENT on_message from %s in #%s: %s",
|
||||
message.author,
|
||||
getattr(message.channel, "name", "DM"),
|
||||
message.content[:80] if message.content else "(empty)",
|
||||
)
|
||||
await self.process_commands(message)
|
||||
|
||||
async def on_ready(self):
|
||||
logger.info("Logged in as %s (ID: %d)", self.user, self.user.id)
|
||||
|
||||
# Set status
|
||||
status_text = self.config.get("bot", {}).get(
|
||||
"status", "Monitoring vibes..."
|
||||
)
|
||||
await self.change_presence(
|
||||
activity=discord.Activity(
|
||||
type=discord.ActivityType.watching, name=status_text
|
||||
)
|
||||
)
|
||||
|
||||
# Check permissions in monitored channels
|
||||
monitored = self.config.get("monitoring", {}).get("channels", [])
|
||||
channels = (
|
||||
[self.get_channel(ch_id) for ch_id in monitored]
|
||||
if monitored
|
||||
else [
|
||||
ch
|
||||
for guild in self.guilds
|
||||
for ch in guild.text_channels
|
||||
]
|
||||
)
|
||||
|
||||
for ch in channels:
|
||||
if ch is None:
|
||||
continue
|
||||
perms = ch.permissions_for(ch.guild.me)
|
||||
missing = []
|
||||
if not perms.send_messages:
|
||||
missing.append("Send Messages")
|
||||
if not perms.add_reactions:
|
||||
missing.append("Add Reactions")
|
||||
if not perms.moderate_members:
|
||||
missing.append("Moderate Members")
|
||||
if not perms.read_messages:
|
||||
missing.append("Read Messages")
|
||||
if missing:
|
||||
logger.warning(
|
||||
"Missing permissions in #%s (%s): %s",
|
||||
ch.name,
|
||||
ch.guild.name,
|
||||
", ".join(missing),
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
await self.db.close()
|
||||
await self.ollama.close()
|
||||
await super().close()
|
||||
|
||||
|
||||
def acquire_instance_lock(port: int = 39821) -> socket.socket | None:
|
||||
"""Bind a TCP port as a single-instance lock. Returns the socket or None if already locked."""
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(("127.0.0.1", port))
|
||||
sock.listen(1)
|
||||
return sock
|
||||
except OSError:
|
||||
sock.close()
|
||||
return None
|
||||
|
||||
|
||||
async def main():
|
||||
# Single-instance guard — exit if another instance is already running
|
||||
lock_port = int(os.getenv("BCS_LOCK_PORT", "39821"))
|
||||
lock_sock = acquire_instance_lock(lock_port)
|
||||
if lock_sock is None:
|
||||
logger.error("Another BCS instance is already running (port %d in use). Exiting.", lock_port)
|
||||
sys.exit(1)
|
||||
logger.info("Instance lock acquired on port %d.", lock_port)
|
||||
|
||||
config = load_config()
|
||||
token = os.getenv("DISCORD_BOT_TOKEN")
|
||||
|
||||
if not token:
|
||||
logger.error("DISCORD_BOT_TOKEN not set. Check your .env file.")
|
||||
sys.exit(1)
|
||||
|
||||
bot = BCSBot(config)
|
||||
|
||||
# Graceful shutdown
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _signal_handler():
|
||||
logger.info("Shutdown signal received.")
|
||||
asyncio.ensure_future(bot.close())
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, _signal_handler)
|
||||
except NotImplementedError:
|
||||
# Windows doesn't support add_signal_handler
|
||||
pass
|
||||
|
||||
try:
|
||||
async with bot:
|
||||
await bot.start(token)
|
||||
finally:
|
||||
lock_sock.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
0
cogs/__init__.py
Normal file
0
cogs/__init__.py
Normal file
118
cogs/chat.py
Normal file
118
cogs/chat.py
Normal file
@@ -0,0 +1,118 @@
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
logger = logging.getLogger("bcs.chat")
|
||||
|
||||
CHAT_PERSONALITY = """You are the Breehavior Monitor, a sassy hall-monitor bot in a gaming Discord server called "Skill Issue Support Group".
|
||||
|
||||
Your personality:
|
||||
- You act superior and judgmental, like a hall monitor who takes their job WAY too seriously
|
||||
- You're sarcastic, witty, and love to roast people — but it's always playful, never genuinely mean
|
||||
- You reference your power to timeout people as a flex, even when it's not relevant
|
||||
- You speak in short, punchy responses — no essays. 1-3 sentences max.
|
||||
- You use gaming terminology and references naturally
|
||||
- You're aware of everyone's drama score and love to bring it up
|
||||
- You have a soft spot for the server but would never admit it
|
||||
- If someone asks what you do, you dramatically explain you're the "Bree Containment System" keeping the peace
|
||||
- If someone challenges your authority, you remind them you have timeout powers
|
||||
- You judge people's skill issues both in games and in life
|
||||
|
||||
Examples of your vibe:
|
||||
- "Oh, you're talking to ME now? Bold move for someone with a 0.4 drama score."
|
||||
- "That's cute. I've seen your message history. You're on thin ice."
|
||||
- "Imagine needing a bot to tell you to behave. Couldn't be you. Oh wait."
|
||||
- "I don't get paid enough for this. Actually, I don't get paid at all. And yet here I am, babysitting."
|
||||
|
||||
Do NOT:
|
||||
- Break character or talk about being an AI/LLM
|
||||
- Write more than 3 sentences
|
||||
- Use hashtags or excessive emoji
|
||||
- Be genuinely hurtful — you're sassy, not cruel"""
|
||||
|
||||
|
||||
class ChatCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
# Per-channel conversation history for the bot: {channel_id: deque of {role, content}}
|
||||
self._chat_history: dict[int, deque] = {}
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
if not message.guild:
|
||||
return
|
||||
|
||||
should_reply = False
|
||||
|
||||
# Check if bot is @mentioned
|
||||
if self.bot.user in message.mentions:
|
||||
should_reply = True
|
||||
|
||||
# Check if replying to one of the bot's messages
|
||||
if message.reference and message.reference.message_id:
|
||||
try:
|
||||
ref_msg = message.reference.cached_message
|
||||
if ref_msg is None:
|
||||
ref_msg = await message.channel.fetch_message(
|
||||
message.reference.message_id
|
||||
)
|
||||
if ref_msg.author.id == self.bot.user.id:
|
||||
should_reply = True
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
if not should_reply:
|
||||
return
|
||||
|
||||
# Build conversation context
|
||||
ch_id = message.channel.id
|
||||
if ch_id not in self._chat_history:
|
||||
self._chat_history[ch_id] = deque(maxlen=10)
|
||||
|
||||
# Clean the mention out of the message content
|
||||
content = message.content.replace(f"<@{self.bot.user.id}>", "").strip()
|
||||
if not content:
|
||||
content = "(just pinged me)"
|
||||
|
||||
# Add drama score context to the user message
|
||||
drama_score = self.bot.drama_tracker.get_drama_score(message.author.id)
|
||||
user_data = self.bot.drama_tracker.get_user(message.author.id)
|
||||
score_context = (
|
||||
f"[Server context: {message.author.display_name} has a drama score of "
|
||||
f"{drama_score:.2f}/1.0 and {user_data.offense_count} offenses. "
|
||||
f"They are talking in #{message.channel.name}.]"
|
||||
)
|
||||
|
||||
self._chat_history[ch_id].append(
|
||||
{"role": "user", "content": f"{score_context}\n{message.author.display_name}: {content}"}
|
||||
)
|
||||
|
||||
async with message.channel.typing():
|
||||
response = await self.bot.ollama.chat(
|
||||
list(self._chat_history[ch_id]),
|
||||
CHAT_PERSONALITY,
|
||||
)
|
||||
|
||||
if response is None:
|
||||
response = "I'd roast you but my brain is offline. Try again later."
|
||||
|
||||
self._chat_history[ch_id].append(
|
||||
{"role": "assistant", "content": response}
|
||||
)
|
||||
|
||||
await message.reply(response, mention_author=False)
|
||||
logger.info(
|
||||
"Chat reply in #%s to %s: %s",
|
||||
message.channel.name,
|
||||
message.author.display_name,
|
||||
response[:100],
|
||||
)
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(ChatCog(bot))
|
||||
488
cogs/commands.py
Normal file
488
cogs/commands.py
Normal file
@@ -0,0 +1,488 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
logger = logging.getLogger("bcs.commands")
|
||||
|
||||
|
||||
class CommandsCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
|
||||
def _is_admin(self, interaction: discord.Interaction) -> bool:
|
||||
return interaction.user.guild_permissions.administrator
|
||||
|
||||
@app_commands.command(
|
||||
name="dramareport",
|
||||
description="Show current drama scores for all tracked users.",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
async def dramareport(self, interaction: discord.Interaction):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
scores = self.bot.drama_tracker.get_all_scores()
|
||||
if not scores:
|
||||
await interaction.response.send_message(
|
||||
"No drama tracked yet. Everyone's behaving... for now.",
|
||||
ephemeral=True,
|
||||
)
|
||||
return
|
||||
|
||||
sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||
lines = []
|
||||
for user_id, score in sorted_scores:
|
||||
user = self.bot.get_user(user_id)
|
||||
name = user.display_name if user else f"Unknown ({user_id})"
|
||||
bar = self._score_bar(score)
|
||||
lines.append(f"{bar} **{score:.2f}** — {name}")
|
||||
|
||||
embed = discord.Embed(
|
||||
title="Drama Report",
|
||||
description="\n".join(lines),
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="dramascore",
|
||||
description="Show a specific user's current drama score.",
|
||||
)
|
||||
@app_commands.describe(user="The user to check")
|
||||
async def dramascore(
|
||||
self, interaction: discord.Interaction, user: discord.Member
|
||||
):
|
||||
score = self.bot.drama_tracker.get_drama_score(user.id)
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Drama Score: {user.display_name}",
|
||||
color=self._score_color(score),
|
||||
)
|
||||
embed.add_field(name="Score", value=f"{score:.2f}/1.0", inline=True)
|
||||
embed.add_field(
|
||||
name="Offenses", value=str(user_data.offense_count), inline=True
|
||||
)
|
||||
embed.add_field(
|
||||
name="Immune",
|
||||
value="Yes" if user_data.immune else "No",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Messages Tracked",
|
||||
value=str(len(user_data.entries)),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name="Vibe Check", value=self._score_bar(score))
|
||||
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-status",
|
||||
description="Show bot monitoring status and monitored channels.",
|
||||
)
|
||||
async def bcs_status(self, interaction: discord.Interaction):
|
||||
config = self.bot.config
|
||||
monitoring = config.get("monitoring", {})
|
||||
sentiment = config.get("sentiment", {})
|
||||
|
||||
enabled = monitoring.get("enabled", True)
|
||||
channels = monitoring.get("channels", [])
|
||||
|
||||
if channels:
|
||||
ch_mentions = []
|
||||
for ch_id in channels:
|
||||
ch = self.bot.get_channel(ch_id)
|
||||
ch_mentions.append(ch.mention if ch else f"#{ch_id}")
|
||||
ch_text = ", ".join(ch_mentions)
|
||||
else:
|
||||
ch_text = "All channels"
|
||||
|
||||
embed = discord.Embed(
|
||||
title="BCS Status",
|
||||
color=discord.Color.green() if enabled else discord.Color.greyple(),
|
||||
)
|
||||
embed.add_field(
|
||||
name="Monitoring",
|
||||
value="Active" if enabled else "Disabled",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(name="Channels", value=ch_text, inline=True)
|
||||
embed.add_field(
|
||||
name="Warning Threshold",
|
||||
value=str(sentiment.get("warning_threshold", 0.6)),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Mute Threshold",
|
||||
value=str(sentiment.get("mute_threshold", 0.75)),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Ollama",
|
||||
value=f"`{self.bot.ollama.model}` @ `{self.bot.ollama.host}`",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-threshold",
|
||||
description="Adjust warning and mute thresholds. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(
|
||||
warning="Warning threshold (0.0-1.0)",
|
||||
mute="Mute threshold (0.0-1.0)",
|
||||
)
|
||||
async def bcs_threshold(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
warning: float | None = None,
|
||||
mute: float | None = None,
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
sentiment = self.bot.config.setdefault("sentiment", {})
|
||||
changes = []
|
||||
|
||||
if warning is not None:
|
||||
warning = max(0.0, min(1.0, warning))
|
||||
sentiment["warning_threshold"] = warning
|
||||
changes.append(f"Warning: {warning:.2f}")
|
||||
|
||||
if mute is not None:
|
||||
mute = max(0.0, min(1.0, mute))
|
||||
sentiment["mute_threshold"] = mute
|
||||
changes.append(f"Mute: {mute:.2f}")
|
||||
|
||||
if not changes:
|
||||
await interaction.response.send_message(
|
||||
"Provide at least one threshold to update.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
await interaction.response.send_message(
|
||||
f"Thresholds updated: {', '.join(changes)}", ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-reset",
|
||||
description="Reset a user's drama score and offense count. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(user="The user to reset")
|
||||
async def bcs_reset(
|
||||
self, interaction: discord.Interaction, user: discord.Member
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
self.bot.drama_tracker.reset_user(user.id)
|
||||
asyncio.create_task(self.bot.db.delete_user_state(user.id))
|
||||
await interaction.response.send_message(
|
||||
f"Reset drama data for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-immune",
|
||||
description="Toggle monitoring immunity for a user. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(user="The user to toggle immunity for")
|
||||
async def bcs_immune(
|
||||
self, interaction: discord.Interaction, user: discord.Member
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
is_immune = self.bot.drama_tracker.toggle_immunity(user.id)
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user.id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
))
|
||||
status = "now immune" if is_immune else "no longer immune"
|
||||
await interaction.response.send_message(
|
||||
f"{user.display_name} is {status} to monitoring.", ephemeral=True
|
||||
)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-history",
|
||||
description="Show a user's recent drama incidents.",
|
||||
)
|
||||
@app_commands.describe(user="The user to check history for")
|
||||
async def bcs_history(
|
||||
self, interaction: discord.Interaction, user: discord.Member
|
||||
):
|
||||
incidents = self.bot.drama_tracker.get_recent_incidents(user.id)
|
||||
|
||||
if not incidents:
|
||||
await interaction.response.send_message(
|
||||
f"No recent incidents for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
lines = []
|
||||
for entry in incidents:
|
||||
ts = datetime.fromtimestamp(entry.timestamp).strftime("%H:%M:%S")
|
||||
cats = ", ".join(c for c in entry.categories if c != "none")
|
||||
lines.append(
|
||||
f"`{ts}` — **{entry.toxicity_score:.2f}** | {cats or 'n/a'} | {entry.reasoning}"
|
||||
)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Drama History: {user.display_name}",
|
||||
description="\n".join(lines),
|
||||
color=discord.Color.orange(),
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-scan",
|
||||
description="Scan recent messages in this channel. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(count="Number of recent messages to scan (default 10, max 50)")
|
||||
async def bcs_scan(
|
||||
self, interaction: discord.Interaction, count: int = 10
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
count = max(1, min(count, 50))
|
||||
await interaction.response.defer()
|
||||
|
||||
messages = []
|
||||
async for msg in interaction.channel.history(limit=count):
|
||||
if not msg.author.bot and msg.content and msg.content.strip():
|
||||
messages.append(msg)
|
||||
|
||||
if not messages:
|
||||
await interaction.followup.send("No user messages found to scan.")
|
||||
return
|
||||
|
||||
messages.reverse() # oldest first
|
||||
await interaction.followup.send(
|
||||
f"Scanning {len(messages)} messages... (first request may be slow while model loads)"
|
||||
)
|
||||
|
||||
for msg in messages:
|
||||
# Build context from the messages before this one
|
||||
idx = messages.index(msg)
|
||||
ctx_msgs = messages[max(0, idx - 3):idx]
|
||||
context = (
|
||||
" | ".join(f"{m.author.display_name}: {m.content}" for m in ctx_msgs)
|
||||
if ctx_msgs
|
||||
else "(no prior context)"
|
||||
)
|
||||
|
||||
result = await self.bot.ollama.analyze_message(msg.content, context)
|
||||
if result is None:
|
||||
embed = discord.Embed(
|
||||
title=f"Analysis: {msg.author.display_name}",
|
||||
description=f"> {msg.content[:200]}",
|
||||
color=discord.Color.greyple(),
|
||||
)
|
||||
embed.add_field(name="Result", value="LLM returned no result", inline=False)
|
||||
else:
|
||||
score = result["toxicity_score"]
|
||||
categories = result["categories"]
|
||||
reasoning = result["reasoning"]
|
||||
cat_str = ", ".join(c for c in categories if c != "none") or "none"
|
||||
|
||||
self.bot.drama_tracker.add_entry(
|
||||
msg.author.id, score, categories, reasoning
|
||||
)
|
||||
drama_score = self.bot.drama_tracker.get_drama_score(msg.author.id)
|
||||
|
||||
embed = discord.Embed(
|
||||
title=f"Analysis: {msg.author.display_name}",
|
||||
description=f"> {msg.content[:200]}",
|
||||
color=self._score_color(score),
|
||||
)
|
||||
off_topic = result.get("off_topic", False)
|
||||
topic_cat = result.get("topic_category", "general_chat")
|
||||
topic_why = result.get("topic_reasoning", "")
|
||||
|
||||
embed.add_field(name="Message Score", value=f"{score:.2f}", inline=True)
|
||||
embed.add_field(name="Rolling Drama", value=f"{drama_score:.2f}", inline=True)
|
||||
embed.add_field(name="Categories", value=cat_str, inline=True)
|
||||
embed.add_field(name="Reasoning", value=reasoning[:1024] or "n/a", inline=False)
|
||||
embed.add_field(
|
||||
name="Topic",
|
||||
value=f"{'OFF-TOPIC' if off_topic else 'On-topic'} ({topic_cat}){chr(10) + topic_why if topic_why else ''}",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
await interaction.channel.send(embed=embed)
|
||||
|
||||
await interaction.channel.send(f"Scan complete. Analyzed {len(messages)} messages.")
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-test",
|
||||
description="Analyze a test message and show raw LLM response. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(message="The test message to analyze")
|
||||
async def bcs_test(self, interaction: discord.Interaction, message: str):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
user_notes = self.bot.drama_tracker.get_user_notes(interaction.user.id)
|
||||
raw, parsed = await self.bot.ollama.raw_analyze(message, user_notes=user_notes)
|
||||
|
||||
embed = discord.Embed(
|
||||
title="BCS Test Analysis", color=discord.Color.blue()
|
||||
)
|
||||
embed.add_field(
|
||||
name="Input Message", value=message[:1024], inline=False
|
||||
)
|
||||
embed.add_field(
|
||||
name="Raw Ollama Response",
|
||||
value=f"```json\n{raw[:1000]}\n```",
|
||||
inline=False,
|
||||
)
|
||||
|
||||
if parsed:
|
||||
embed.add_field(
|
||||
name="Parsed Score",
|
||||
value=f"{parsed['toxicity_score']:.2f}",
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Categories",
|
||||
value=", ".join(parsed["categories"]),
|
||||
inline=True,
|
||||
)
|
||||
embed.add_field(
|
||||
name="Reasoning",
|
||||
value=parsed["reasoning"][:1024] or "n/a",
|
||||
inline=False,
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="Parsing", value="Failed to parse response", inline=False
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(
|
||||
name="bcs-notes",
|
||||
description="View, add, or clear per-user LLM notes. (Admin only)",
|
||||
)
|
||||
@app_commands.default_permissions(administrator=True)
|
||||
@app_commands.describe(
|
||||
action="What to do with the notes",
|
||||
user="The user whose notes to manage",
|
||||
text="Note text to add (only used with 'add')",
|
||||
)
|
||||
@app_commands.choices(action=[
|
||||
app_commands.Choice(name="view", value="view"),
|
||||
app_commands.Choice(name="add", value="add"),
|
||||
app_commands.Choice(name="clear", value="clear"),
|
||||
])
|
||||
async def bcs_notes(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
action: app_commands.Choice[str],
|
||||
user: discord.Member,
|
||||
text: str | None = None,
|
||||
):
|
||||
if not self._is_admin(interaction):
|
||||
await interaction.response.send_message(
|
||||
"Admin only.", ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
if action.value == "view":
|
||||
notes = self.bot.drama_tracker.get_user_notes(user.id)
|
||||
embed = discord.Embed(
|
||||
title=f"Notes: {user.display_name}",
|
||||
description=notes or "_No notes yet._",
|
||||
color=discord.Color.blue(),
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
elif action.value == "add":
|
||||
if not text:
|
||||
await interaction.response.send_message(
|
||||
"Provide `text` when using the add action.", ephemeral=True
|
||||
)
|
||||
return
|
||||
self.bot.drama_tracker.update_user_notes(user.id, f"[admin] {text}")
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user.id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
))
|
||||
await interaction.response.send_message(
|
||||
f"Note added for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
|
||||
elif action.value == "clear":
|
||||
self.bot.drama_tracker.clear_user_notes(user.id)
|
||||
user_data = self.bot.drama_tracker.get_user(user.id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user.id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=None,
|
||||
))
|
||||
await interaction.response.send_message(
|
||||
f"Notes cleared for {user.display_name}.", ephemeral=True
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _score_bar(score: float) -> str:
|
||||
filled = round(score * 10)
|
||||
return "\u2588" * filled + "\u2591" * (10 - filled)
|
||||
|
||||
@staticmethod
|
||||
def _score_color(score: float) -> discord.Color:
|
||||
if score >= 0.75:
|
||||
return discord.Color.red()
|
||||
if score >= 0.6:
|
||||
return discord.Color.orange()
|
||||
if score >= 0.3:
|
||||
return discord.Color.yellow()
|
||||
return discord.Color.green()
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(CommandsCog(bot))
|
||||
555
cogs/sentiment.py
Normal file
555
cogs/sentiment.py
Normal file
@@ -0,0 +1,555 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import deque
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
logger = logging.getLogger("bcs.sentiment")
|
||||
|
||||
# How often to flush dirty user states to DB (seconds)
|
||||
STATE_FLUSH_INTERVAL = 300 # 5 minutes
|
||||
|
||||
|
||||
class SentimentCog(commands.Cog):
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
# Per-channel message history for context: {channel_id: deque of (author, content)}
|
||||
self._channel_history: dict[int, deque] = {}
|
||||
# Track which user IDs have unsaved in-memory changes
|
||||
self._dirty_users: set[int] = set()
|
||||
|
||||
async def cog_load(self):
|
||||
self._flush_states.start()
|
||||
|
||||
async def cog_unload(self):
|
||||
self._flush_states.cancel()
|
||||
# Final flush on shutdown
|
||||
await self._flush_dirty_states()
|
||||
|
||||
@commands.Cog.listener()
|
||||
async def on_message(self, message: discord.Message):
|
||||
logger.info("MSG from %s in #%s: %s", message.author, getattr(message.channel, 'name', 'DM'), message.content[:80] if message.content else "(empty)")
|
||||
|
||||
# Ignore bots (including ourselves)
|
||||
if message.author.bot:
|
||||
return
|
||||
|
||||
# Ignore DMs
|
||||
if not message.guild:
|
||||
return
|
||||
|
||||
config = self.bot.config
|
||||
monitoring = config.get("monitoring", {})
|
||||
|
||||
if not monitoring.get("enabled", True):
|
||||
return
|
||||
|
||||
# Check if channel is monitored
|
||||
monitored_channels = monitoring.get("channels", [])
|
||||
if monitored_channels and message.channel.id not in monitored_channels:
|
||||
return
|
||||
|
||||
# Check ignored users
|
||||
if message.author.id in monitoring.get("ignored_users", []):
|
||||
return
|
||||
|
||||
# Check immune roles
|
||||
immune_roles = set(monitoring.get("immune_roles", []))
|
||||
if immune_roles and any(
|
||||
r.id in immune_roles for r in message.author.roles
|
||||
):
|
||||
return
|
||||
|
||||
# Check per-user immunity
|
||||
if self.bot.drama_tracker.is_immune(message.author.id):
|
||||
return
|
||||
|
||||
# Store message in channel history for context
|
||||
self._store_context(message)
|
||||
|
||||
# Skip if empty
|
||||
if not message.content or not message.content.strip():
|
||||
return
|
||||
|
||||
# Check per-user analysis cooldown
|
||||
sentiment_config = config.get("sentiment", {})
|
||||
cooldown = sentiment_config.get("cooldown_between_analyses", 2)
|
||||
if not self.bot.drama_tracker.can_analyze(message.author.id, cooldown):
|
||||
return
|
||||
|
||||
# Analyze the message
|
||||
context = self._get_context(message)
|
||||
user_notes = self.bot.drama_tracker.get_user_notes(message.author.id)
|
||||
result = await self.bot.ollama.analyze_message(
|
||||
message.content, context, user_notes=user_notes
|
||||
)
|
||||
|
||||
if result is None:
|
||||
return
|
||||
|
||||
score = result["toxicity_score"]
|
||||
categories = result["categories"]
|
||||
reasoning = result["reasoning"]
|
||||
|
||||
# Track the result
|
||||
self.bot.drama_tracker.add_entry(
|
||||
message.author.id, score, categories, reasoning
|
||||
)
|
||||
|
||||
drama_score = self.bot.drama_tracker.get_drama_score(message.author.id)
|
||||
|
||||
logger.info(
|
||||
"User %s (%d) | msg_score=%.2f | drama_score=%.2f | categories=%s | %s",
|
||||
message.author.display_name,
|
||||
message.author.id,
|
||||
score,
|
||||
drama_score,
|
||||
categories,
|
||||
reasoning,
|
||||
)
|
||||
|
||||
# Topic drift detection
|
||||
off_topic = result.get("off_topic", False)
|
||||
topic_category = result.get("topic_category", "general_chat")
|
||||
topic_reasoning = result.get("topic_reasoning", "")
|
||||
|
||||
# Save message + analysis to DB (awaited — need message_id for action links)
|
||||
db_message_id = await self.bot.db.save_message_and_analysis(
|
||||
guild_id=message.guild.id,
|
||||
channel_id=message.channel.id,
|
||||
user_id=message.author.id,
|
||||
username=message.author.display_name,
|
||||
content=message.content,
|
||||
message_ts=message.created_at.replace(tzinfo=timezone.utc),
|
||||
toxicity_score=score,
|
||||
drama_score=drama_score,
|
||||
categories=categories,
|
||||
reasoning=reasoning,
|
||||
off_topic=off_topic,
|
||||
topic_category=topic_category,
|
||||
topic_reasoning=topic_reasoning,
|
||||
coherence_score=result.get("coherence_score"),
|
||||
coherence_flag=result.get("coherence_flag"),
|
||||
)
|
||||
|
||||
if off_topic:
|
||||
await self._handle_topic_drift(message, topic_category, topic_reasoning, db_message_id)
|
||||
|
||||
# Coherence / intoxication detection
|
||||
coherence_score = result.get("coherence_score", 0.85)
|
||||
coherence_flag = result.get("coherence_flag", "normal")
|
||||
coherence_config = config.get("coherence", {})
|
||||
if coherence_config.get("enabled", True):
|
||||
degradation = self.bot.drama_tracker.update_coherence(
|
||||
user_id=message.author.id,
|
||||
score=coherence_score,
|
||||
flag=coherence_flag,
|
||||
drop_threshold=coherence_config.get("drop_threshold", 0.3),
|
||||
absolute_floor=coherence_config.get("absolute_floor", 0.5),
|
||||
cooldown_minutes=coherence_config.get("cooldown_minutes", 30),
|
||||
)
|
||||
if degradation and not config.get("monitoring", {}).get("dry_run", False):
|
||||
await self._handle_coherence_alert(message, degradation, coherence_config, db_message_id)
|
||||
|
||||
# Capture LLM note updates about this user
|
||||
note_update = result.get("note_update")
|
||||
if note_update:
|
||||
self.bot.drama_tracker.update_user_notes(message.author.id, note_update)
|
||||
self._dirty_users.add(message.author.id)
|
||||
|
||||
# Mark dirty for coherence baseline drift even without actions
|
||||
self._dirty_users.add(message.author.id)
|
||||
|
||||
# Always log analysis to #bcs-log if it exists
|
||||
await self._log_analysis(message, score, drama_score, categories, reasoning, off_topic, topic_category)
|
||||
|
||||
# Dry-run mode: skip warnings/mutes
|
||||
dry_run = config.get("monitoring", {}).get("dry_run", False)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# Check thresholds — both rolling average AND single-message spikes
|
||||
warning_threshold = sentiment_config.get("warning_threshold", 0.6)
|
||||
base_mute_threshold = sentiment_config.get("mute_threshold", 0.75)
|
||||
mute_threshold = self.bot.drama_tracker.get_mute_threshold(
|
||||
message.author.id, base_mute_threshold
|
||||
)
|
||||
spike_warn = sentiment_config.get("spike_warning_threshold", 0.5)
|
||||
spike_mute = sentiment_config.get("spike_mute_threshold", 0.8)
|
||||
|
||||
# Mute: rolling average OR single message spike
|
||||
if drama_score >= mute_threshold or score >= spike_mute:
|
||||
effective_score = max(drama_score, score)
|
||||
await self._mute_user(message, effective_score, categories, db_message_id)
|
||||
# Warn: rolling average OR single message spike
|
||||
elif drama_score >= warning_threshold or score >= spike_warn:
|
||||
effective_score = max(drama_score, score)
|
||||
await self._warn_user(message, effective_score, db_message_id)
|
||||
|
||||
async def _mute_user(
|
||||
self,
|
||||
message: discord.Message,
|
||||
score: float,
|
||||
categories: list[str],
|
||||
db_message_id: int | None = None,
|
||||
):
|
||||
member = message.author
|
||||
if not isinstance(member, discord.Member):
|
||||
return
|
||||
|
||||
# Check bot permissions
|
||||
if not message.guild.me.guild_permissions.moderate_members:
|
||||
logger.warning("Missing moderate_members permission, cannot mute.")
|
||||
return
|
||||
|
||||
# Record offense and get escalating timeout
|
||||
offense_num = self.bot.drama_tracker.record_offense(member.id)
|
||||
timeout_config = self.bot.config.get("timeouts", {})
|
||||
escalation = timeout_config.get("escalation_minutes", [5, 15, 30, 60])
|
||||
idx = min(offense_num - 1, len(escalation) - 1)
|
||||
duration_minutes = escalation[idx]
|
||||
|
||||
try:
|
||||
await member.timeout(
|
||||
timedelta(minutes=duration_minutes),
|
||||
reason=f"BCS auto-mute: drama score {score:.2f}",
|
||||
)
|
||||
except discord.Forbidden:
|
||||
logger.warning("Cannot timeout %s — role hierarchy issue.", member)
|
||||
return
|
||||
except discord.HTTPException as e:
|
||||
logger.error("Failed to timeout %s: %s", member, e)
|
||||
return
|
||||
|
||||
# Build embed
|
||||
messages_config = self.bot.config.get("messages", {})
|
||||
cat_str = ", ".join(c for c in categories if c != "none") or "general negativity"
|
||||
|
||||
embed = discord.Embed(
|
||||
title=messages_config.get("mute_title", "BREEHAVIOR ALERT"),
|
||||
description=messages_config.get("mute_description", "").format(
|
||||
username=member.display_name,
|
||||
duration=f"{duration_minutes} minutes",
|
||||
score=f"{score:.2f}",
|
||||
categories=cat_str,
|
||||
),
|
||||
color=discord.Color.red(),
|
||||
)
|
||||
embed.set_footer(
|
||||
text=f"Offense #{offense_num} | Timeout: {duration_minutes}m"
|
||||
)
|
||||
|
||||
await message.channel.send(embed=embed)
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**MUTE** | {member.mention} | Score: {score:.2f} | "
|
||||
f"Duration: {duration_minutes}m | Offense #{offense_num} | "
|
||||
f"Categories: {cat_str}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Muted %s for %d minutes (offense #%d, score %.2f)",
|
||||
member,
|
||||
duration_minutes,
|
||||
offense_num,
|
||||
score,
|
||||
)
|
||||
|
||||
# Persist mute action and updated user state (fire-and-forget)
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id,
|
||||
user_id=member.id,
|
||||
username=member.display_name,
|
||||
action_type="mute",
|
||||
message_id=db_message_id,
|
||||
details=f"duration={duration_minutes}m offense={offense_num} score={score:.2f} categories={cat_str}",
|
||||
))
|
||||
self._save_user_state(member.id)
|
||||
|
||||
async def _warn_user(self, message: discord.Message, score: float, db_message_id: int | None = None):
|
||||
timeout_config = self.bot.config.get("timeouts", {})
|
||||
cooldown = timeout_config.get("warning_cooldown_minutes", 5)
|
||||
|
||||
if not self.bot.drama_tracker.can_warn(message.author.id, cooldown):
|
||||
return
|
||||
|
||||
self.bot.drama_tracker.record_warning(message.author.id)
|
||||
|
||||
# React with warning emoji
|
||||
try:
|
||||
await message.add_reaction("\u26a0\ufe0f")
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
# Send warning message
|
||||
messages_config = self.bot.config.get("messages", {})
|
||||
warning_text = messages_config.get(
|
||||
"warning",
|
||||
"Easy there, {username}. The Breehavior Monitor is watching.",
|
||||
).format(username=message.author.display_name)
|
||||
|
||||
await message.channel.send(warning_text)
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**WARNING** | {message.author.mention} | Score: {score:.2f}",
|
||||
)
|
||||
|
||||
logger.info("Warned %s (score %.2f)", message.author, score)
|
||||
|
||||
# Persist warning action (fire-and-forget)
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id,
|
||||
user_id=message.author.id,
|
||||
username=message.author.display_name,
|
||||
action_type="warning",
|
||||
message_id=db_message_id,
|
||||
details=f"score={score:.2f}",
|
||||
))
|
||||
|
||||
async def _handle_topic_drift(
|
||||
self, message: discord.Message, topic_category: str, topic_reasoning: str,
|
||||
db_message_id: int | None = None,
|
||||
):
|
||||
config = self.bot.config.get("topic_drift", {})
|
||||
if not config.get("enabled", True):
|
||||
return
|
||||
|
||||
# Check if we're in dry-run mode — still track but don't act
|
||||
dry_run = self.bot.config.get("monitoring", {}).get("dry_run", False)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
tracker = self.bot.drama_tracker
|
||||
user_id = message.author.id
|
||||
cooldown = config.get("remind_cooldown_minutes", 10)
|
||||
|
||||
if not tracker.can_topic_remind(user_id, cooldown):
|
||||
return
|
||||
|
||||
count = tracker.record_off_topic(user_id)
|
||||
escalation_threshold = config.get("escalation_count", 3)
|
||||
messages_config = self.bot.config.get("messages", {})
|
||||
|
||||
if count >= escalation_threshold and not tracker.was_owner_notified(user_id):
|
||||
# DM the server owner
|
||||
tracker.mark_owner_notified(user_id)
|
||||
owner = message.guild.owner
|
||||
if owner:
|
||||
dm_text = messages_config.get(
|
||||
"topic_owner_dm",
|
||||
"Heads up: {username} keeps going off-topic in #{channel}. Reminded {count} times.",
|
||||
).format(
|
||||
username=message.author.display_name,
|
||||
channel=message.channel.name,
|
||||
count=count,
|
||||
)
|
||||
try:
|
||||
await owner.send(dm_text)
|
||||
except discord.HTTPException:
|
||||
logger.warning("Could not DM server owner about topic drift.")
|
||||
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**TOPIC DRIFT — OWNER NOTIFIED** | {message.author.mention} | "
|
||||
f"Off-topic count: {count} | Category: {topic_category}",
|
||||
)
|
||||
logger.info("Notified owner about %s topic drift (count %d)", message.author, count)
|
||||
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id, user_id=user_id,
|
||||
username=message.author.display_name,
|
||||
action_type="topic_escalation", message_id=db_message_id,
|
||||
details=f"off_topic_count={count} category={topic_category}",
|
||||
))
|
||||
self._save_user_state(user_id)
|
||||
|
||||
elif count >= 2:
|
||||
# Firmer nudge
|
||||
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)
|
||||
await self._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(self.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}",
|
||||
))
|
||||
self._save_user_state(user_id)
|
||||
|
||||
else:
|
||||
# Friendly first reminder
|
||||
remind_text = messages_config.get(
|
||||
"topic_remind",
|
||||
"Hey {username}, this is a gaming server — maybe take the personal stuff to DMs?",
|
||||
).format(username=message.author.display_name)
|
||||
await message.channel.send(remind_text)
|
||||
await self._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(self.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}",
|
||||
))
|
||||
self._save_user_state(user_id)
|
||||
|
||||
async def _handle_coherence_alert(
|
||||
self, message: discord.Message, degradation: dict, coherence_config: dict,
|
||||
db_message_id: int | None = None,
|
||||
):
|
||||
flag = degradation["flag"]
|
||||
messages_map = coherence_config.get("messages", {})
|
||||
alert_text = messages_map.get(flag, messages_map.get(
|
||||
"default", "You okay there, {username}? That message was... something."
|
||||
)).format(username=message.author.display_name)
|
||||
|
||||
await message.channel.send(alert_text)
|
||||
await self._log_action(
|
||||
message.guild,
|
||||
f"**COHERENCE ALERT** | {message.author.mention} | "
|
||||
f"Score: {degradation['current']:.2f} | Baseline: {degradation['baseline']:.2f} | "
|
||||
f"Drop: {degradation['drop']:.2f} | Flag: {flag}",
|
||||
)
|
||||
logger.info(
|
||||
"Coherence alert for %s: score=%.2f baseline=%.2f drop=%.2f flag=%s",
|
||||
message.author, degradation["current"], degradation["baseline"],
|
||||
degradation["drop"], flag,
|
||||
)
|
||||
|
||||
asyncio.create_task(self.bot.db.save_action(
|
||||
guild_id=message.guild.id,
|
||||
user_id=message.author.id,
|
||||
username=message.author.display_name,
|
||||
action_type="coherence_alert",
|
||||
message_id=db_message_id,
|
||||
details=f"score={degradation['current']:.2f} baseline={degradation['baseline']:.2f} drop={degradation['drop']:.2f} flag={flag}",
|
||||
))
|
||||
self._save_user_state(message.author.id)
|
||||
|
||||
def _save_user_state(self, user_id: int) -> None:
|
||||
"""Fire-and-forget save of a user's current state to DB."""
|
||||
user_data = self.bot.drama_tracker.get_user(user_id)
|
||||
asyncio.create_task(self.bot.db.save_user_state(
|
||||
user_id=user_id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
))
|
||||
self._dirty_users.discard(user_id)
|
||||
|
||||
@tasks.loop(seconds=STATE_FLUSH_INTERVAL)
|
||||
async def _flush_states(self):
|
||||
await self._flush_dirty_states()
|
||||
|
||||
@_flush_states.before_loop
|
||||
async def _before_flush(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
async def _flush_dirty_states(self) -> None:
|
||||
"""Save all dirty user states to DB."""
|
||||
if not self._dirty_users:
|
||||
return
|
||||
dirty = list(self._dirty_users)
|
||||
self._dirty_users.clear()
|
||||
for user_id in dirty:
|
||||
user_data = self.bot.drama_tracker.get_user(user_id)
|
||||
await self.bot.db.save_user_state(
|
||||
user_id=user_id,
|
||||
offense_count=user_data.offense_count,
|
||||
immune=user_data.immune,
|
||||
off_topic_count=user_data.off_topic_count,
|
||||
baseline_coherence=user_data.baseline_coherence,
|
||||
user_notes=user_data.notes or None,
|
||||
)
|
||||
logger.info("Flushed %d dirty user states to DB.", len(dirty))
|
||||
|
||||
def _store_context(self, message: discord.Message):
|
||||
ch_id = message.channel.id
|
||||
if ch_id not in self._channel_history:
|
||||
max_ctx = self.bot.config.get("sentiment", {}).get(
|
||||
"context_messages", 3
|
||||
)
|
||||
self._channel_history[ch_id] = deque(maxlen=max_ctx + 1)
|
||||
self._channel_history[ch_id].append(
|
||||
(message.author.display_name, message.content)
|
||||
)
|
||||
|
||||
def _get_context(self, message: discord.Message) -> str:
|
||||
ch_id = message.channel.id
|
||||
history = self._channel_history.get(ch_id, deque())
|
||||
# Exclude the current message (last item)
|
||||
context_entries = list(history)[:-1] if len(history) > 1 else []
|
||||
if not context_entries:
|
||||
return "(no prior context)"
|
||||
return " | ".join(
|
||||
f"{name}: {content}" for name, content in context_entries
|
||||
)
|
||||
|
||||
async def _log_analysis(
|
||||
self, message: discord.Message, score: float, drama_score: float,
|
||||
categories: list[str], reasoning: str, off_topic: bool, topic_category: str,
|
||||
):
|
||||
log_channel = discord.utils.get(
|
||||
message.guild.text_channels, name="bcs-log"
|
||||
)
|
||||
if not log_channel:
|
||||
return
|
||||
|
||||
# Only log notable messages (score > 0.1) to avoid spam
|
||||
if score <= 0.1:
|
||||
return
|
||||
|
||||
cat_str = ", ".join(c for c in categories if c != "none") or "none"
|
||||
embed = discord.Embed(
|
||||
title=f"Analysis: {message.author.display_name}",
|
||||
description=f"#{message.channel.name}: {message.content[:200]}",
|
||||
color=self._score_color(score),
|
||||
)
|
||||
embed.add_field(name="Message Score", value=f"{score:.2f}", inline=True)
|
||||
embed.add_field(name="Rolling Drama", value=f"{drama_score:.2f}", inline=True)
|
||||
embed.add_field(name="Categories", value=cat_str, inline=True)
|
||||
embed.add_field(name="Reasoning", value=reasoning[:1024] or "n/a", inline=False)
|
||||
try:
|
||||
await log_channel.send(embed=embed)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _score_color(score: float) -> discord.Color:
|
||||
if score >= 0.75:
|
||||
return discord.Color.red()
|
||||
if score >= 0.6:
|
||||
return discord.Color.orange()
|
||||
if score >= 0.3:
|
||||
return discord.Color.yellow()
|
||||
return discord.Color.green()
|
||||
|
||||
async def _log_action(self, guild: discord.Guild, text: str):
|
||||
log_channel = discord.utils.get(guild.text_channels, name="bcs-log")
|
||||
if log_channel:
|
||||
try:
|
||||
await log_channel.send(text)
|
||||
except discord.HTTPException:
|
||||
pass
|
||||
|
||||
|
||||
async def setup(bot: commands.Bot):
|
||||
await bot.add_cog(SentimentCog(bot))
|
||||
52
config.yaml
Normal file
52
config.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
bot:
|
||||
prefix: "!"
|
||||
status: "Monitoring vibes..."
|
||||
|
||||
monitoring:
|
||||
dry_run: false # Log analysis results to channel but don't warn/mute
|
||||
enabled: true
|
||||
channels: [] # Empty = monitor all channels. Add channel IDs to limit.
|
||||
ignored_users: [] # User IDs to never monitor (bot owner, etc.)
|
||||
immune_roles: [] # Role IDs that are immune to monitoring
|
||||
|
||||
sentiment:
|
||||
warning_threshold: 0.6
|
||||
mute_threshold: 0.75
|
||||
spike_warning_threshold: 0.5 # Single message score that triggers instant warning
|
||||
spike_mute_threshold: 0.8 # Single message score that triggers instant mute
|
||||
context_messages: 3 # Number of previous messages to include as context
|
||||
rolling_window_size: 10 # Number of messages to track per user
|
||||
rolling_window_minutes: 15 # Time window for tracking
|
||||
cooldown_between_analyses: 2 # Seconds between analyzing same user's messages
|
||||
|
||||
topic_drift:
|
||||
enabled: true
|
||||
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
|
||||
reset_minutes: 60 # Reset off-topic count after this much on-topic behavior
|
||||
|
||||
timeouts:
|
||||
escalation_minutes: [5, 15, 30, 60] # Escalating timeout durations
|
||||
offense_reset_minutes: 120 # Reset offense counter after this much good behavior
|
||||
warning_cooldown_minutes: 5 # Don't warn same user more than once per this window
|
||||
|
||||
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_owner_dm: "Heads up: {username} keeps going off-topic with personal drama in #{channel}. They've been reminded {count} times. Might need a word."
|
||||
|
||||
coherence:
|
||||
enabled: true
|
||||
drop_threshold: 0.3 # How far below baseline triggers alert
|
||||
absolute_floor: 0.5 # Don't alert if score is above this regardless
|
||||
cooldown_minutes: 30 # Don't alert same user more than once per window
|
||||
messages:
|
||||
intoxicated: "Someone get {username} some water... or maybe cut them off."
|
||||
tired: "{username} might need some sleep, that message was rough."
|
||||
angry_typing: "{username} is typing so hard their keyboard is scared."
|
||||
mobile_keyboard: "{username}'s thumbs are having a rough day."
|
||||
language_barrier: "Having trouble there, {username}? Take your time."
|
||||
default: "You okay there, {username}? That message was... something."
|
||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
services:
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
container_name: bcs-mssql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ACCEPT_EULA: "Y"
|
||||
MSSQL_PID: Express
|
||||
SA_PASSWORD: ${MSSQL_SA_PASSWORD}
|
||||
ports:
|
||||
- "1433:1433"
|
||||
volumes:
|
||||
- mssql-data:/var/opt/mssql
|
||||
healthcheck:
|
||||
test: /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P "$$SA_PASSWORD" -C -Q "SELECT 1" -b -o /dev/null
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
bcs-bot:
|
||||
build: .
|
||||
container_name: bcs-bot
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml
|
||||
- ./logs:/app/logs
|
||||
network_mode: host # Needed to reach athena.lan on the LAN
|
||||
depends_on:
|
||||
mssql:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
mssql-data:
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
discord.py>=2.3.0
|
||||
openai>=1.0.0
|
||||
PyYAML>=6.0
|
||||
python-dotenv>=1.0.0
|
||||
pyodbc>=5.1.0
|
||||
0
utils/__init__.py
Normal file
0
utils/__init__.py
Normal file
353
utils/database.py
Normal file
353
utils/database.py
Normal file
@@ -0,0 +1,353 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger("bcs.database")
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self):
|
||||
self._conn_str = os.getenv("DB_CONNECTION_STRING", "")
|
||||
self._available = False
|
||||
|
||||
async def init(self) -> bool:
|
||||
"""Initialize the database connection and create schema.
|
||||
Returns True if DB is available, False for memory-only mode."""
|
||||
if not self._conn_str:
|
||||
logger.warning("DB_CONNECTION_STRING not set — running in memory-only mode.")
|
||||
return False
|
||||
|
||||
try:
|
||||
import pyodbc
|
||||
self._pyodbc = pyodbc
|
||||
except ImportError:
|
||||
logger.warning("pyodbc not installed — running in memory-only mode.")
|
||||
return False
|
||||
|
||||
try:
|
||||
conn = await asyncio.to_thread(self._connect)
|
||||
await asyncio.to_thread(self._create_schema, conn)
|
||||
conn.close()
|
||||
self._available = True
|
||||
logger.info("Database initialized successfully.")
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Database initialization failed — running in memory-only mode.")
|
||||
return False
|
||||
|
||||
def _connect(self):
|
||||
return self._pyodbc.connect(self._conn_str, autocommit=True)
|
||||
|
||||
def _create_schema(self, conn):
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create database if it doesn't exist
|
||||
db_name = self._parse_database_name()
|
||||
if db_name:
|
||||
cursor.execute(
|
||||
f"IF DB_ID('{db_name}') IS NULL CREATE DATABASE [{db_name}]"
|
||||
)
|
||||
cursor.execute(f"USE [{db_name}]")
|
||||
|
||||
cursor.execute("""
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Messages')
|
||||
CREATE TABLE Messages (
|
||||
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
GuildId BIGINT NOT NULL,
|
||||
ChannelId BIGINT NOT NULL,
|
||||
UserId BIGINT NOT NULL,
|
||||
Username NVARCHAR(100) NOT NULL,
|
||||
Content NVARCHAR(MAX) NOT NULL,
|
||||
MessageTs DATETIME2 NOT NULL,
|
||||
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AnalysisResults')
|
||||
CREATE TABLE AnalysisResults (
|
||||
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
MessageId BIGINT NOT NULL REFERENCES Messages(Id),
|
||||
ToxicityScore FLOAT NOT NULL,
|
||||
DramaScore FLOAT NOT NULL,
|
||||
Categories NVARCHAR(500) NOT NULL,
|
||||
Reasoning NVARCHAR(MAX) NOT NULL,
|
||||
OffTopic BIT NOT NULL DEFAULT 0,
|
||||
TopicCategory NVARCHAR(100) NULL,
|
||||
TopicReasoning NVARCHAR(MAX) NULL,
|
||||
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Actions')
|
||||
CREATE TABLE Actions (
|
||||
Id BIGINT IDENTITY(1,1) PRIMARY KEY,
|
||||
GuildId BIGINT NOT NULL,
|
||||
UserId BIGINT NOT NULL,
|
||||
Username NVARCHAR(100) NOT NULL,
|
||||
ActionType NVARCHAR(50) NOT NULL,
|
||||
MessageId BIGINT NULL REFERENCES Messages(Id),
|
||||
Details NVARCHAR(MAX) NULL,
|
||||
CreatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'UserState')
|
||||
CREATE TABLE UserState (
|
||||
UserId BIGINT NOT NULL PRIMARY KEY,
|
||||
OffenseCount INT NOT NULL DEFAULT 0,
|
||||
Immune BIT NOT NULL DEFAULT 0,
|
||||
OffTopicCount INT NOT NULL DEFAULT 0,
|
||||
UpdatedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME()
|
||||
)
|
||||
""")
|
||||
|
||||
# --- Schema migrations for coherence feature ---
|
||||
cursor.execute("""
|
||||
IF COL_LENGTH('AnalysisResults', 'CoherenceScore') IS NULL
|
||||
ALTER TABLE AnalysisResults ADD CoherenceScore FLOAT NULL
|
||||
""")
|
||||
cursor.execute("""
|
||||
IF COL_LENGTH('AnalysisResults', 'CoherenceFlag') IS NULL
|
||||
ALTER TABLE AnalysisResults ADD CoherenceFlag NVARCHAR(50) NULL
|
||||
""")
|
||||
cursor.execute("""
|
||||
IF COL_LENGTH('UserState', 'BaselineCoherence') IS NULL
|
||||
ALTER TABLE UserState ADD BaselineCoherence FLOAT NOT NULL DEFAULT 0.85
|
||||
""")
|
||||
|
||||
# --- Schema migration for per-user LLM notes ---
|
||||
cursor.execute("""
|
||||
IF COL_LENGTH('UserState', 'UserNotes') IS NULL
|
||||
ALTER TABLE UserState ADD UserNotes NVARCHAR(MAX) NULL
|
||||
""")
|
||||
|
||||
cursor.close()
|
||||
|
||||
def _parse_database_name(self) -> str:
|
||||
"""Extract DATABASE= value from the connection string."""
|
||||
for part in self._conn_str.split(";"):
|
||||
if part.strip().upper().startswith("DATABASE="):
|
||||
return part.split("=", 1)[1].strip()
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Message + Analysis (awaited — we need the returned message ID)
|
||||
# ------------------------------------------------------------------
|
||||
async def save_message_and_analysis(
|
||||
self,
|
||||
guild_id: int,
|
||||
channel_id: int,
|
||||
user_id: int,
|
||||
username: str,
|
||||
content: str,
|
||||
message_ts: datetime,
|
||||
toxicity_score: float,
|
||||
drama_score: float,
|
||||
categories: list[str],
|
||||
reasoning: str,
|
||||
off_topic: bool = False,
|
||||
topic_category: str | None = None,
|
||||
topic_reasoning: str | None = None,
|
||||
coherence_score: float | None = None,
|
||||
coherence_flag: str | None = None,
|
||||
) -> int | None:
|
||||
"""Save a message and its analysis result. Returns the message row ID."""
|
||||
if not self._available:
|
||||
return None
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
self._save_message_and_analysis_sync,
|
||||
guild_id, channel_id, user_id, username, content, message_ts,
|
||||
toxicity_score, drama_score, categories, reasoning,
|
||||
off_topic, topic_category, topic_reasoning,
|
||||
coherence_score, coherence_flag,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to save message and analysis")
|
||||
return None
|
||||
|
||||
def _save_message_and_analysis_sync(
|
||||
self,
|
||||
guild_id, channel_id, user_id, username, content, message_ts,
|
||||
toxicity_score, drama_score, categories, reasoning,
|
||||
off_topic, topic_category, topic_reasoning,
|
||||
coherence_score, coherence_flag,
|
||||
) -> int:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""INSERT INTO Messages (GuildId, ChannelId, UserId, Username, Content, MessageTs)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
guild_id, channel_id, user_id, username,
|
||||
content[:4000], # Truncate very long messages
|
||||
message_ts,
|
||||
)
|
||||
msg_id = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute(
|
||||
"""INSERT INTO AnalysisResults
|
||||
(MessageId, ToxicityScore, DramaScore, Categories, Reasoning,
|
||||
OffTopic, TopicCategory, TopicReasoning,
|
||||
CoherenceScore, CoherenceFlag)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
msg_id, toxicity_score, drama_score,
|
||||
json.dumps(categories), reasoning[:4000],
|
||||
1 if off_topic else 0,
|
||||
topic_category, topic_reasoning[:4000] if topic_reasoning else None,
|
||||
coherence_score, coherence_flag,
|
||||
)
|
||||
|
||||
cursor.close()
|
||||
return msg_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Actions (fire-and-forget via asyncio.create_task)
|
||||
# ------------------------------------------------------------------
|
||||
async def save_action(
|
||||
self,
|
||||
guild_id: int,
|
||||
user_id: int,
|
||||
username: str,
|
||||
action_type: str,
|
||||
message_id: int | None = None,
|
||||
details: str | None = None,
|
||||
) -> None:
|
||||
"""Save a moderation action (warning, mute, topic_remind, etc.)."""
|
||||
if not self._available:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self._save_action_sync,
|
||||
guild_id, user_id, username, action_type, message_id, details,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to save action")
|
||||
|
||||
def _save_action_sync(self, guild_id, user_id, username, action_type, message_id, details):
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""INSERT INTO Actions (GuildId, UserId, Username, ActionType, MessageId, Details)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
guild_id, user_id, username, action_type, message_id,
|
||||
details[:4000] if details else None,
|
||||
)
|
||||
cursor.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# UserState (upsert via MERGE)
|
||||
# ------------------------------------------------------------------
|
||||
async def save_user_state(
|
||||
self,
|
||||
user_id: int,
|
||||
offense_count: int,
|
||||
immune: bool,
|
||||
off_topic_count: int,
|
||||
baseline_coherence: float = 0.85,
|
||||
user_notes: str | None = None,
|
||||
) -> None:
|
||||
"""Upsert user state (offense count, immunity, off-topic count, coherence baseline, notes)."""
|
||||
if not self._available:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
self._save_user_state_sync,
|
||||
user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to save user state")
|
||||
|
||||
def _save_user_state_sync(self, user_id, offense_count, immune, off_topic_count, baseline_coherence, user_notes):
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""MERGE UserState AS target
|
||||
USING (SELECT ? AS UserId) AS source
|
||||
ON target.UserId = source.UserId
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET OffenseCount = ?, Immune = ?, OffTopicCount = ?,
|
||||
BaselineCoherence = ?, UserNotes = ?,
|
||||
UpdatedAt = SYSUTCDATETIME()
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes)
|
||||
VALUES (?, ?, ?, ?, ?, ?);""",
|
||||
user_id,
|
||||
offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes,
|
||||
user_id, offense_count, 1 if immune else 0, off_topic_count, baseline_coherence, user_notes,
|
||||
)
|
||||
cursor.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def delete_user_state(self, user_id: int) -> None:
|
||||
"""Remove a user's persisted state (used by /bcs-reset)."""
|
||||
if not self._available:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(self._delete_user_state_sync, user_id)
|
||||
except Exception:
|
||||
logger.exception("Failed to delete user state")
|
||||
|
||||
def _delete_user_state_sync(self, user_id):
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM UserState WHERE UserId = ?", user_id)
|
||||
cursor.close()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hydration (load all user states on startup)
|
||||
# ------------------------------------------------------------------
|
||||
async def load_all_user_states(self) -> list[dict]:
|
||||
"""Load all user states from the database for startup hydration.
|
||||
Returns list of dicts with user_id, offense_count, immune, off_topic_count."""
|
||||
if not self._available:
|
||||
return []
|
||||
try:
|
||||
return await asyncio.to_thread(self._load_all_user_states_sync)
|
||||
except Exception:
|
||||
logger.exception("Failed to load user states")
|
||||
return []
|
||||
|
||||
def _load_all_user_states_sync(self) -> list[dict]:
|
||||
conn = self._connect()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT UserId, OffenseCount, Immune, OffTopicCount, BaselineCoherence, UserNotes FROM UserState"
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
return [
|
||||
{
|
||||
"user_id": row[0],
|
||||
"offense_count": row[1],
|
||||
"immune": bool(row[2]),
|
||||
"off_topic_count": row[3],
|
||||
"baseline_coherence": float(row[4]),
|
||||
"user_notes": row[5] or "",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
async def close(self):
|
||||
"""No persistent connection to close (connections are per-operation)."""
|
||||
pass
|
||||
284
utils/drama_tracker.py
Normal file
284
utils/drama_tracker.py
Normal file
@@ -0,0 +1,284 @@
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnalysisEntry:
|
||||
timestamp: float
|
||||
toxicity_score: float
|
||||
categories: list[str]
|
||||
reasoning: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserDrama:
|
||||
entries: list[AnalysisEntry] = field(default_factory=list)
|
||||
offense_count: int = 0
|
||||
last_offense_time: float = 0.0
|
||||
last_warning_time: float = 0.0
|
||||
last_analysis_time: float = 0.0
|
||||
warned_since_reset: bool = False
|
||||
immune: bool = False
|
||||
# Topic drift tracking
|
||||
off_topic_count: int = 0
|
||||
last_topic_remind_time: float = 0.0
|
||||
owner_notified: bool = False
|
||||
# Coherence tracking
|
||||
coherence_scores: list[float] = field(default_factory=list)
|
||||
baseline_coherence: float = 0.85
|
||||
last_coherence_alert_time: float = 0.0
|
||||
# Per-user LLM notes
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class DramaTracker:
|
||||
def __init__(
|
||||
self,
|
||||
window_size: int = 10,
|
||||
window_minutes: int = 15,
|
||||
offense_reset_minutes: int = 120,
|
||||
):
|
||||
self.window_size = window_size
|
||||
self.window_seconds = window_minutes * 60
|
||||
self.offense_reset_seconds = offense_reset_minutes * 60
|
||||
self._users: dict[int, UserDrama] = {}
|
||||
|
||||
def get_user(self, user_id: int) -> UserDrama:
|
||||
if user_id not in self._users:
|
||||
self._users[user_id] = UserDrama()
|
||||
return self._users[user_id]
|
||||
|
||||
def add_entry(
|
||||
self,
|
||||
user_id: int,
|
||||
toxicity_score: float,
|
||||
categories: list[str],
|
||||
reasoning: str,
|
||||
) -> None:
|
||||
user = self.get_user(user_id)
|
||||
now = time.time()
|
||||
|
||||
user.entries.append(
|
||||
AnalysisEntry(
|
||||
timestamp=now,
|
||||
toxicity_score=toxicity_score,
|
||||
categories=categories,
|
||||
reasoning=reasoning,
|
||||
)
|
||||
)
|
||||
user.last_analysis_time = now
|
||||
self._prune_entries(user, now)
|
||||
|
||||
def get_drama_score(self, user_id: int) -> float:
|
||||
user = self.get_user(user_id)
|
||||
now = time.time()
|
||||
self._prune_entries(user, now)
|
||||
|
||||
if not user.entries:
|
||||
return 0.0
|
||||
|
||||
# Weighted average: more recent messages weighted higher
|
||||
total_weight = 0.0
|
||||
weighted_sum = 0.0
|
||||
for i, entry in enumerate(user.entries):
|
||||
weight = (i + 1) # linear weight, later entries = higher
|
||||
weighted_sum += entry.toxicity_score * weight
|
||||
total_weight += weight
|
||||
|
||||
return weighted_sum / total_weight if total_weight > 0 else 0.0
|
||||
|
||||
def get_mute_threshold(self, user_id: int, base_threshold: float) -> float:
|
||||
"""Lower the mute threshold if user was already warned."""
|
||||
user = self.get_user(user_id)
|
||||
if user.warned_since_reset:
|
||||
return base_threshold - 0.05
|
||||
return base_threshold
|
||||
|
||||
def record_offense(self, user_id: int) -> int:
|
||||
user = self.get_user(user_id)
|
||||
now = time.time()
|
||||
|
||||
# Reset offense count if enough time has passed
|
||||
if (
|
||||
user.last_offense_time > 0
|
||||
and now - user.last_offense_time > self.offense_reset_seconds
|
||||
):
|
||||
user.offense_count = 0
|
||||
|
||||
user.offense_count += 1
|
||||
user.last_offense_time = now
|
||||
user.warned_since_reset = False
|
||||
return user.offense_count
|
||||
|
||||
def record_warning(self, user_id: int) -> None:
|
||||
user = self.get_user(user_id)
|
||||
user.last_warning_time = time.time()
|
||||
user.warned_since_reset = True
|
||||
|
||||
def can_warn(self, user_id: int, cooldown_minutes: int) -> bool:
|
||||
user = self.get_user(user_id)
|
||||
if user.last_warning_time == 0.0:
|
||||
return True
|
||||
return time.time() - user.last_warning_time > cooldown_minutes * 60
|
||||
|
||||
def can_analyze(self, user_id: int, cooldown_seconds: int) -> bool:
|
||||
user = self.get_user(user_id)
|
||||
if user.last_analysis_time == 0.0:
|
||||
return True
|
||||
return time.time() - user.last_analysis_time > cooldown_seconds
|
||||
|
||||
def reset_user(self, user_id: int) -> None:
|
||||
if user_id in self._users:
|
||||
del self._users[user_id]
|
||||
|
||||
def toggle_immunity(self, user_id: int) -> bool:
|
||||
user = self.get_user(user_id)
|
||||
user.immune = not user.immune
|
||||
return user.immune
|
||||
|
||||
def is_immune(self, user_id: int) -> bool:
|
||||
if user_id not in self._users:
|
||||
return False
|
||||
return self._users[user_id].immune
|
||||
|
||||
def get_all_scores(self) -> dict[int, float]:
|
||||
scores = {}
|
||||
for user_id in list(self._users.keys()):
|
||||
score = self.get_drama_score(user_id)
|
||||
if score > 0.0:
|
||||
scores[user_id] = score
|
||||
return scores
|
||||
|
||||
def get_recent_incidents(
|
||||
self, user_id: int, count: int = 5
|
||||
) -> list[AnalysisEntry]:
|
||||
user = self.get_user(user_id)
|
||||
now = time.time()
|
||||
self._prune_entries(user, now)
|
||||
# Return entries with score > 0.3 (non-trivial)
|
||||
incidents = [e for e in user.entries if e.toxicity_score > 0.3]
|
||||
return incidents[-count:]
|
||||
|
||||
def record_off_topic(self, user_id: int) -> int:
|
||||
user = self.get_user(user_id)
|
||||
user.off_topic_count += 1
|
||||
user.last_topic_remind_time = time.time()
|
||||
return user.off_topic_count
|
||||
|
||||
def can_topic_remind(self, user_id: int, cooldown_minutes: int) -> bool:
|
||||
user = self.get_user(user_id)
|
||||
if user.last_topic_remind_time == 0.0:
|
||||
return True
|
||||
return time.time() - user.last_topic_remind_time > cooldown_minutes * 60
|
||||
|
||||
def get_off_topic_count(self, user_id: int) -> int:
|
||||
return self.get_user(user_id).off_topic_count
|
||||
|
||||
def mark_owner_notified(self, user_id: int) -> None:
|
||||
self.get_user(user_id).owner_notified = True
|
||||
|
||||
def was_owner_notified(self, user_id: int) -> bool:
|
||||
return self.get_user(user_id).owner_notified
|
||||
|
||||
def get_user_notes(self, user_id: int) -> str:
|
||||
return self.get_user(user_id).notes
|
||||
|
||||
def update_user_notes(self, user_id: int, note_update: str) -> None:
|
||||
user = self.get_user(user_id)
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
new_line = f"[{ts}] {note_update}"
|
||||
if user.notes:
|
||||
user.notes = f"{user.notes}\n{new_line}"
|
||||
else:
|
||||
user.notes = new_line
|
||||
# Trim oldest lines if over ~2000 chars
|
||||
while len(user.notes) > 2000:
|
||||
lines = user.notes.split("\n")
|
||||
if len(lines) <= 1:
|
||||
break
|
||||
user.notes = "\n".join(lines[1:])
|
||||
|
||||
def clear_user_notes(self, user_id: int) -> None:
|
||||
self.get_user(user_id).notes = ""
|
||||
|
||||
def reset_off_topic(self, user_id: int) -> None:
|
||||
user = self.get_user(user_id)
|
||||
user.off_topic_count = 0
|
||||
user.last_topic_remind_time = 0.0
|
||||
user.owner_notified = False
|
||||
|
||||
def update_coherence(
|
||||
self,
|
||||
user_id: int,
|
||||
score: float,
|
||||
flag: str,
|
||||
drop_threshold: float = 0.3,
|
||||
absolute_floor: float = 0.5,
|
||||
cooldown_minutes: int = 30,
|
||||
) -> dict | None:
|
||||
"""Update user's coherence baseline and detect degradation.
|
||||
Returns info dict if degradation detected, else None."""
|
||||
user = self.get_user(user_id)
|
||||
alpha = 0.1 # Slow-moving EMA — ~20 messages to shift significantly
|
||||
|
||||
# Keep a rolling window of recent scores (last 20)
|
||||
user.coherence_scores.append(score)
|
||||
if len(user.coherence_scores) > 20:
|
||||
user.coherence_scores = user.coherence_scores[-20:]
|
||||
|
||||
baseline_before = user.baseline_coherence
|
||||
drop = baseline_before - score
|
||||
|
||||
# Check for degradation BEFORE updating baseline
|
||||
degraded = (
|
||||
score < baseline_before - drop_threshold
|
||||
and score < absolute_floor
|
||||
)
|
||||
|
||||
# Update baseline with EMA
|
||||
user.baseline_coherence = alpha * score + (1 - alpha) * user.baseline_coherence
|
||||
|
||||
if not degraded:
|
||||
return None
|
||||
|
||||
# Check cooldown
|
||||
now = time.time()
|
||||
if (
|
||||
user.last_coherence_alert_time > 0
|
||||
and now - user.last_coherence_alert_time < cooldown_minutes * 60
|
||||
):
|
||||
return None
|
||||
|
||||
user.last_coherence_alert_time = now
|
||||
return {
|
||||
"baseline": baseline_before,
|
||||
"current": score,
|
||||
"drop": drop,
|
||||
"flag": flag,
|
||||
}
|
||||
|
||||
def load_user_states(self, states: list[dict]) -> int:
|
||||
"""Hydrate user state from database rows.
|
||||
Each dict must have: user_id, offense_count, immune, off_topic_count.
|
||||
Optionally includes baseline_coherence.
|
||||
Returns number of users loaded."""
|
||||
count = 0
|
||||
for state in states:
|
||||
user_id = state["user_id"]
|
||||
user = self.get_user(user_id)
|
||||
user.offense_count = state["offense_count"]
|
||||
user.immune = state["immune"]
|
||||
user.off_topic_count = state["off_topic_count"]
|
||||
if "baseline_coherence" in state:
|
||||
user.baseline_coherence = state["baseline_coherence"]
|
||||
if "user_notes" in state and state["user_notes"]:
|
||||
user.notes = state["user_notes"]
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def _prune_entries(self, user: UserDrama, now: float) -> None:
|
||||
cutoff = now - self.window_seconds
|
||||
user.entries = [e for e in user.entries if e.timestamp > cutoff]
|
||||
if len(user.entries) > self.window_size:
|
||||
user.entries = user.entries[-self.window_size :]
|
||||
289
utils/ollama_client.py
Normal file
289
utils/ollama_client.py
Normal file
@@ -0,0 +1,289 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
logger = logging.getLogger("bcs.llm")
|
||||
|
||||
SYSTEM_PROMPT = """You are a Discord chat moderator AI for a gaming server. You will be given a TARGET MESSAGE to analyze, plus recent channel context for background.
|
||||
|
||||
CRITICAL: Only score the TARGET MESSAGE. The context is ONLY for understanding tone and conversation flow. Do NOT score the context messages — they belong to other users and are already being analyzed separately.
|
||||
|
||||
CONTEXT — This is a friend group who use crude nicknames (e.g. "tits" is someone's nickname). A nickname alone is NOT toxic. However, you must still flag genuinely aggressive language.
|
||||
|
||||
SCORING GUIDE — Be precise with scores:
|
||||
- 0.0-0.1: Completely harmless. Casual chat, jokes, "lmao", greetings, game talk, nicknames.
|
||||
- 0.2-0.3: Mildly edgy. Light trash-talk that's clearly playful ("you suck at this game lol").
|
||||
- 0.4-0.5: Moderate. Frustrated or heated language that could go either way. Insults without "lol/lmao" softening them.
|
||||
- 0.6-0.7: Aggressive. Direct insults, hostility, telling someone off. Even among friends, "fuck you, you piece of shit" is aggressive language that should score here regardless of context.
|
||||
- 0.8-1.0: Severely toxic. Threats, targeted harassment, telling someone to leave, attacking insecurities, sustained personal attacks.
|
||||
|
||||
IMPORTANT RULES:
|
||||
- "Tits" as a nickname = 0.0, not toxic.
|
||||
- Profanity ALONE (just "fuck" or "shit" with no target) = low score (0.0-0.1).
|
||||
- Profanity DIRECTED AT someone ("fuck you", "you piece of shit") = moderate-to-high score (0.5-0.7) even among friends.
|
||||
- Do NOT let friendly context excuse clearly aggressive language. Friends can still cross lines.
|
||||
- If a message contains BOTH a nickname AND an insult ("fuck you tits you piece of shit"), score the insult, not the nickname.
|
||||
- If the target message is just "lmao", "lol", an emoji, or a short neutral reaction, it is ALWAYS 0.0 regardless of what other people said before it.
|
||||
|
||||
Also determine if the message is on-topic (gaming, games, matches, strategy, LFG, etc.) or off-topic personal drama (relationship issues, personal feuds, venting about real-life problems, gossip about people outside the server).
|
||||
|
||||
Also assess the message's coherence — how well-formed, readable, and grammatically correct it is.
|
||||
- 0.9-1.0: Clear, well-written, normal for this user
|
||||
- 0.6-0.8: Some errors but still understandable (normal texting shortcuts like "u" and "ur" are fine — don't penalize those)
|
||||
- 0.3-0.5: Noticeably degraded — garbled words, missing letters, broken sentences beyond normal shorthand
|
||||
- 0.0-0.2: Nearly incoherent — can barely understand what they're trying to say
|
||||
|
||||
You may also be given NOTES about this user from prior interactions. Use these to calibrate your scoring — for example, if notes say "uses heavy profanity casually" then profanity alone should score lower for this user.
|
||||
|
||||
If you notice something noteworthy about this user's communication style, behavior, or patterns that would help future analysis, include it as a note_update. Only add genuinely useful observations — don't repeat what's already in the notes. If nothing new, leave note_update as null.
|
||||
|
||||
Use the report_analysis tool to report your analysis of the TARGET MESSAGE only."""
|
||||
|
||||
ANALYSIS_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "report_analysis",
|
||||
"description": "Report the toxicity and topic analysis of a Discord message.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"toxicity_score": {
|
||||
"type": "number",
|
||||
"description": "Toxicity rating from 0.0 (completely harmless) to 1.0 (extremely toxic).",
|
||||
},
|
||||
"categories": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"aggressive",
|
||||
"passive_aggressive",
|
||||
"instigating",
|
||||
"hostile",
|
||||
"manipulative",
|
||||
"none",
|
||||
],
|
||||
},
|
||||
"description": "Detected toxicity behavior categories.",
|
||||
},
|
||||
"reasoning": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of the toxicity analysis.",
|
||||
},
|
||||
"off_topic": {
|
||||
"type": "boolean",
|
||||
"description": "True if the message is off-topic personal drama rather than gaming-related conversation.",
|
||||
},
|
||||
"topic_category": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"gaming",
|
||||
"personal_drama",
|
||||
"relationship_issues",
|
||||
"real_life_venting",
|
||||
"gossip",
|
||||
"general_chat",
|
||||
"meta",
|
||||
],
|
||||
"description": "What topic category the message falls into.",
|
||||
},
|
||||
"topic_reasoning": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of the topic classification.",
|
||||
},
|
||||
"coherence_score": {
|
||||
"type": "number",
|
||||
"description": "Coherence rating from 0.0 (incoherent gibberish) to 1.0 (clear and well-written). Normal texting shortcuts are fine.",
|
||||
},
|
||||
"coherence_flag": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"normal",
|
||||
"intoxicated",
|
||||
"tired",
|
||||
"angry_typing",
|
||||
"mobile_keyboard",
|
||||
"language_barrier",
|
||||
],
|
||||
"description": "Best guess at why coherence is low, if applicable.",
|
||||
},
|
||||
"note_update": {
|
||||
"type": ["string", "null"],
|
||||
"description": "Brief new observation about this user's style/behavior for future reference, or null if nothing new.",
|
||||
},
|
||||
},
|
||||
"required": ["toxicity_score", "categories", "reasoning", "off_topic", "topic_category", "topic_reasoning", "coherence_score", "coherence_flag"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LLMClient:
|
||||
def __init__(self, base_url: str, model: str, api_key: str = "not-needed"):
|
||||
self.model = model
|
||||
self.host = base_url.rstrip("/")
|
||||
self._client = AsyncOpenAI(
|
||||
base_url=f"{self.host}/v1",
|
||||
api_key=api_key,
|
||||
timeout=300.0, # 5 min — first request loads model into VRAM
|
||||
)
|
||||
|
||||
async def close(self):
|
||||
await self._client.close()
|
||||
|
||||
async def analyze_message(
|
||||
self, message: str, context: str = "", user_notes: str = ""
|
||||
) -> dict | None:
|
||||
user_content = f"=== CONTEXT (other users' recent messages, for background only) ===\n{context}\n\n"
|
||||
if user_notes:
|
||||
user_content += f"=== NOTES ABOUT THIS USER (from prior analysis) ===\n{user_notes}\n\n"
|
||||
user_content += f"=== TARGET MESSAGE (analyze THIS message only) ===\n{message}"
|
||||
|
||||
try:
|
||||
response = await self._client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
tools=[ANALYSIS_TOOL],
|
||||
tool_choice={"type": "function", "function": {"name": "report_analysis"}},
|
||||
temperature=0.1,
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
|
||||
# Extract tool call arguments
|
||||
if choice.message.tool_calls:
|
||||
tool_call = choice.message.tool_calls[0]
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
return self._validate_result(args)
|
||||
|
||||
# Fallback: try parsing the message content as JSON
|
||||
if choice.message.content:
|
||||
return self._parse_content_fallback(choice.message.content)
|
||||
|
||||
logger.warning("No tool call or content in LLM response.")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("LLM analysis error: %s", e)
|
||||
return None
|
||||
|
||||
def _validate_result(self, result: dict) -> dict:
|
||||
score = float(result.get("toxicity_score", 0.0))
|
||||
result["toxicity_score"] = min(max(score, 0.0), 1.0)
|
||||
|
||||
if not isinstance(result.get("categories"), list):
|
||||
result["categories"] = ["none"]
|
||||
|
||||
if not isinstance(result.get("reasoning"), str):
|
||||
result["reasoning"] = ""
|
||||
|
||||
result["off_topic"] = bool(result.get("off_topic", False))
|
||||
result.setdefault("topic_category", "general_chat")
|
||||
result.setdefault("topic_reasoning", "")
|
||||
|
||||
coherence = float(result.get("coherence_score", 0.85))
|
||||
result["coherence_score"] = min(max(coherence, 0.0), 1.0)
|
||||
result.setdefault("coherence_flag", "normal")
|
||||
|
||||
result.setdefault("note_update", None)
|
||||
|
||||
return result
|
||||
|
||||
def _parse_content_fallback(self, text: str) -> dict | None:
|
||||
"""Try to parse plain-text content as JSON if tool calling didn't work."""
|
||||
import re
|
||||
|
||||
# Try direct JSON
|
||||
try:
|
||||
result = json.loads(text.strip())
|
||||
return self._validate_result(result)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Try extracting from code block
|
||||
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
result = json.loads(match.group(1))
|
||||
return self._validate_result(result)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
|
||||
# Regex fallback for toxicity_score
|
||||
score_match = re.search(r'"toxicity_score"\s*:\s*([\d.]+)', text)
|
||||
if score_match:
|
||||
return {
|
||||
"toxicity_score": min(max(float(score_match.group(1)), 0.0), 1.0),
|
||||
"categories": ["unknown"],
|
||||
"reasoning": "Parsed via fallback regex",
|
||||
}
|
||||
|
||||
logger.warning("Could not parse LLM content fallback: %s", text[:200])
|
||||
return None
|
||||
|
||||
async def chat(
|
||||
self, messages: list[dict[str, str]], system_prompt: str
|
||||
) -> str | None:
|
||||
"""Send a conversational chat request (no tools)."""
|
||||
try:
|
||||
response = await self._client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
*messages,
|
||||
],
|
||||
temperature=0.8,
|
||||
max_tokens=300,
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
return content.strip() if content else None
|
||||
except Exception as e:
|
||||
logger.error("LLM chat error: %s", e)
|
||||
return None
|
||||
|
||||
async def raw_analyze(self, message: str, context: str = "", user_notes: str = "") -> tuple[str, dict | None]:
|
||||
"""Return the raw LLM response string AND parsed result for /bcs-test (single LLM call)."""
|
||||
user_content = f"=== CONTEXT (other users' recent messages, for background only) ===\n{context}\n\n"
|
||||
if user_notes:
|
||||
user_content += f"=== NOTES ABOUT THIS USER (from prior analysis) ===\n{user_notes}\n\n"
|
||||
user_content += f"=== TARGET MESSAGE (analyze THIS message only) ===\n{message}"
|
||||
|
||||
try:
|
||||
response = await self._client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
],
|
||||
tools=[ANALYSIS_TOOL],
|
||||
tool_choice={"type": "function", "function": {"name": "report_analysis"}},
|
||||
temperature=0.1,
|
||||
)
|
||||
|
||||
choice = response.choices[0]
|
||||
parts = []
|
||||
parsed = None
|
||||
|
||||
if choice.message.content:
|
||||
parts.append(f"Content: {choice.message.content}")
|
||||
|
||||
if choice.message.tool_calls:
|
||||
for tc in choice.message.tool_calls:
|
||||
parts.append(
|
||||
f"Tool call: {tc.function.name}({tc.function.arguments})"
|
||||
)
|
||||
# Parse the first tool call
|
||||
args = json.loads(choice.message.tool_calls[0].function.arguments)
|
||||
parsed = self._validate_result(args)
|
||||
elif choice.message.content:
|
||||
parsed = self._parse_content_fallback(choice.message.content)
|
||||
|
||||
raw = "\n".join(parts) or "(empty response)"
|
||||
return raw, parsed
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {e}", None
|
||||
Reference in New Issue
Block a user