Makes ai rule generation content work good.

This commit is contained in:
Bryce
2025-08-10 21:21:02 -07:00
parent 47a63d2eab
commit 0dac428217
12 changed files with 2129 additions and 8 deletions

348
app/ai_service.py Normal file
View 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"

View File

@@ -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

View File

@@ -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'})

View 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 %}

View File

@@ -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>