aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2025-09-17 19:32:48 -0700
committerFuwn <[email protected]>2025-09-17 19:32:48 -0700
commit205f9e39fbf708f330312e4b4af9eb93227c4ec5 (patch)
tree648461ef75a71be061bb604300d54e0ca60aa366
parentfeat(rules): Add intelligent roleplay moderator rule (diff)
downloadumabot-205f9e39fbf708f330312e4b4af9eb93227c4ec5.tar.xz
umabot-205f9e39fbf708f330312e4b4af9eb93227c4ec5.zip
refactor(intelligent_moderator): Make logic generic between test script and rule
-rw-r--r--src/umabot/rules/__init__.py3
-rw-r--r--src/umabot/rules/intelligent_moderator_base.py309
-rw-r--r--src/umabot/rules/intelligent_roleplay_moderator.py265
-rwxr-xr-xtest_moderator.py278
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: