diff options
| author | Fuwn <[email protected]> | 2025-09-17 19:28:59 -0700 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2025-09-17 19:28:59 -0700 |
| commit | 4eb16c473424f6844b1bb2cdc5d3cb5aff916d12 (patch) | |
| tree | 116f76ea0627536bd7ce3d39e10aa7615ec33549 /src | |
| parent | refactor(media_required): Rename to roleplay_media_required (diff) | |
| download | umabot-4eb16c473424f6844b1bb2cdc5d3cb5aff916d12.tar.xz umabot-4eb16c473424f6844b1bb2cdc5d3cb5aff916d12.zip | |
feat(rules): Add intelligent roleplay moderator rule
Diffstat (limited to 'src')
| -rw-r--r-- | src/umabot/bot.py | 9 | ||||
| -rw-r--r-- | src/umabot/config.py | 6 | ||||
| -rw-r--r-- | src/umabot/rules/__init__.py | 3 | ||||
| -rw-r--r-- | src/umabot/rules/intelligent_roleplay_moderator.py | 351 |
4 files changed, 363 insertions, 6 deletions
diff --git a/src/umabot/bot.py b/src/umabot/bot.py index 87068a5..510ca7a 100644 --- a/src/umabot/bot.py +++ b/src/umabot/bot.py @@ -9,7 +9,7 @@ from socketserver import ThreadingMixIn from loguru import logger from .config import Config -from .rules import SpamDetector, RoleplayLimiter, RoleplayMediaRequiredRule, RoleplayWordCountRule +from .rules import SpamDetector, IntelligentRoleplayModerator # from .rules import StaticRoleplayLimiter # Disabled by default - uncomment to use static limiting @@ -76,9 +76,10 @@ class UmaBot: # Initialize rules self.rules = [ SpamDetector(config), - RoleplayMediaRequiredRule(config), # Requires media for roleplay posts - RoleplayWordCountRule(config), # Sends short roleplay posts to mod queue - RoleplayLimiter(config, self.subreddit) # Surge-based roleplay limiter (default) + IntelligentRoleplayModerator(config, self.subreddit), # Intelligent roleplay moderation + # RoleplayMediaRequiredRule(config), # Disabled - using intelligent moderator + # RoleplayWordCountRule(config), # Disabled - using intelligent moderator + # RoleplayLimiter(config, self.subreddit) # Disabled - using intelligent moderator # StaticRoleplayLimiter(config) # Uncomment to use static roleplay limiting instead ] diff --git a/src/umabot/config.py b/src/umabot/config.py index f49f936..ab03a25 100644 --- a/src/umabot/config.py +++ b/src/umabot/config.py @@ -19,6 +19,9 @@ class Config: password: str user_agent: str + # OpenAI API credentials + openai_api_key: str + # Subreddit configuration subreddit_name: str @@ -42,6 +45,7 @@ class Config: username=os.getenv("REDDIT_USERNAME", ""), password=os.getenv("REDDIT_PASSWORD", ""), user_agent=os.getenv("REDDIT_USER_AGENT", "UmaBot/0.1.0"), + openai_api_key=os.getenv("OPENAI_API_KEY", ""), subreddit_name=os.getenv("SUBREDDIT_NAME", ""), roleplay_message=os.getenv( "ROLEPLAY_MESSAGE", @@ -59,7 +63,7 @@ class Config: """Validate that all required configuration is present.""" required_fields = [ "client_id", "client_secret", "username", - "password", "subreddit_name" + "password", "subreddit_name", "openai_api_key" ] missing_fields = [] diff --git a/src/umabot/rules/__init__.py b/src/umabot/rules/__init__.py index 73f84ec..01827ce 100644 --- a/src/umabot/rules/__init__.py +++ b/src/umabot/rules/__init__.py @@ -5,5 +5,6 @@ from .spam_detector import SpamDetector from .roleplay_limiter import RoleplayLimiter, StaticRoleplayLimiter from .roleplay_media_required import RoleplayMediaRequiredRule from .roleplay_word_count import RoleplayWordCountRule +from .intelligent_roleplay_moderator import IntelligentRoleplayModerator -__all__ = ["Rule", "SpamDetector", "RoleplayLimiter", "StaticRoleplayLimiter", "RoleplayMediaRequiredRule", "RoleplayWordCountRule"] +__all__ = ["Rule", "SpamDetector", "RoleplayLimiter", "StaticRoleplayLimiter", "RoleplayMediaRequiredRule", "RoleplayWordCountRule", "IntelligentRoleplayModerator"] diff --git a/src/umabot/rules/intelligent_roleplay_moderator.py b/src/umabot/rules/intelligent_roleplay_moderator.py new file mode 100644 index 0000000..7343df5 --- /dev/null +++ b/src/umabot/rules/intelligent_roleplay_moderator.py @@ -0,0 +1,351 @@ +"""Intelligent roleplay moderator using GPT-5-nano.""" + +import json +import praw.models +from openai import OpenAI +from .base import Rule + + +class IntelligentRoleplayModerator(Rule): + """Intelligent roleplay moderator that evaluates post quality and appropriateness.""" + + def __init__(self, config, subreddit): + """Initialize the intelligent roleplay moderator.""" + super().__init__(config) + self.subreddit = subreddit + self.roleplay_flair_template_id = "311f0024-8302-11f0-9b41-46c005ad843c" + self.art_flair_template_id = "dd4589ba-7c43-11f0-8ef0-22eefb7f5b84" + + # Initialize OpenAI client + self.openai_client = OpenAI(api_key=config.openai_api_key) + + # Evaluation prompt + self.evaluation_prompt = """ +You are an expert moderator for a roleplay subreddit. Your job is to evaluate roleplay posts and determine: + +1. Whether this post would be better flaired as "Art" instead of "Roleplay" +2. Whether this is low-effort content that should be removed + +For each post, respond with a JSON object containing: +{{ + "should_be_art": boolean, + "is_low_effort": boolean, + "confidence": float (0.0 to 1.0), + "reasoning": "Brief explanation of your decision" +}} + +Guidelines: +- A post should be flaired as "Art" if it's primarily showcasing artwork, images, or visual content with minimal roleplay text +- A post is "low effort" if it lacks substance, creativity, or meaningful roleplay content +- Consider factors like: word count, creativity, effort, engagement potential, originality +- Be strict but fair - err on the side of allowing content unless it's clearly low quality +- High confidence (0.8+) for clear cases, lower confidence for borderline cases + +Post to evaluate: +Title: {title} +Content: {content} +Has Media: {has_media} +Media Type: {media_type} +Word Count: {word_count} +""" + + def should_remove(self, submission: praw.models.Submission) -> bool: + """Evaluate a roleplay post and take appropriate action.""" + if not submission.author: + return False + + # Check if this is a roleplay post + if not self._is_roleplay_post(submission): + return False + + try: + # Evaluate the post using GPT-5-nano + evaluation = self._evaluate_post(submission) + + if evaluation["should_be_art"]: + # Change flair to Art and notify user + self._change_flair_to_art(submission) + self._notify_art_flair_change(submission, evaluation) + return False # Don't remove, just change flair + + elif evaluation["is_low_effort"]: + # Remove low effort post and notify user + self._notify_low_effort_removal(submission, evaluation) + return True # Remove the post + + # Post is good quality roleplay - allow it + return False + + except Exception as e: + self.logger.error(f"Error evaluating roleplay post {submission.id}: {e}") + return False # Don't remove on error + + def get_removal_message(self, submission: praw.models.Submission) -> str: + """Get removal message for low effort posts.""" + return "" # We send mod mail instead of commenting + + def _evaluate_post(self, submission: praw.models.Submission) -> dict: + """Use GPT-5-nano to evaluate the post with retry logic.""" + max_retries = 3 + for attempt in range(max_retries): + try: + self.logger.info(f"Attempt {attempt + 1}/{max_retries} for submission {submission.id}") + return self._evaluate_post_attempt(submission) + except Exception as e: + self.logger.warning(f"Attempt {attempt + 1} failed for {submission.id}: {e}") + if attempt == max_retries - 1: + # Last attempt failed, return default evaluation + self.logger.error(f"All {max_retries} attempts failed for {submission.id}, returning default evaluation") + return { + "should_be_art": False, + "is_low_effort": False, + "confidence": 0.0, + "reasoning": f"Failed after {max_retries} attempts: {e}" + } + else: + self.logger.info(f"Retrying in 1 second...") + import time + time.sleep(1) + + # This should never be reached, but just in case + return { + "should_be_art": False, + "is_low_effort": False, + "confidence": 0.0, + "reasoning": "Unexpected error in retry logic" + } + + def _evaluate_post_attempt(self, submission: praw.models.Submission) -> dict: + """Single attempt to evaluate the post using GPT-5-nano.""" + try: + # Extract post information + title = submission.title or "" + content = submission.selftext or "" + has_media = self._has_media(submission) + media_type = self._get_media_type(submission) + word_count = len(content.split()) if content else 0 + + # Prepare the prompt + prompt = self.evaluation_prompt.format( + title=title, + content=content, + has_media=has_media, + media_type=media_type, + word_count=word_count + ) + + # Call GPT-5-nano + response = self.openai_client.chat.completions.create( + model="gpt-5-nano", + messages=[ + {"role": "system", "content": "You are an expert roleplay moderator. Always respond with valid JSON only. Do not include any text before or after the JSON object."}, + {"role": "user", "content": prompt} + ], + max_completion_tokens=2000 + ) + + # Parse the response + response_text = response.choices[0].message.content.strip() + + # Clean up the response text to handle common JSON formatting issues + response_text = response_text.replace('\n', '').replace('\r', '') + + # Try to extract JSON from the response if it's embedded in other text + if '```json' in response_text: + # Extract JSON from code blocks + start = response_text.find('```json') + 7 + end = response_text.find('```', start) + if end != -1: + response_text = response_text[start:end].strip() + elif '```' in response_text: + # Extract JSON from generic code blocks + start = response_text.find('```') + 3 + end = response_text.find('```', start) + if end != -1: + response_text = response_text[start:end].strip() + + # Find JSON object boundaries + if '{' in response_text and '}' in response_text: + start = response_text.find('{') + end = response_text.rfind('}') + 1 + response_text = response_text[start:end] + + evaluation = json.loads(response_text) + + self.logger.info(f"GPT-5-nano evaluation for {submission.id}: {evaluation}") + return evaluation + + except json.JSONDecodeError as e: + self.logger.error(f"JSON parsing error for {submission.id}: {e}") + self.logger.error(f"Raw response: {response_text}") + # Return default evaluation on JSON error + return { + "should_be_art": False, + "is_low_effort": False, + "confidence": 0.0, + "reasoning": f"JSON parsing error: {e}" + } + except Exception as e: + self.logger.error(f"Error calling GPT-5-nano for {submission.id}: {e}") + # Return default evaluation on error + return { + "should_be_art": False, + "is_low_effort": False, + "confidence": 0.0, + "reasoning": f"Error occurred during evaluation: {e}" + } + + def _change_flair_to_art(self, submission: praw.models.Submission) -> None: + """Change the post flair to Art.""" + try: + submission.mod.flair( + text="Art", + flair_template_id=self.art_flair_template_id + ) + self.logger.info(f"Changed flair to Art for post {submission.id}") + except Exception as e: + self.logger.error(f"Error changing flair for {submission.id}: {e}") + + def _notify_art_flair_change(self, submission: praw.models.Submission, evaluation: dict) -> None: + """Send mod mail about flair change.""" + try: + username = submission.author.name + subject = "Your post flair has been changed to Art" + + message = f"""Hello u/{username}, + +Your roleplay post has been automatically re-flaired as "Art" because it appears to be primarily showcasing artwork or visual content rather than roleplay. + +Reasoning: {evaluation['reasoning']} + +Post link: https://reddit.com{submission.permalink} + +If you believe this was done in error, please contact the moderators via Mod Mail. + +Thank you for understanding!""" + + submission.author.message(subject, message) + self.logger.info(f"Sent art flair change notification to {username}") + + except Exception as e: + self.logger.error(f"Error sending art flair notification for {submission.id}: {e}") + + def _notify_low_effort_removal(self, submission: praw.models.Submission, evaluation: dict) -> None: + """Send mod mail about low effort removal.""" + try: + username = submission.author.name + subject = "Your roleplay post has been removed for low effort" + + message = f"""Hello u/{username}, + +Your roleplay post has been removed because it was determined to be low-effort content. + +Reasoning: {evaluation['reasoning']} + +Post link: https://reddit.com{submission.permalink} + +To improve your roleplay posts, consider: +- Adding more detailed descriptions +- Creating engaging scenarios +- Including meaningful character interactions +- Ensuring your content adds value to the community + +If you believe this was done in error, please contact the moderators via Mod Mail. + +Thank you for understanding!""" + + submission.author.message(subject, message) + self.logger.info(f"Sent low effort removal notification to {username}") + + except Exception as e: + self.logger.error(f"Error sending low effort notification for {submission.id}: {e}") + + def _is_roleplay_post(self, submission: praw.models.Submission) -> bool: + """Check if a submission has the roleplay flair.""" + try: + # Check link flair template ID first (most reliable) + if hasattr(submission, 'link_flair_template_id') and submission.link_flair_template_id: + return submission.link_flair_template_id == self.roleplay_flair_template_id + + # Fallback to flair text + if hasattr(submission, 'link_flair_text') and submission.link_flair_text: + return submission.link_flair_text.lower() == "roleplay" + + return False + + except Exception as e: + self.logger.error(f"Error checking flair for submission {submission.id}: {e}") + return False + + def _has_media(self, submission: praw.models.Submission) -> bool: + """Check if a submission has media attached.""" + try: + # Check for image/video posts + if submission.is_video or submission.is_self: + # For self posts, check if they contain media links + if submission.is_self and submission.selftext: + # Look for common media URLs in the text + media_indicators = [ + 'imgur.com', 'i.imgur.com', 'redd.it', 'i.redd.it', + 'youtube.com', 'youtu.be', 'vimeo.com', 'gfycat.com', + 'streamable.com', 'twitter.com', 'x.com', 'tiktok.com', + 'instagram.com', 'facebook.com', 'discord.com', 'discordapp.com' + ] + + text_lower = submission.selftext.lower() + return any(indicator in text_lower for indicator in media_indicators) + return False + + # Check for link posts with media + if hasattr(submission, 'url') and submission.url: + # Check if URL points to media + url_lower = submission.url.lower() + media_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.gifv', '.mp4', '.webm', '.webp'] + media_domains = [ + 'imgur.com', 'i.imgur.com', 'redd.it', 'i.redd.it', + 'youtube.com', 'youtu.be', 'vimeo.com', 'gfycat.com', + 'streamable.com', 'twitter.com', 'x.com', 'tiktok.com', + 'instagram.com', 'facebook.com' + ] + + # Check file extensions + if any(url_lower.endswith(ext) for ext in media_extensions): + return True + + # Check media domains + if any(domain in url_lower for domain in media_domains): + return True + + # Check for gallery posts + if hasattr(submission, 'is_gallery') and submission.is_gallery: + return True + + return False + + except Exception as e: + self.logger.error(f"Error checking media for submission {submission.id}: {e}") + return False + + def _get_media_type(self, submission: praw.models.Submission) -> str: + """Get the type of media in the submission.""" + try: + if submission.is_video: + return "video" + elif hasattr(submission, 'is_gallery') and submission.is_gallery: + return "gallery" + elif hasattr(submission, 'url') and submission.url: + url_lower = submission.url.lower() + if any(ext in url_lower for ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']): + return "image" + elif any(ext in url_lower for ext in ['.mp4', '.webm', '.gifv']): + return "video" + elif any(domain in url_lower for domain in ['youtube.com', 'youtu.be', 'vimeo.com']): + return "video" + else: + return "link" + else: + return "text" + + except Exception as e: + self.logger.error(f"Error getting media type for submission {submission.id}: {e}") + return "unknown" |