fix: separate context from new messages so prior-cycle chat doesn't inflate scores

The conversation analysis was re-scoring old messages alongside new ones,
causing users to get penalized repeatedly for already-scored messages.
A "--- NEW MESSAGES ---" separator now marks which messages are new, and
the prompt instructs the LLM to score only those. Also fixes bot-mention
detection to require an explicit @mention in message text rather than
treating reply-pings as scans (so toxic replies to bot warnings aren't
silently skipped).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 15:48:02 -05:00
parent 8734f1883b
commit 7417908142
3 changed files with 31 additions and 17 deletions

View File

@@ -87,19 +87,18 @@ class SentimentCog(commands.Cog):
if self.bot.drama_tracker.is_immune(message.author.id): if self.bot.drama_tracker.is_immune(message.author.id):
return return
# Messages directed at the bot (mentions, replies) shouldn't be scored # Explicit @mention of the bot triggers a mention scan instead of scoring.
# for toxicity — but @mentions can trigger a scan of recent chat # Reply-pings (Discord auto-adds replied-to user to mentions) should NOT
directed_at_bot = self.bot.user in message.mentions # trigger scans — and reply-to-bot messages should still be scored normally
if not directed_at_bot and message.reference and message.reference.message_id: # so toxic replies to bot warnings aren't silently skipped.
ref = message.reference.cached_message bot_mentioned_in_text = (
if ref and ref.author.id == self.bot.user.id: f"<@{self.bot.user.id}>" in (message.content or "")
directed_at_bot = True or f"<@!{self.bot.user.id}>" in (message.content or "")
if directed_at_bot: )
# @mention (not just reply-to-bot) triggers a mention scan if bot_mentioned_in_text:
if self.bot.user in message.mentions: mention_config = config.get("mention_scan", {})
mention_config = config.get("mention_scan", {}) if mention_config.get("enabled", True):
if mention_config.get("enabled", True): await self._maybe_start_mention_scan(message, mention_config)
await self._maybe_start_mention_scan(message, mention_config)
return return
# Skip if empty # Skip if empty
@@ -166,6 +165,7 @@ class SentimentCog(commands.Cog):
history_messages.reverse() # chronological order history_messages.reverse() # chronological order
# Combine: history (context) + buffered (new messages to analyze) # Combine: history (context) + buffered (new messages to analyze)
new_message_start = len(history_messages)
all_messages = history_messages + messages all_messages = history_messages + messages
# Build msg_id_to_author lookup for reply resolution # Build msg_id_to_author lookup for reply resolution
@@ -215,6 +215,7 @@ class SentimentCog(commands.Cog):
conversation, conversation,
channel_context=channel_context, channel_context=channel_context,
user_notes_map=user_notes_map, user_notes_map=user_notes_map,
new_message_start=new_message_start,
) )
if result is None: if result is None:
@@ -233,6 +234,7 @@ class SentimentCog(commands.Cog):
conversation, conversation,
channel_context=channel_context, channel_context=channel_context,
user_notes_map=user_notes_map, user_notes_map=user_notes_map,
new_message_start=new_message_start,
) )
if heavy_result is not None: if heavy_result is not None:
logger.info( logger.info(

View File

@@ -40,8 +40,10 @@ Use the report_analysis tool to report your analysis of the TARGET MESSAGE only.
CONVERSATION-LEVEL ANALYSIS (when given a CONVERSATION BLOCK instead of a single TARGET MESSAGE): CONVERSATION-LEVEL ANALYSIS (when given a CONVERSATION BLOCK instead of a single TARGET MESSAGE):
When you receive a full conversation block with multiple users, use the report_conversation_scan tool instead: When you receive a full conversation block with multiple users, use the report_conversation_scan tool instead:
- Provide ONE finding per user (not per message) — aggregate their behavior across the conversation. - The conversation block may contain a "--- NEW MESSAGES (score only these) ---" separator. Messages ABOVE the separator are CONTEXT ONLY (already scored in a prior cycle) — do NOT let them inflate scores. Messages BELOW the separator are the NEW messages to score.
- Weight their average tone and worst message equally when determining the toxicity_score. - Provide ONE finding per user who has NEW messages (not per message).
- Score based ONLY on the user's NEW messages. Use context messages to understand tone and relationships, but do NOT penalize a user for something they said in the context section.
- If a user's only new message is benign (e.g. "I got the 17.."), score it low regardless of what they said in context.
- Use the same scoring bands (0.0-1.0) as for single messages. - Use the same scoring bands (0.0-1.0) as for single messages.
- Quote the worst/most problematic snippet in worst_message (max 100 chars, exact quote). - Quote the worst/most problematic snippet in worst_message (max 100 chars, exact quote).
- Flag off_topic if user's messages are primarily personal drama, not gaming. - Flag off_topic if user's messages are primarily personal drama, not gaming.

View File

@@ -383,12 +383,16 @@ class LLMClient:
def _format_conversation_block( def _format_conversation_block(
messages: list[tuple[str, str, datetime, str | None]], messages: list[tuple[str, str, datetime, str | None]],
now: datetime | None = None, now: datetime | None = None,
new_message_start: int | None = None,
) -> str: ) -> str:
"""Format messages as a compact timestamped chat block. """Format messages as a compact timestamped chat block.
Each tuple is (username, content, timestamp, reply_to_username). Each tuple is (username, content, timestamp, reply_to_username).
Consecutive messages from the same user collapse to indented lines. Consecutive messages from the same user collapse to indented lines.
Replies shown as ``username → replied_to:``. Replies shown as ``username → replied_to:``.
If *new_message_start* is given, a separator is inserted before that
index so the LLM can distinguish context from new messages.
""" """
if now is None: if now is None:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -396,7 +400,12 @@ class LLMClient:
lines = [f"[Current time: {now.strftime('%I:%M %p')}]", ""] lines = [f"[Current time: {now.strftime('%I:%M %p')}]", ""]
last_user = None last_user = None
for username, content, ts, reply_to in messages: for idx, (username, content, ts, reply_to) in enumerate(messages):
if new_message_start is not None and idx == new_message_start:
lines.append("")
lines.append("--- NEW MESSAGES (score only these) ---")
lines.append("")
last_user = None # reset collapse so first new msg gets full header
delta = now - ts.replace(tzinfo=timezone.utc) if ts.tzinfo is None else now - ts delta = now - ts.replace(tzinfo=timezone.utc) if ts.tzinfo is None else now - ts
rel = LLMClient._format_relative_time(delta) rel = LLMClient._format_relative_time(delta)
@@ -425,12 +434,13 @@ class LLMClient:
mention_context: str = "", mention_context: str = "",
channel_context: str = "", channel_context: str = "",
user_notes_map: dict[str, str] | None = None, user_notes_map: dict[str, str] | None = None,
new_message_start: int | None = None,
) -> dict | None: ) -> dict | None:
"""Analyze a conversation block in one call, returning per-user findings.""" """Analyze a conversation block in one call, returning per-user findings."""
if not messages: if not messages:
return None return None
convo_block = self._format_conversation_block(messages) convo_block = self._format_conversation_block(messages, new_message_start=new_message_start)
user_content = f"=== CONVERSATION BLOCK ===\n{convo_block}\n\n" user_content = f"=== CONVERSATION BLOCK ===\n{convo_block}\n\n"
if user_notes_map: if user_notes_map: