diff options
| author | Fuwn <[email protected]> | 2025-09-17 19:32:48 -0700 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2025-09-17 19:32:48 -0700 |
| commit | 205f9e39fbf708f330312e4b4af9eb93227c4ec5 (patch) | |
| tree | 648461ef75a71be061bb604300d54e0ca60aa366 | |
| parent | feat(rules): Add intelligent roleplay moderator rule (diff) | |
| download | umabot-205f9e39fbf708f330312e4b4af9eb93227c4ec5.tar.xz umabot-205f9e39fbf708f330312e4b4af9eb93227c4ec5.zip | |
refactor(intelligent_moderator): Make logic generic between test script and rule
| -rw-r--r-- | src/umabot/rules/__init__.py | 3 | ||||
| -rw-r--r-- | src/umabot/rules/intelligent_moderator_base.py | 309 | ||||
| -rw-r--r-- | src/umabot/rules/intelligent_roleplay_moderator.py | 265 | ||||
| -rwxr-xr-x | test_moderator.py | 278 |
4 files changed, 401 insertions, 454 deletions
diff --git a/src/umabot/rules/__init__.py b/src/umabot/rules/__init__.py index 01827ce..e429070 100644 --- a/src/umabot/rules/__init__.py +++ b/src/umabot/rules/__init__.py @@ -6,5 +6,6 @@ from .roleplay_limiter import RoleplayLimiter, StaticRoleplayLimiter from .roleplay_media_required import RoleplayMediaRequiredRule from .roleplay_word_count import RoleplayWordCountRule from .intelligent_roleplay_moderator import IntelligentRoleplayModerator +from .intelligent_moderator_base import IntelligentModeratorBase -__all__ = ["Rule", "SpamDetector", "RoleplayLimiter", "StaticRoleplayLimiter", "RoleplayMediaRequiredRule", "RoleplayWordCountRule", "IntelligentRoleplayModerator"] +__all__ = ["Rule", "SpamDetector", "RoleplayLimiter", "StaticRoleplayLimiter", "RoleplayMediaRequiredRule", "RoleplayWordCountRule", "IntelligentRoleplayModerator", "IntelligentModeratorBase"] diff --git a/src/umabot/rules/intelligent_moderator_base.py b/src/umabot/rules/intelligent_moderator_base.py new file mode 100644 index 0000000..3eddd1b --- /dev/null +++ b/src/umabot/rules/intelligent_moderator_base.py @@ -0,0 +1,309 @@ +"""Base class for intelligent roleplay moderation logic.""" + +import json +import time +from abc import ABC, abstractmethod +from typing import Dict, Any, Union +from openai import OpenAI + + +class IntelligentModeratorBase(ABC): + """Base class containing shared logic for intelligent roleplay moderation.""" + + def __init__(self, openai_api_key: str): + """Initialize the base moderator.""" + self.openai_client = OpenAI(api_key=openai_api_key) + self.roleplay_flair_template_id = "311f0024-8302-11f0-9b41-46c005ad843c" + self.art_flair_template_id = "dd4589ba-7c43-11f0-8ef0-22eefb7f5b84" + + # 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 evaluate_post(self, submission: Any) -> Dict[str, Any]: + """Use GPT-5-nano to evaluate the post with retry logic.""" + max_retries = 3 + for attempt in range(max_retries): + try: + self._log_info(f"Attempt {attempt + 1}/{max_retries} for submission {self._get_submission_id(submission)}") + return self._evaluate_post_attempt(submission) + except Exception as e: + self._log_warning(f"Attempt {attempt + 1} failed for {self._get_submission_id(submission)}: {e}") + if attempt == max_retries - 1: + # Last attempt failed, return default evaluation + self._log_error(f"All {max_retries} attempts failed for {self._get_submission_id(submission)}, 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._log_info(f"Retrying in 1 second...") + 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: Any) -> Dict[str, Any]: + """Single attempt to evaluate the post using GPT-5-nano.""" + try: + # Extract post information + title = self._get_title(submission) or "" + content = self._get_content(submission) 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] + + try: + evaluation = json.loads(response_text) + except json.JSONDecodeError as json_err: + # Try to fix common JSON issues + try: + # Fix unterminated strings by adding missing quotes + if '"reasoning": "' in response_text and not response_text.rstrip().endswith('"'): + response_text = response_text.rstrip() + '"' + evaluation = json.loads(response_text) + except: + raise json_err + + self._log_info(f"GPT-5-nano evaluation for {self._get_submission_id(submission)}: {evaluation}") + return evaluation + + except json.JSONDecodeError as e: + self._log_error(f"JSON parsing error for {self._get_submission_id(submission)}: {e}") + self._log_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._log_error(f"Error calling GPT-5-nano for {self._get_submission_id(submission)}: {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 determine_actions(self, evaluation: Dict[str, Any]) -> list: + """Determine what actions to take based on evaluation.""" + actions = [] + if evaluation["should_be_art"]: + # Change flair to Art and notify user (priority over removal) + actions.append("CHANGE_FLAIR_TO_ART") + elif evaluation["is_low_effort"]: + # Remove low effort post and notify user + actions.append("REMOVE_POST") + else: + # Post is good quality roleplay - allow it + actions.append("ALLOW_POST") + + return actions + + def _has_media(self, submission: Any) -> bool: + """Check if a submission has media attached.""" + try: + # Check for image/video posts + if self._is_video(submission) or self._is_self(submission): + # For self posts, check if they contain media links + if self._is_self(submission) and self._get_content(submission): + # 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 = self._get_content(submission).lower() + return any(indicator in text_lower for indicator in media_indicators) + return False + + # Check for link posts with media + url = self._get_url(submission) + if url: + # Check if URL points to media + url_lower = 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 self._is_gallery(submission): + return True + + return False + + except Exception as e: + self._log_error(f"Error checking media: {e}") + return False + + def _get_media_type(self, submission: Any) -> str: + """Get the type of media in the submission.""" + try: + if self._is_video(submission): + return "video" + elif self._is_gallery(submission): + return "gallery" + else: + url = self._get_url(submission) + if url: + url_lower = 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._log_error(f"Error getting media type: {e}") + return "unknown" + + # Abstract methods that must be implemented by subclasses + @abstractmethod + def _get_submission_id(self, submission: Any) -> str: + """Get the submission ID.""" + pass + + @abstractmethod + def _get_title(self, submission: Any) -> str: + """Get the submission title.""" + pass + + @abstractmethod + def _get_content(self, submission: Any) -> str: + """Get the submission content.""" + pass + + @abstractmethod + def _get_url(self, submission: Any) -> str: + """Get the submission URL.""" + pass + + @abstractmethod + def _is_video(self, submission: Any) -> bool: + """Check if submission is a video.""" + pass + + @abstractmethod + def _is_self(self, submission: Any) -> bool: + """Check if submission is a self post.""" + pass + + @abstractmethod + def _is_gallery(self, submission: Any) -> bool: + """Check if submission is a gallery.""" + pass + + @abstractmethod + def _log_info(self, message: str) -> None: + """Log info message.""" + pass + + @abstractmethod + def _log_warning(self, message: str) -> None: + """Log warning message.""" + pass + + @abstractmethod + def _log_error(self, message: str) -> None: + """Log error message.""" + pass diff --git a/src/umabot/rules/intelligent_roleplay_moderator.py b/src/umabot/rules/intelligent_roleplay_moderator.py index 7343df5..92ee9d1 100644 --- a/src/umabot/rules/intelligent_roleplay_moderator.py +++ b/src/umabot/rules/intelligent_roleplay_moderator.py @@ -1,53 +1,18 @@ """Intelligent roleplay moderator using GPT-5-nano.""" -import json import praw.models -from openai import OpenAI from .base import Rule +from .intelligent_moderator_base import IntelligentModeratorBase -class IntelligentRoleplayModerator(Rule): +class IntelligentRoleplayModerator(Rule, IntelligentModeratorBase): """Intelligent roleplay moderator that evaluates post quality and appropriateness.""" def __init__(self, config, subreddit): """Initialize the intelligent roleplay moderator.""" - super().__init__(config) + Rule.__init__(self, config) + IntelligentModeratorBase.__init__(self, config.openai_api_key) 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.""" @@ -60,7 +25,7 @@ Word Count: {word_count} try: # Evaluate the post using GPT-5-nano - evaluation = self._evaluate_post(submission) + evaluation = self.evaluate_post(submission) if evaluation["should_be_art"]: # Change flair to Art and notify user @@ -84,116 +49,46 @@ Word Count: {word_count} """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" - } + # Abstract method implementations for IntelligentModeratorBase + def _get_submission_id(self, submission: praw.models.Submission) -> str: + """Get the submission ID.""" + return submission.id - 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 _get_title(self, submission: praw.models.Submission) -> str: + """Get the submission title.""" + return submission.title or "" + + def _get_content(self, submission: praw.models.Submission) -> str: + """Get the submission content.""" + return submission.selftext or "" + + def _get_url(self, submission: praw.models.Submission) -> str: + """Get the submission URL.""" + return submission.url or "" + + def _is_video(self, submission: praw.models.Submission) -> bool: + """Check if submission is a video.""" + return submission.is_video + + def _is_self(self, submission: praw.models.Submission) -> bool: + """Check if submission is a self post.""" + return submission.is_self + + def _is_gallery(self, submission: praw.models.Submission) -> bool: + """Check if submission is a gallery.""" + return submission.is_gallery + + def _log_info(self, message: str) -> None: + """Log info message.""" + self.logger.info(message) + + def _log_warning(self, message: str) -> None: + """Log warning message.""" + self.logger.warning(message) + + def _log_error(self, message: str) -> None: + """Log error message.""" + self.logger.error(message) def _change_flair_to_art(self, submission: praw.models.Submission) -> None: """Change the post flair to Art.""" @@ -277,75 +172,3 @@ Thank you for understanding!""" 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" diff --git a/test_moderator.py b/test_moderator.py index 50b6715..7f04e37 100755 --- a/test_moderator.py +++ b/test_moderator.py @@ -13,6 +13,10 @@ from openai import OpenAI from dotenv import load_dotenv import praw +# Import the base class +sys.path.append('src') +from umabot.rules.intelligent_moderator_base import IntelligentModeratorBase + @dataclass class MockSubmission: @@ -168,44 +172,12 @@ class RedditDownloader: return downloaded_files -class TestIntelligentModerator: +class TestIntelligentModerator(IntelligentModeratorBase): """Test version of the intelligent roleplay moderator.""" def __init__(self, openai_api_key: str): """Initialize the test moderator.""" - self.openai_client = OpenAI(api_key=openai_api_key) - self.roleplay_flair = "Roleplay" - self.art_flair_template_id = "dd4589ba-7c43-11f0-8ef0-22eefb7f5b84" - - # 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} -""" + super().__init__(openai_api_key) def test_file(self, file_path: Path, author_name: str = "testuser") -> Dict[str, Any]: """Test a single file against the moderator.""" @@ -228,19 +200,10 @@ Word Count: {word_count} ) # Evaluate the post - evaluation = self._evaluate_post(submission) + evaluation = self.evaluate_post(submission) # Determine actions (same logic as production moderator) - actions = [] - if evaluation["should_be_art"]: - # Change flair to Art and notify user (priority over removal) - actions.append("CHANGE_FLAIR_TO_ART") - elif evaluation["is_low_effort"]: - # Remove low effort post and notify user - actions.append("REMOVE_POST") - else: - # Post is good quality roleplay - allow it - actions.append("ALLOW_POST") + actions = self.determine_actions(evaluation) return { "file": str(file_path), @@ -286,195 +249,46 @@ Word Count: {word_count} return results - def _evaluate_post(self, submission: MockSubmission) -> Dict[str, Any]: - """Use GPT-5-nano to evaluate the post with retry logic.""" - response_text = "" # Initialize to avoid scope issues - - max_retries = 3 - for attempt in range(max_retries): - try: - return self._evaluate_post_attempt(submission) - except Exception as e: - if attempt == max_retries - 1: - # Last attempt failed, return default evaluation - return { - "should_be_art": False, - "is_low_effort": False, - "confidence": 0.0, - "reasoning": f"Failed after {max_retries} attempts: {e}" - } - else: - 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: MockSubmission) -> Dict[str, Any]: - """Single attempt to evaluate the post using GPT-5-nano.""" - response_text = "" # Initialize to avoid scope issues - - 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] - - try: - evaluation = json.loads(response_text) - except json.JSONDecodeError as json_err: - # Try to fix common JSON issues - try: - # Fix unterminated strings by adding missing quotes - if '"reasoning": "' in response_text and not response_text.rstrip().endswith('"'): - response_text = response_text.rstrip() + '"' - evaluation = json.loads(response_text) - except: - raise json_err - - return evaluation - - except json.JSONDecodeError as e: - # 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: - # Return default evaluation on error - return { - "should_be_art": False, - "is_low_effort": False, - "confidence": 0.0, - "reasoning": f"Error occurred during evaluation: {e}" - } + # Abstract method implementations for IntelligentModeratorBase + def _get_submission_id(self, submission: MockSubmission) -> str: + """Get the submission ID.""" + return submission.id - def _has_media(self, submission: MockSubmission) -> 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: - print(f"Error checking media: {e}") - return False + def _get_title(self, submission: MockSubmission) -> str: + """Get the submission title.""" + return submission.title or "" - def _get_media_type(self, submission: MockSubmission) -> 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: - print(f"Error getting media type: {e}") - return "unknown" + def _get_content(self, submission: MockSubmission) -> str: + """Get the submission content.""" + return submission.selftext or "" + + def _get_url(self, submission: MockSubmission) -> str: + """Get the submission URL.""" + return submission.url or "" + + def _is_video(self, submission: MockSubmission) -> bool: + """Check if submission is a video.""" + return submission.is_video + + def _is_self(self, submission: MockSubmission) -> bool: + """Check if submission is a self post.""" + return submission.is_self + + def _is_gallery(self, submission: MockSubmission) -> bool: + """Check if submission is a gallery.""" + return submission.is_gallery + + def _log_info(self, message: str) -> None: + """Log info message.""" + print(f"INFO: {message}") + + def _log_warning(self, message: str) -> None: + """Log warning message.""" + print(f"WARNING: {message}") + + def _log_error(self, message: str) -> None: + """Log error message.""" + print(f"ERROR: {message}") def print_results(results: List[Dict[str, Any]]) -> None: |