From 622f0a325b391ab95070b92dc1e76a2a9168d74e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 23 Feb 2026 09:22:32 -0500 Subject: [PATCH] Add auto-polls to settle disagreements between users LLM analysis now detects when two users are in a genuine disagreement. When detected, the bot creates a native Discord poll with each user's position as an option. - Disagreement detection added to LLM analysis tool schema - Polls last 4 hours with 1 hour per-channel cooldown - LLM extracts topic, both positions, and usernames - Configurable via polls section in config.yaml Co-Authored-By: Claude Opus 4.6 --- cogs/sentiment.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ config.yaml | 5 ++++ prompts/analysis.txt | 2 ++ utils/llm_client.py | 43 ++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+) diff --git a/cogs/sentiment.py b/cogs/sentiment.py index 4a25711..55e0ed5 100644 --- a/cogs/sentiment.py +++ b/cogs/sentiment.py @@ -25,6 +25,8 @@ class SentimentCog(commands.Cog): self._message_buffer: dict[tuple[int, int], list[discord.Message]] = {} # Pending debounce timer tasks self._debounce_tasks: dict[tuple[int, int], asyncio.Task] = {} + # Per-channel poll cooldown: {channel_id: last_poll_datetime} + self._poll_cooldowns: dict[int, datetime] = {} async def cog_load(self): self._flush_states.start() @@ -245,6 +247,16 @@ class SentimentCog(commands.Cog): if degradation and not config.get("monitoring", {}).get("dry_run", False): await self._handle_coherence_alert(message, degradation, coherence_config, db_message_id) + # Disagreement poll detection + polls_config = config.get("polls", {}) + if ( + polls_config.get("enabled", False) + and result.get("disagreement_detected", False) + and result.get("disagreement_summary") + and not monitoring.get("dry_run", False) + ): + await self._handle_disagreement_poll(message, result["disagreement_summary"], polls_config) + # Capture LLM note updates about this user note_update = result.get("note_update") if note_update: @@ -543,6 +555,57 @@ class SentimentCog(commands.Cog): )) self._save_user_state(message.author.id) + async def _handle_disagreement_poll( + self, message: discord.Message, summary: dict, polls_config: dict, + ): + """Create a Discord poll to settle a detected disagreement.""" + ch_id = message.channel.id + cooldown_minutes = polls_config.get("cooldown_minutes", 60) + now = datetime.now(timezone.utc) + + # Check per-channel cooldown + last_poll = self._poll_cooldowns.get(ch_id) + if last_poll and (now - last_poll) < timedelta(minutes=cooldown_minutes): + return + + topic = summary.get("topic", "Who's right?") + side_a = summary.get("side_a", "Side A") + side_b = summary.get("side_b", "Side B") + user_a = summary.get("user_a", "") + user_b = summary.get("user_b", "") + + # Build poll question + question_text = f"Settle this: {topic}"[:300] + + # Build answer labels with usernames + label_a = f"{side_a} ({user_a})" if user_a else side_a + label_b = f"{side_b} ({user_b})" if user_b else side_b + + duration_hours = polls_config.get("duration_hours", 4) + + try: + poll = discord.Poll( + question=question_text, + duration=timedelta(hours=duration_hours), + ) + poll.add_answer(text=label_a[:55]) + poll.add_answer(text=label_b[:55]) + + await message.channel.send(poll=poll) + self._poll_cooldowns[ch_id] = now + + await self._log_action( + message.guild, + f"**AUTO-POLL** | #{message.channel.name} | " + f"{question_text} | {label_a} vs {label_b}", + ) + logger.info( + "Auto-poll created in #%s: %s | %s vs %s", + message.channel.name, topic, label_a, label_b, + ) + except discord.HTTPException as e: + logger.error("Failed to create disagreement poll: %s", e) + 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) diff --git a/config.yaml b/config.yaml index 79e1ce7..0e61b4a 100644 --- a/config.yaml +++ b/config.yaml @@ -84,6 +84,11 @@ modes: spike_warning_threshold: 0.75 spike_mute_threshold: 0.90 +polls: + enabled: true + duration_hours: 4 + cooldown_minutes: 60 # Per-channel cooldown between auto-polls + coherence: enabled: true drop_threshold: 0.3 # How far below baseline triggers alert diff --git a/prompts/analysis.txt b/prompts/analysis.txt index 2db7f34..17dad74 100644 --- a/prompts/analysis.txt +++ b/prompts/analysis.txt @@ -36,4 +36,6 @@ If you notice something noteworthy about this user's communication style, behavi GAME DETECTION — If CHANNEL INFO is provided, identify which specific game the message is discussing. Set detected_game to the channel name that best matches (e.g. "gta-online", "warzone", "battlefield", "cod-zombies") using ONLY the channel names listed in the channel info. If the message isn't about a specific game or you're unsure, set detected_game to null. +DISAGREEMENT DETECTION — Look at the recent channel context. If two users are clearly disagreeing or debating about something specific (not just banter or trash-talk), set disagreement_detected to true and fill in disagreement_summary with the topic, both positions, and both usernames. Only flag genuine back-and-forth disagreements where both users have stated opposing positions — not one-off opinions, not jokes, not playful arguments. The topic should be a short question (e.g. "Are snipers OP?"), and each side should be a concise position statement. + Use the report_analysis tool to report your analysis of the TARGET MESSAGE only. \ No newline at end of file diff --git a/utils/llm_client.py b/utils/llm_client.py index e6b2c27..7130548 100644 --- a/utils/llm_client.py +++ b/utils/llm_client.py @@ -90,6 +90,36 @@ ANALYSIS_TOOL = { "type": ["string", "null"], "description": "The game channel name this message is about (e.g. 'gta-online', 'warzone'), or null if not game-specific.", }, + "disagreement_detected": { + "type": "boolean", + "description": "True if the target message is part of a clear disagreement between two users in the recent context. Only flag genuine back-and-forth debates, not one-off opinions.", + }, + "disagreement_summary": { + "type": ["object", "null"], + "description": "If disagreement_detected is true, summarize the disagreement. Null otherwise.", + "properties": { + "topic": { + "type": "string", + "description": "Short topic of the disagreement (max 60 chars, e.g. 'Are snipers OP in Warzone?').", + }, + "side_a": { + "type": "string", + "description": "First user's position (max 50 chars, e.g. 'Snipers are overpowered').", + }, + "side_b": { + "type": "string", + "description": "Second user's position (max 50 chars, e.g. 'Snipers are balanced').", + }, + "user_a": { + "type": "string", + "description": "Display name of the first user.", + }, + "user_b": { + "type": "string", + "description": "Display name of the second user.", + }, + }, + }, }, "required": ["toxicity_score", "categories", "reasoning", "off_topic", "topic_category", "topic_reasoning", "coherence_score", "coherence_flag"], }, @@ -213,6 +243,19 @@ class LLMClient: result.setdefault("note_update", None) result.setdefault("detected_game", None) + result["disagreement_detected"] = bool(result.get("disagreement_detected", False)) + summary = result.get("disagreement_summary") + if result["disagreement_detected"] and isinstance(summary, dict): + # Truncate fields to Discord poll limits + summary["topic"] = str(summary.get("topic", ""))[:60] + summary["side_a"] = str(summary.get("side_a", ""))[:50] + summary["side_b"] = str(summary.get("side_b", ""))[:50] + summary.setdefault("user_a", "") + summary.setdefault("user_b", "") + result["disagreement_summary"] = summary + else: + result["disagreement_summary"] = None + return result def _parse_content_fallback(self, text: str) -> dict | None: