Makes ai rule generation content work good.
This commit is contained in:
348
app/ai_service.py
Normal file
348
app/ai_service.py
Normal file
@@ -0,0 +1,348 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
class AIService:
|
||||
"""AI service layer for email rule generation and quality assessment."""
|
||||
|
||||
def __init__(self):
|
||||
self.api_url = os.environ.get('AI_SERVICE_URL', 'https://api.openai.com/v1')
|
||||
self.api_key = os.environ.get('AI_SERVICE_API_KEY')
|
||||
self.model = os.environ.get('AI_MODEL', 'gpt-3.5-turbo')
|
||||
self.timeout = int(os.environ.get('AI_TIMEOUT', 30))
|
||||
self.max_retries = int(os.environ.get('AI_MAX_RETRIES', 3))
|
||||
self.cache_ttl = int(os.environ.get('AI_CACHE_TTL', 3600)) # 1 hour
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def _make_request(self, endpoint: str, payload: Dict, headers: Dict = None) -> Optional[Dict]:
|
||||
"""Make HTTP request to AI service with retry logic."""
|
||||
if not headers:
|
||||
headers = {}
|
||||
|
||||
headers.update({
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
})
|
||||
|
||||
url = f"{self.api_url}/{endpoint}"
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=self.timeout
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.warning(f"AI service request failed (attempt {attempt + 1}/{self.max_retries}): {e}")
|
||||
if attempt == self.max_retries - 1:
|
||||
self.logger.error(f"AI service request failed after {self.max_retries} attempts")
|
||||
return None
|
||||
time.sleep(2 ** attempt) # Exponential backoff
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Unexpected error in AI service request (attempt {attempt + 1}/{self.max_retries}): {e}")
|
||||
if attempt == self.max_retries - 1:
|
||||
self.logger.error(f"AI service request failed after {self.max_retries} attempts due to unexpected error")
|
||||
return None
|
||||
time.sleep(2 ** attempt) # Exponential backoff
|
||||
|
||||
return None
|
||||
|
||||
def generate_single_rule(self, folder_name: str, folder_type: str = 'destination', rule_text: str ='') -> Tuple[Optional[str], Optional[Dict]]:
|
||||
"""Generate a single email organization rule using AI."""
|
||||
prompt = self._build_single_rule_prompt(folder_name, folder_type, rule_text)
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': 'You are an expert email organizer assistant.'},
|
||||
{'role': 'user', 'content': prompt}
|
||||
],
|
||||
'max_tokens': 800,
|
||||
'temperature': 0.7
|
||||
}
|
||||
|
||||
result = self._make_request('chat/completions', payload)
|
||||
|
||||
if not result or 'choices' not in result or not result['choices']:
|
||||
return None, {'error': 'No response from AI service'}
|
||||
|
||||
try:
|
||||
rule_text = result['choices'][0]['message']['content'].strip()
|
||||
quality_score = self._assess_rule_quality(rule_text, folder_name, folder_type)
|
||||
|
||||
return rule_text, {
|
||||
'quality_score': quality_score,
|
||||
'model_used': self.model,
|
||||
'generated_at': datetime.utcnow().isoformat()
|
||||
}
|
||||
except (KeyError, IndexError) as e:
|
||||
return None, {'error': f'Failed to parse AI response: {str(e)}'}
|
||||
|
||||
def generate_multiple_rules(self, folder_name: str, folder_type: str = 'destination', rule_text:str = '', count: int = 3) -> Tuple[Optional[List[Dict]], Optional[Dict]]:
|
||||
"""Generate multiple email organization rule options using AI."""
|
||||
prompt = self._build_multiple_rules_prompt(folder_name, folder_type, rule_text, count)
|
||||
print("PROMPT", prompt)
|
||||
|
||||
payload = {
|
||||
'model': self.model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': 'You are an expert email organizer assistant.'},
|
||||
{'role': 'user', 'content': prompt}
|
||||
],
|
||||
'max_tokens': 400,
|
||||
'temperature': 0.8
|
||||
}
|
||||
|
||||
result = self._make_request('chat/completions', payload)
|
||||
|
||||
if not result or 'choices' not in result or not result['choices']:
|
||||
return None, {'error': 'No response from AI service'}
|
||||
|
||||
response_text = result['choices'][0]['message']['content'].strip()
|
||||
print(f"RESPONSE WAS '{response_text}'")
|
||||
rules = self._parse_multiple_rules_response(response_text)
|
||||
|
||||
if not rules:
|
||||
return None, {'error': 'Failed to parse AI response'}
|
||||
|
||||
# Assess quality for each rule
|
||||
scored_rules = []
|
||||
for rule in rules:
|
||||
quality_score = self._assess_rule_quality(rule['text'], folder_name, folder_type)
|
||||
scored_rules.append({
|
||||
'text': rule['text'],
|
||||
'quality_score': quality_score,
|
||||
'key_criteria': rule.get('criteria', ''),
|
||||
'model_used': self.model,
|
||||
'generated_at': datetime.utcnow().isoformat()
|
||||
})
|
||||
|
||||
return scored_rules, {
|
||||
'total_generated': len(scored_rules),
|
||||
'model_used': self.model,
|
||||
'generated_at': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
def assess_rule_quality(self, rule_text: str, folder_name: str, folder_type: str = 'destination') -> Dict:
|
||||
"""Assess the quality of an email organization rule."""
|
||||
score = self._assess_rule_quality(rule_text, folder_name, folder_type)
|
||||
|
||||
return {
|
||||
'score': score,
|
||||
'grade': self._get_quality_grade(score),
|
||||
'feedback': self._generate_quality_feedback(rule_text, folder_name, score),
|
||||
'assessed_at': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
def _build_single_rule_prompt(self, folder_name: str, folder_type: str, rule_text: str) -> str:
|
||||
"""Build prompt for single rule generation."""
|
||||
return f"""
|
||||
Generate a single, effective email organization rule for a folder named "{folder_name}".
|
||||
This folder is of type "{folder_type}".
|
||||
The current rule text is "{rule_text}". You can choose to enhance this.
|
||||
|
||||
Requirements:
|
||||
1. The rule should be specific and actionable
|
||||
2. Use natural language that can be easily understood
|
||||
3. Focus on common email patterns that would benefit from organization
|
||||
4. Keep it concise (under 150 characters)
|
||||
5. Make it relevant to the folder name and purpose
|
||||
6. Rules should follow the structure: Bulleted list (separated by new line) of * (content) belongs in this folder. * (content) DOES NOT belong in this folder
|
||||
|
||||
Return only the rule text, nothing else.
|
||||
"""
|
||||
|
||||
def _build_multiple_rules_prompt(self, folder_name: str, folder_type: str, rule_text: str, count: int) -> str:
|
||||
"""Build prompt for multiple rule generation."""
|
||||
return f"""
|
||||
Generate {count} different email organization rule options for a folder named "{folder_name}".
|
||||
This folder is of type "{folder_type}".
|
||||
The current rule text is "{rule_text}". If there is content in this, your options must respect the existing content.
|
||||
|
||||
Requirements:
|
||||
1. Each rule should be specific and actionable
|
||||
2. Use natural language that can be easily understood
|
||||
3. Focus on different aspects of email organization for this folder
|
||||
4. Keep each rule concise (under 150 characters)
|
||||
5. Make rules relevant to the folder name and purpose
|
||||
6. Provide variety in rule approaches
|
||||
7. A single rule option should should follow the structure: Bulleted list (separated by new line) of * (content) belongs in this folder. * (content) DOES NOT belong in this folder
|
||||
|
||||
Return the rules in JSON format:
|
||||
{{
|
||||
"rules": [
|
||||
{{
|
||||
"text": "rule text here, as a bulleted list separated by newlines",
|
||||
"criteria": "brief explanation of what this rule targets"
|
||||
}},
|
||||
...
|
||||
]
|
||||
}}
|
||||
7. DO NOT use markdown at all. Respond with JSON.
|
||||
8. Rules should follow the structure: Bulleted list (separated by newlines ,\\n) of * (content) belongs in this folder. * (content) DOES NOT belong in this folder
|
||||
|
||||
"""
|
||||
|
||||
def _parse_multiple_rules_response(self, response_text: str) -> List[Dict]:
|
||||
"""Parse multiple rules response from AI."""
|
||||
try:
|
||||
# Try to parse as JSON first
|
||||
data = json.loads(response_text)
|
||||
print(f"DATAA WAS {data}")
|
||||
if 'rules' in data and isinstance(data['rules'], list):
|
||||
return data['rules']
|
||||
|
||||
# If JSON parsing fails, try to extract rules manually
|
||||
rules = []
|
||||
lines = response_text.split('\n')
|
||||
current_rule = {}
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith('"text":') or line.startswith('"rule":'):
|
||||
if current_rule:
|
||||
rules.append(current_rule)
|
||||
current_rule = {'text': line.split(':', 1)[1].strip().strip('"')}
|
||||
elif line.startswith('"criteria":') and current_rule:
|
||||
current_rule['criteria'] = line.split(':', 1)[1].strip().strip('"')
|
||||
|
||||
if current_rule:
|
||||
rules.append(current_rule)
|
||||
|
||||
return rules[:5] # Return max 5 rules
|
||||
|
||||
except json.JSONDecodeError:
|
||||
self.logger.warning("Failed to parse AI response as JSON, attempting manual parsing")
|
||||
return []
|
||||
|
||||
def _assess_rule_quality(self, rule_text: str, folder_name: str, folder_type: str) -> int:
|
||||
"""Assess rule quality and return score 0-100."""
|
||||
if not rule_text or len(rule_text.strip()) < 10:
|
||||
return 0
|
||||
|
||||
score = 50 # Base score
|
||||
|
||||
# Length check (optimal: 20-100 characters)
|
||||
rule_length = len(rule_text.strip())
|
||||
if 20 <= rule_length <= 100:
|
||||
score += 20
|
||||
elif 10 <= rule_length < 20 or 100 < rule_length <= 150:
|
||||
score += 10
|
||||
|
||||
# Specificity check
|
||||
specific_keywords = ['from', 'subject', 'contains', 'sender', 'domain', 'email']
|
||||
has_specific_keyword = any(keyword in rule_text.lower() for keyword in specific_keywords)
|
||||
if has_specific_keyword:
|
||||
score += 20
|
||||
|
||||
# Action-oriented check
|
||||
action_words = ['move', 'filter', 'organize', 'sort', 'categorize', 'send', 'redirect']
|
||||
has_action_word = any(word in rule_text.lower() for word in action_words)
|
||||
if has_action_word:
|
||||
score += 15
|
||||
|
||||
# Relevance to folder name
|
||||
folder_words = folder_name.lower().split()
|
||||
folder_relevance = sum(1 for word in folder_words if word in rule_text.lower())
|
||||
if folder_relevance > 0:
|
||||
score += 15
|
||||
|
||||
# Grammar and structure check
|
||||
if '.' not in rule_text and '?' not in rule_text and '!' not in rule_text:
|
||||
score += 10 # Simple, clean structure
|
||||
|
||||
# Check for common rule patterns
|
||||
common_patterns = [
|
||||
r'from:.*@.*\..*',
|
||||
r'subject:.*',
|
||||
r'contains:.*',
|
||||
r'if.*then.*'
|
||||
]
|
||||
|
||||
import re
|
||||
for pattern in common_patterns:
|
||||
if re.search(pattern, rule_text, re.IGNORECASE):
|
||||
score += 10
|
||||
break
|
||||
|
||||
return min(score, 100) # Cap at 100
|
||||
|
||||
def _get_quality_grade(self, score: int) -> str:
|
||||
"""Get quality grade based on score."""
|
||||
if score >= 80:
|
||||
return 'excellent'
|
||||
elif score >= 60:
|
||||
return 'good'
|
||||
elif score >= 40:
|
||||
return 'fair'
|
||||
else:
|
||||
return 'poor'
|
||||
|
||||
def _generate_quality_feedback(self, rule_text: str, folder_name: str, score: int) -> str:
|
||||
"""Generate quality feedback based on rule assessment."""
|
||||
feedback = []
|
||||
|
||||
if score >= 80:
|
||||
feedback.append("Excellent rule! It's specific, actionable, and well-structured.")
|
||||
elif score >= 60:
|
||||
feedback.append("Good rule with room for improvement.")
|
||||
elif score >= 40:
|
||||
feedback.append("Fair rule. Consider making it more specific.")
|
||||
else:
|
||||
feedback.append("Poor rule. Needs significant improvement.")
|
||||
|
||||
# Add specific feedback
|
||||
if len(rule_text.strip()) < 20:
|
||||
feedback.append("Rule is too short. Add more specific criteria.")
|
||||
elif len(rule_text.strip()) > 100:
|
||||
feedback.append("Rule is too long. Be more concise.")
|
||||
|
||||
if not any(word in rule_text.lower() for word in ['from', 'subject', 'contains']):
|
||||
feedback.append("Consider adding specific criteria like 'from:' or 'subject:'.")
|
||||
|
||||
if not any(word in rule_text.lower() for word in ['move', 'filter', 'organize']):
|
||||
feedback.append("Make sure the rule includes an action word.")
|
||||
|
||||
return " ".join(feedback)
|
||||
|
||||
@staticmethod
|
||||
def generate_cache_key(folder_name: str, folder_type: str, rule_type: str, raw_text: str) -> str:
|
||||
"""Generate a cache key for AI rule requests."""
|
||||
key_string = f"{folder_name}:{folder_type}:{rule_type}:{raw_text}"
|
||||
return hashlib.md5(key_string.encode()).hexdigest()
|
||||
|
||||
def get_fallback_rule(self, folder_name: str, folder_type: str = 'destination') -> str:
|
||||
"""Generate a fallback rule when AI service is unavailable."""
|
||||
fallback_rules = {
|
||||
'destination': [
|
||||
f"Move emails containing '{folder_name}' in the subject to this folder",
|
||||
f"Filter emails from senders with '{folder_name}' in their domain",
|
||||
f"Organize emails with '{folder_name}' keywords in the body"
|
||||
],
|
||||
'tidy': [
|
||||
f"Move emails older than 30 days to this folder",
|
||||
f"Archive processed emails from '{folder_name}'",
|
||||
f"Sort completed emails by date"
|
||||
],
|
||||
'ignore': [
|
||||
f"Ignore emails containing '{folder_name}'",
|
||||
f"Exclude emails from '{folder_name}' senders",
|
||||
f"Skip emails with '{folder_name}' in subject"
|
||||
]
|
||||
}
|
||||
|
||||
rules = fallback_rules.get(folder_type, fallback_rules['destination'])
|
||||
return rules[0] if rules else f"Move emails related to '{folder_name}' to this folder"
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
from flask_login import UserMixin
|
||||
|
||||
import uuid
|
||||
import hashlib
|
||||
|
||||
Base = declarative_base()
|
||||
db = SQLAlchemy(model_class=Base)
|
||||
@@ -65,4 +66,34 @@ class ProcessedEmail(Base):
|
||||
folder = db.relationship('Folder', backref=db.backref('processed_emails', lazy=True))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<ProcessedEmail {self.email_uid} for folder {self.folder_name}>'
|
||||
return f'<ProcessedEmail {self.email_uid} for folder {self.folder_name}>'
|
||||
|
||||
class AIRuleCache(Base):
|
||||
"""Cache for AI-generated rules to improve performance and reduce API calls."""
|
||||
__tablename__ = 'ai_rule_cache'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
folder_name = db.Column(db.String(255), nullable=False)
|
||||
folder_type = db.Column(db.String(20), nullable=False)
|
||||
rule_text = db.Column(db.Text, nullable=False)
|
||||
rule_metadata = db.Column(db.JSON) # Quality score, model info, etc.
|
||||
cache_key = db.Column(db.String(64), unique=True, nullable=False) # MD5 hash of inputs
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
|
||||
user = db.relationship('User', backref=db.backref('ai_rule_cache', lazy=True))
|
||||
|
||||
def __repr__(self):
|
||||
return f'<AIRuleCache {self.folder_name} for user {self.user_id}>'
|
||||
|
||||
@staticmethod
|
||||
def generate_cache_key(folder_name: str, folder_type: str, rule_type: str = 'single', rule_text: str = '') -> str:
|
||||
"""Generate a unique cache key based on inputs."""
|
||||
input_string = f"{folder_name}:{folder_type}:{rule_type}:{rule_text}"
|
||||
return hashlib.md5(input_string.encode()).hexdigest()
|
||||
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if cache entry is expired."""
|
||||
return datetime.utcnow() > self.expires_at
|
||||
@@ -1,8 +1,14 @@
|
||||
from flask import Blueprint, render_template, request, jsonify, make_response
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import Folder
|
||||
from app.models import Folder, AIRuleCache
|
||||
from app.ai_service import AIService
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
import json
|
||||
|
||||
# Initialize the AI service instance
|
||||
ai_service = AIService()
|
||||
|
||||
folders_bp = Blueprint('folders', __name__)
|
||||
|
||||
@@ -180,7 +186,10 @@ def edit_folder_modal(folder_id):
|
||||
return jsonify({'error': 'Folder not found'}), 404
|
||||
|
||||
# Return the edit folder modal with folder data
|
||||
response = make_response(render_template('partials/folder_modal.html', folder=folder))
|
||||
response = make_response(render_template('partials/folder_modal.html', folder=folder,
|
||||
folder_data={'rule_text': folder.rule_text,
|
||||
'show_ai_rules': True,
|
||||
'errors': None }))
|
||||
response.headers['HX-Trigger'] = 'open-modal'
|
||||
return response
|
||||
|
||||
@@ -313,4 +322,160 @@ def get_folders():
|
||||
|
||||
return response
|
||||
else:
|
||||
return render_template('partials/folders_list.html', folders=folders, show_hidden=show_hidden)
|
||||
return render_template('partials/folders_list.html', folders=folders, show_hidden=show_hidden)
|
||||
|
||||
@folders_bp.route('/api/folders/generate-rule', methods=['POST'])
|
||||
@login_required
|
||||
def generate_rule():
|
||||
"""Generate an email organization rule using AI."""
|
||||
try:
|
||||
# Get form data
|
||||
folder_name = request.form.get('name', '').strip()
|
||||
folder_type = request.form.get('folder_type', 'destination')
|
||||
rule_type = request.form.get('rule_type', 'single') # 'single' or 'multiple'
|
||||
rule_text = request.form.get('rule_text', '')
|
||||
|
||||
# Validate inputs
|
||||
if not folder_name:
|
||||
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Folder name is required'})
|
||||
|
||||
if folder_type not in ['destination', 'tidy', 'ignore']:
|
||||
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Invalid folder type'})
|
||||
|
||||
if rule_type not in ['single', 'multiple']:
|
||||
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Invalid rule type'})
|
||||
|
||||
# Check cache first
|
||||
cache_key = AIRuleCache.generate_cache_key(folder_name, folder_type, rule_type, rule_text)
|
||||
cached_rule = AIRuleCache.query.filter_by(
|
||||
cache_key=cache_key,
|
||||
user_id=current_user.id,
|
||||
is_active=True
|
||||
).first()
|
||||
|
||||
if cached_rule and not cached_rule.is_expired():
|
||||
# Return cached result
|
||||
result = {
|
||||
'success': True,
|
||||
'cached': True,
|
||||
'rule': cached_rule.rule_text,
|
||||
'metadata': cached_rule.rule_metadata,
|
||||
'quality_score': cached_rule.rule_metadata.get('quality_score', 0) if cached_rule.rule_metadata else 0
|
||||
}
|
||||
return render_template('partials/ai_rule_result.html', result=result)
|
||||
|
||||
# Generate new rule using AI service
|
||||
if rule_type == 'single':
|
||||
rule_text, metadata = ai_service.generate_single_rule(folder_name, folder_type, rule_text)
|
||||
|
||||
if rule_text is None:
|
||||
# AI service failed, return fallback
|
||||
fallback_rule = ai_service.get_fallback_rule(folder_name, folder_type)
|
||||
result = {
|
||||
'success': True,
|
||||
'fallback': True,
|
||||
'rule': fallback_rule,
|
||||
'quality_score': 50,
|
||||
'message': 'AI service unavailable, using fallback rule'
|
||||
}
|
||||
return render_template('partials/ai_rule_result.html', result=result)
|
||||
|
||||
# Cache the result
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1) # Cache for 1 hour
|
||||
cache_entry = AIRuleCache(
|
||||
user_id=current_user.id,
|
||||
folder_name=folder_name,
|
||||
folder_type=folder_type,
|
||||
rule_text=rule_text,
|
||||
rule_metadata=metadata,
|
||||
cache_key=cache_key,
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.session.add(cache_entry)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'rule': rule_text,
|
||||
'metadata': metadata,
|
||||
'quality_score': metadata.get('quality_score', 0)
|
||||
}
|
||||
return render_template('partials/ai_rule_result.html', result=result)
|
||||
|
||||
else: # multiple rules
|
||||
rules, metadata = ai_service.generate_multiple_rules(folder_name, folder_type, rule_text)
|
||||
|
||||
if rules is None:
|
||||
# AI service failed, return fallback
|
||||
fallback_rule = ai_service.get_fallback_rule(folder_name, folder_type)
|
||||
result = {
|
||||
'success': True,
|
||||
'fallback': True,
|
||||
'rules': [{'text': fallback_rule, 'quality_score': 50}],
|
||||
'message': 'AI service unavailable, using fallback rule'
|
||||
}
|
||||
return render_template('partials/ai_rule_result.html', result=result)
|
||||
|
||||
# Cache the first rule as representative
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||
cache_entry = AIRuleCache(
|
||||
user_id=current_user.id,
|
||||
folder_name=folder_name,
|
||||
folder_type=folder_type,
|
||||
rule_text=rules[0]['text'] if rules else '',
|
||||
rule_metadata=metadata,
|
||||
cache_key=cache_key,
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.session.add(cache_entry)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'rules': rules,
|
||||
'metadata': metadata
|
||||
}
|
||||
return render_template('partials/ai_rule_result.html', result=result)
|
||||
|
||||
except Exception as e:
|
||||
# Print unhandled exceptions to the console as required
|
||||
logging.exception("Error generating rule: %s", e)
|
||||
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'An unexpected error occurred'})
|
||||
|
||||
@folders_bp.route('/api/folders/assess-rule', methods=['POST'])
|
||||
@login_required
|
||||
def assess_rule():
|
||||
"""Assess the quality of an email organization rule."""
|
||||
try:
|
||||
# Get form data
|
||||
rule_text = request.form.get('rule_text', '').strip()
|
||||
folder_name = request.form.get('folder_name', '').strip()
|
||||
folder_type = request.form.get('folder_type', 'destination')
|
||||
|
||||
# Validate inputs
|
||||
if not rule_text:
|
||||
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Rule text is required'})
|
||||
|
||||
if not folder_name:
|
||||
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Folder name is required'})
|
||||
|
||||
if folder_type not in ['destination', 'tidy', 'ignore']:
|
||||
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'Invalid folder type'})
|
||||
|
||||
# Assess rule quality
|
||||
quality_assessment = ai_service.assess_rule_quality(rule_text, folder_name, folder_type)
|
||||
|
||||
result = {
|
||||
'success': True,
|
||||
'assessment': quality_assessment,
|
||||
'rule': rule_text,
|
||||
'quality_score': quality_assessment['score']
|
||||
}
|
||||
return render_template('partials/ai_rule_result.html', result=result)
|
||||
|
||||
except Exception as e:
|
||||
# Print unhandled exceptions to the console as required
|
||||
logging.exception("Error assessing rule: %s", e)
|
||||
return render_template('partials/ai_rule_result.html', result={'success': False, 'error': 'An unexpected error occurred'})
|
||||
104
app/templates/partials/ai_rule_result.html
Normal file
104
app/templates/partials/ai_rule_result.html
Normal file
@@ -0,0 +1,104 @@
|
||||
{% if result.success %}
|
||||
<div>
|
||||
{% if result.cached %}
|
||||
<div class="alert alert-info mb-2" role="status" aria-live="polite">
|
||||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||
Using cached rule
|
||||
</div>
|
||||
{% elif result.fallback %}
|
||||
<div class="alert alert-warning mb-2" role="status" aria-live="polite">
|
||||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||
{{ result.message | default('Using fallback rule') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if result.rules %}
|
||||
<div class="grid grid-cols-1 gap-2 mb-2" role="list" aria-label="AI-generated rule options">
|
||||
{% for rule in result.rules %}
|
||||
<div class="bg-white border rounded-lg p-3" role="listitem"
|
||||
x-data="{}"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h4 class="font-medium text-sm">Option {{ loop.index }}</h4>
|
||||
<span class="badge {% if rule.quality_score >= 80 %}badge-success{% elif rule.quality_score >= 60 %}badge-warning{% else %}badge-error{% endif %}"
|
||||
role="status" aria-live="polite">
|
||||
{{ rule.quality_score }}%
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 mb-2 whitespace-pre-line">{{ rule.text }}</p>
|
||||
{% if rule.key_criteria %}
|
||||
<div class="text-xs text-gray-500 mb-2">
|
||||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||
{{ rule.key_criteria }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex gap-1">
|
||||
<button type="button"
|
||||
class="btn btn-xs btn-primary"
|
||||
@click="rule_text = $el.getAttribute('data-rule-text'); show_ai_rules=false "
|
||||
data-rule-text="{{ rule.text|safe }}"
|
||||
aria-label="Use rule option {{ loop.index }}">
|
||||
<i class="fas fa-check mr-1" aria-hidden="true"></i>
|
||||
Use
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// Alpine.js data and methods
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('aiRuleResult', () => ({
|
||||
copyRuleText() {
|
||||
const ruleText = document.getElementById('generated-rule-text').textContent;
|
||||
navigator.clipboard.writeText(ruleText).then(() => {
|
||||
// Show feedback
|
||||
const button = event.target.closest('button');
|
||||
const originalContent = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check"></i>';
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalContent;
|
||||
}, 2000);
|
||||
// Announce to screen readers
|
||||
announceToScreenReader('Rule copied to clipboard');
|
||||
}).catch(() => {
|
||||
announceToScreenReader('Failed to copy rule to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}))
|
||||
});
|
||||
|
||||
function announceToScreenReader(message) {
|
||||
const announcement = document.createElement('div');
|
||||
announcement.setAttribute('role', 'status');
|
||||
announcement.setAttribute('aria-live', 'polite');
|
||||
announcement.className = 'sr-only';
|
||||
announcement.textContent = message;
|
||||
document.body.appendChild(announcement);
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(announcement);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Add keyboard support for buttons
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
const focusedElement = document.activeElement;
|
||||
if (focusedElement && focusedElement.getAttribute('role') === 'button') {
|
||||
event.preventDefault();
|
||||
focusedElement.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-error mb-2">
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||
{{ result.error | default('Failed to generate rule') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,4 +1,10 @@
|
||||
<div id="folder-modal" @click.away="$refs.modal.close()" class="modal-box" x-data="{ errors: {{ 'true' if errors else 'false' }} }" x-init="$nextTick(() => { if (errors) { document.querySelector('#submit-btn').classList.add('shake'); } })">
|
||||
<!-- x-data="{ errors: {{ 'true' if errors else 'false' }},
|
||||
ruleText:{% if folder %}{{ folder.rule_text|tojson }}{% endif %},
|
||||
showAiResults: true }"
|
||||
-->
|
||||
<div id="folder-modal" @click.away="$refs.modal.close()" class="modal-box"
|
||||
x-data='{{ folder_data|tojson }}'
|
||||
x-init="$nextTick(() => { if (errors) { document.querySelector('#submit-btn').classList.add('shake'); } })">
|
||||
<h3 class="font-bold text-lg mb-4" id="modal-title">
|
||||
{% if folder %}Edit Folder{% else %}Add New Folder{% endif %}
|
||||
</h3>
|
||||
@@ -10,8 +16,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="folder-form"
|
||||
{% if folder %} hx-put="/api/folders/{{ folder.id }}" {% else %} hx-post="/api/folders" {% endif %}
|
||||
<form id="folder-form"
|
||||
{% if folder %} hx-put="/api/folders/{{ folder.id }}" {% else %} hx-post="/api/folders" {% endif %}
|
||||
hx-target="#folder-modal"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
@@ -30,10 +36,34 @@
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="folder-rule" class="block text-sm font-medium mb-1">Rule (Natural Language)</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline btn-secondary"
|
||||
id="generate-multiple-rules"
|
||||
hx-post="/api/folders/generate-rule"
|
||||
hx-vals='{"folder_name": "{{ name if name is defined else '' }}", "folder_type": "{{ 'tidy' if (name is defined and name.strip().lower() == 'inbox') else 'destination' }}", "rule_type": "multiple"}'
|
||||
hx-target="#rule-generation-result"
|
||||
hx-swap="innerHTML"
|
||||
data-loading-disable
|
||||
aria-label="Generate multiple AI-powered email rule options"
|
||||
aria-describedby="ai-rule-help">
|
||||
<i class="fas fa-th mr-1" data-loading-class="!hidden"></i>
|
||||
<span data-loading-class="!hidden">Enhance my rules</span>
|
||||
<span class="loading loading-spinner loading-xs hidden" data-loading-class-remove="hidden"></span>
|
||||
</button>
|
||||
<div id="ai-rule-help" class="hidden">
|
||||
AI-powered rule generation creates email organization rules based on your folder name and type.
|
||||
</div>
|
||||
</div>
|
||||
<div id="rule-generation-result" class="mb-2" x-show="show_ai_rules">
|
||||
<!-- AI rule results will be injected here -->
|
||||
</div>
|
||||
<textarea id="folder-rule" name="rule_text"
|
||||
class="textarea textarea-bordered w-full h-24 {% if errors and errors.rule_text %}textarea-error{% endif %}"
|
||||
placeholder="e.g., Move emails from 'newsletter@company.com' to this folder"
|
||||
required>{% if rule_text is defined %}{{ rule_text }}{% elif folder %}{{ folder.rule_text }}{% endif %}</textarea>
|
||||
required
|
||||
x-model="rule_text"
|
||||
>{% if rule_text is defined %}{{ rule_text }}{% elif folder %}{{ folder.rule_text }}{% endif %}</textarea>
|
||||
{% if errors and errors.rule_text %}
|
||||
<div class="text-error text-sm mt-1">{{ errors.rule_text }}</div>
|
||||
{% endif %}
|
||||
@@ -55,4 +85,19 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Alpine.js event listener for AI rule usage -->
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
// Listen for the custom event when an AI rule is used
|
||||
window.addEventListener('ai-rule-used', (event) => {
|
||||
// Set the textarea value with the selected text
|
||||
document.getElementById('folder-rule').value = event.detail.text;
|
||||
// Trigger validation
|
||||
document.getElementById('folder-rule').dispatchEvent(new Event('input'));
|
||||
// Hide AI results
|
||||
document.querySelector('[x-data]').__x.$data.showAiResults = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
Reference in New Issue
Block a user