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 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,8 @@ class SentimentCog(commands.Cog):
|
|||||||
self._message_buffer: dict[tuple[int, int], list[discord.Message]] = {}
|
self._message_buffer: dict[tuple[int, int], list[discord.Message]] = {}
|
||||||
# Pending debounce timer tasks
|
# Pending debounce timer tasks
|
||||||
self._debounce_tasks: dict[tuple[int, int], asyncio.Task] = {}
|
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):
|
async def cog_load(self):
|
||||||
self._flush_states.start()
|
self._flush_states.start()
|
||||||
@@ -245,6 +247,16 @@ class SentimentCog(commands.Cog):
|
|||||||
if degradation and not config.get("monitoring", {}).get("dry_run", False):
|
if degradation and not config.get("monitoring", {}).get("dry_run", False):
|
||||||
await self._handle_coherence_alert(message, degradation, coherence_config, db_message_id)
|
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
|
# Capture LLM note updates about this user
|
||||||
note_update = result.get("note_update")
|
note_update = result.get("note_update")
|
||||||
if note_update:
|
if note_update:
|
||||||
@@ -543,6 +555,57 @@ class SentimentCog(commands.Cog):
|
|||||||
))
|
))
|
||||||
self._save_user_state(message.author.id)
|
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:
|
def _save_user_state(self, user_id: int) -> None:
|
||||||
"""Fire-and-forget save of a user's current state to DB."""
|
"""Fire-and-forget save of a user's current state to DB."""
|
||||||
user_data = self.bot.drama_tracker.get_user(user_id)
|
user_data = self.bot.drama_tracker.get_user(user_id)
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ modes:
|
|||||||
spike_warning_threshold: 0.75
|
spike_warning_threshold: 0.75
|
||||||
spike_mute_threshold: 0.90
|
spike_mute_threshold: 0.90
|
||||||
|
|
||||||
|
polls:
|
||||||
|
enabled: true
|
||||||
|
duration_hours: 4
|
||||||
|
cooldown_minutes: 60 # Per-channel cooldown between auto-polls
|
||||||
|
|
||||||
coherence:
|
coherence:
|
||||||
enabled: true
|
enabled: true
|
||||||
drop_threshold: 0.3 # How far below baseline triggers alert
|
drop_threshold: 0.3 # How far below baseline triggers alert
|
||||||
|
|||||||
@@ -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.
|
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.
|
Use the report_analysis tool to report your analysis of the TARGET MESSAGE only.
|
||||||
@@ -90,6 +90,36 @@ ANALYSIS_TOOL = {
|
|||||||
"type": ["string", "null"],
|
"type": ["string", "null"],
|
||||||
"description": "The game channel name this message is about (e.g. 'gta-online', 'warzone'), or null if not game-specific.",
|
"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"],
|
"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("note_update", None)
|
||||||
result.setdefault("detected_game", 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
|
return result
|
||||||
|
|
||||||
def _parse_content_fallback(self, text: str) -> dict | None:
|
def _parse_content_fallback(self, text: str) -> dict | None:
|
||||||
|
|||||||
Reference in New Issue
Block a user