Files
Emailsorter/server/services/ai-sorter.mjs
ANDJ 89bc86b615 Try
dfssdfsfdsf
2026-04-09 21:00:04 +02:00

888 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AI Email Sorter Service
* Uses Mistral AI for intelligent email categorization
*/
import { Mistral } from '@mistralai/mistralai'
import { config } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs'
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms))
}
function is503Error(error) {
const status = error?.status ?? error?.statusCode ?? error?.response?.status
if (status === 503) return true
const msg = String(error?.message || '').toLowerCase()
return msg.includes('503') || msg.includes('service unavailable')
}
function isRetryableError(err) {
if (is503Error(err)) return true
const status = err?.status ?? err?.statusCode ?? err?.response?.status
if (status === 429) return true
const msg = (err?.message || '').toLowerCase()
return (
msg.includes('429') ||
msg.includes('rate limit') ||
msg.includes('too many requests')
)
}
/**
* Rule-based fallback when Mistral is unavailable or rate-limited.
* @param {{ from?: string, subject?: string, snippet?: string }} emailData
*/
export function ruleBasedCategory(emailData) {
const from = (emailData.from || '').toLowerCase()
const subject = (emailData.subject || '').toLowerCase()
const snippet = (emailData.snippet || '').toLowerCase()
// NEWSLETTERS — mass emails, unsubscribe links
if (
from.includes('noreply') ||
from.includes('no-reply') ||
from.includes('newsletter') ||
from.includes('marketing') ||
subject.includes('newsletter') ||
subject.includes('unsubscribe') ||
subject.includes('abbestellen')
) {
return 'newsletters'
}
// PROMOTIONS — sales, offers, discounts
if (
subject.includes('sale') ||
subject.includes('offer') ||
subject.includes('deal') ||
subject.includes('discount') ||
subject.includes('% off') ||
subject.includes('angebot') ||
subject.includes('rabatt') ||
from.includes('promo') ||
from.includes('deals') ||
from.includes('offers')
) {
return 'promotions'
}
// INVOICES — billing documents
if (
subject.includes('invoice') ||
subject.includes('rechnung') ||
subject.includes('payment') ||
subject.includes('zahlung') ||
subject.includes('bill ') ||
subject.includes('receipt') ||
subject.includes('quittung')
) {
return 'invoices'
}
// SECURITY — ONLY real security alerts (very specific)
if (
(subject.includes('security alert') ||
subject.includes('sign-in') ||
subject.includes('new login') ||
subject.includes('suspicious') ||
subject.includes('verify your') ||
subject.includes('2fa') ||
subject.includes('two-factor') ||
subject.includes('password reset') ||
(subject.includes('passwort') && subject.includes('zurücksetzen'))) &&
(from.includes('security') ||
from.includes('noreply') ||
from.includes('accounts') ||
from.includes('alerts'))
) {
return 'security'
}
// CALENDAR — meetings and events
if (
subject.includes('meeting') ||
subject.includes('invitation') ||
subject.includes('calendar') ||
subject.includes('appointment') ||
subject.includes('termin') ||
subject.includes('einladung') ||
subject.endsWith('.ics')
) {
return 'calendar'
}
// VIP — personal direct emails (not noreply, short subject)
if (
!from.includes('noreply') &&
!from.includes('no-reply') &&
!from.includes('newsletter') &&
!from.includes('info@') &&
subject.length < 60 &&
subject.length > 3
) {
return 'vip'
}
// DEFAULT — review (not security!)
return 'review'
}
/** Pace Mistral calls (IMAP sort uses these in email.mjs) */
export const AI_BATCH_CHUNK_SIZE = 5
export const AI_BATCH_CHUNK_DELAY_MS = 2000
/**
* Email categories with metadata
* Uses Gmail categories where available
*
* Actions:
* - 'star': Keep in inbox + add star (VIP)
* - 'inbox': Keep in inbox
* - 'archive_read': Archive + mark as read (cleans inbox)
*/
const CATEGORIES = {
vip: {
name: 'Important',
description: 'Important emails from known contacts',
color: '#ff0000',
gmailCategory: null,
action: 'star', // Keep in inbox + star
priority: 1,
},
customers: {
name: 'Clients',
description: 'Emails from clients and projects',
color: '#4285f4',
gmailCategory: null,
action: 'inbox', // Keep in inbox
priority: 2,
},
invoices: {
name: 'Invoices',
description: 'Invoices, receipts and financial documents',
color: '#0f9d58',
gmailCategory: null,
action: 'inbox', // Keep in inbox
priority: 3,
},
newsletters: {
name: 'Newsletter',
description: 'Regular newsletters and updates',
color: '#9c27b0',
gmailCategory: 'CATEGORY_UPDATES',
action: 'archive_read', // Archive + mark as read
priority: 4,
},
promotions: {
name: 'Promotions',
description: 'Marketing emails and promotions',
color: '#ff9800',
gmailCategory: 'CATEGORY_PROMOTIONS',
action: 'archive_read', // Archive + mark as read
priority: 5,
},
social: {
name: 'Social',
description: 'Social media and platform notifications',
color: '#00bcd4',
gmailCategory: 'CATEGORY_SOCIAL',
action: 'archive_read', // Archive + mark as read
priority: 6,
},
security: {
name: 'Security',
description:
'ONLY real account-security mail: login alerts (new sign-in, suspicious activity), password reset/change, 2FA/MFA codes, device verification. NOT marketing, shipping alerts, price drops, social notifications, or generic “notification” subjects.',
color: '#f44336',
gmailCategory: null,
action: 'inbox', // Keep in inbox (important!)
priority: 1,
},
calendar: {
name: 'Calendar',
description: 'Calendar invites and events',
color: '#673ab7',
gmailCategory: null,
action: 'inbox', // Keep in inbox
priority: 3,
},
review: {
name: 'Review',
description: 'Emails that need manual review',
color: '#607d8b',
gmailCategory: null,
action: 'inbox', // Keep in inbox for review
priority: 10,
},
}
/**
* Known companies for automatic detection
* Maps domain patterns to company names
*/
const KNOWN_COMPANIES = {
'amazon.com': 'Amazon',
'amazon.de': 'Amazon',
'amazon.co.uk': 'Amazon',
'amazon.fr': 'Amazon',
'google.com': 'Google',
'gmail.com': 'Google',
'microsoft.com': 'Microsoft',
'outlook.com': 'Microsoft',
'hotmail.com': 'Microsoft',
'apple.com': 'Apple',
'icloud.com': 'Apple',
'facebook.com': 'Facebook',
'meta.com': 'Meta',
'twitter.com': 'Twitter',
'x.com': 'Twitter',
'linkedin.com': 'LinkedIn',
'github.com': 'GitHub',
'netflix.com': 'Netflix',
'spotify.com': 'Spotify',
'paypal.com': 'PayPal',
'stripe.com': 'Stripe',
'shopify.com': 'Shopify',
'uber.com': 'Uber',
'airbnb.com': 'Airbnb',
'dropbox.com': 'Dropbox',
'slack.com': 'Slack',
'zoom.us': 'Zoom',
}
/**
* AI Sorter Service Class
*/
export class AISorterService {
constructor() {
this.client = null
this.model = 'mistral-small-latest'
this.enabled = Boolean(config.mistral.apiKey)
if (this.enabled) {
this.client = new Mistral({ apiKey: config.mistral.apiKey })
log.info('AI Sorter Service initialized with Mistral AI')
} else {
log.warn('AI Sorter Service disabled - no MISTRAL_API_KEY')
}
}
/**
* Get all categories
*/
getCategories() {
return CATEGORIES
}
/**
* Get category names as array
*/
getCategoryNames() {
return Object.values(CATEGORIES).map(c => c.name)
}
/**
* Get label name for a category key
*/
getLabelName(key) {
return CATEGORIES[key]?.name || CATEGORIES.review.name
}
/**
* Get Gmail category ID if available
*/
getGmailCategory(key) {
return CATEGORIES[key]?.gmailCategory || null
}
/**
* Get action for category (respects user preferences)
*/
getCategoryAction(key, preferences = {}) {
// Check for user override first
if (preferences.categoryActions?.[key]) {
return preferences.categoryActions[key]
}
// Return default action
return CATEGORIES[key]?.action || 'inbox'
}
/**
* Get color for category
*/
getCategoryColor(key) {
return CATEGORIES[key]?.color || '#607d8b'
}
/**
* Get enabled categories based on user preferences
*/
getEnabledCategories(preferences = {}) {
const enabled = preferences.enabledCategories || Object.keys(CATEGORIES)
return enabled.filter(key => CATEGORIES[key]) // Only return valid categories
}
/**
* Detect company from email address
*/
detectCompany(email) {
if (!email?.from) return null
// Extract domain from email
const emailMatch = email.from.match(/@([^\s>]+)/)
if (!emailMatch) return null
const domain = emailMatch[1].toLowerCase()
// Check known companies
if (KNOWN_COMPANIES[domain]) {
return {
name: KNOWN_COMPANIES[domain],
domain,
label: KNOWN_COMPANIES[domain],
}
}
// Check for subdomains (e.g., mail.amazon.com -> Amazon)
const domainParts = domain.split('.')
if (domainParts.length > 2) {
const baseDomain = domainParts.slice(-2).join('.')
if (KNOWN_COMPANIES[baseDomain]) {
return {
name: KNOWN_COMPANIES[baseDomain],
domain: baseDomain,
label: KNOWN_COMPANIES[baseDomain],
}
}
}
return null
}
/**
* Generate suggested rules based on email patterns
* Analyzes email samples to detect patterns and suggest rules
*/
async generateSuggestedRules(userId, emailSamples) {
if (!emailSamples || emailSamples.length === 0) {
return []
}
const suggestions = []
const senderCounts = {}
const domainCounts = {}
const subjectPatterns = {}
const categoryPatterns = {}
// Analyze patterns
for (const email of emailSamples) {
const from = email.from?.toLowerCase() || ''
const subject = email.subject?.toLowerCase() || ''
// Extract domain
const emailMatch = from.match(/@([^\s>]+)/)
if (emailMatch) {
const domain = emailMatch[1].toLowerCase()
domainCounts[domain] = (domainCounts[domain] || 0) + 1
}
// Count senders
const senderEmail = from.split('<')[1]?.split('>')[0] || from
senderCounts[senderEmail] = (senderCounts[senderEmail] || 0) + 1
// Detect category patterns
const category = email.category || 'review'
categoryPatterns[category] = (categoryPatterns[category] || 0) + 1
}
const totalEmails = emailSamples.length
const threshold = Math.max(3, Math.ceil(totalEmails * 0.1)) // At least 3 emails or 10% of total
// Suggest VIP senders (frequent senders)
const frequentSenders = Object.entries(senderCounts)
.filter(([_, count]) => count >= threshold)
.sort(([_, a], [__, b]) => b - a)
.slice(0, 3)
for (const [sender, count] of frequentSenders) {
suggestions.push({
type: 'vip_sender',
name: `Mark ${sender.split('@')[0]} as VIP`,
description: `${count} emails from this sender`,
confidence: Math.min(0.9, count / totalEmails),
action: {
type: 'add_vip',
email: sender,
},
})
}
// Suggest company labels (frequent domains)
const frequentDomains = Object.entries(domainCounts)
.filter(([domain, count]) => count >= threshold && !KNOWN_COMPANIES[domain])
.sort(([_, a], [__, b]) => b - a)
.slice(0, 3)
for (const [domain, count] of frequentDomains) {
const companyName = domain.split('.')[0].charAt(0).toUpperCase() + domain.split('.')[0].slice(1)
suggestions.push({
type: 'company_label',
name: `Label ${companyName} emails`,
description: `${count} emails from ${domain}`,
confidence: Math.min(0.85, count / totalEmails),
action: {
type: 'add_company_label',
name: companyName,
condition: `from:${domain}`,
category: 'promotions', // Default, user can change
},
})
}
// Suggest category-specific rules based on patterns
if (categoryPatterns.newsletters >= threshold) {
suggestions.push({
type: 'category_rule',
name: 'Archive newsletters automatically',
description: `${categoryPatterns.newsletters} newsletter emails detected`,
confidence: 0.8,
action: {
type: 'enable_category',
category: 'newsletters',
action: 'archive_read',
},
})
}
if (categoryPatterns.promotions >= threshold) {
suggestions.push({
type: 'category_rule',
name: 'Archive promotions automatically',
description: `${categoryPatterns.promotions} promotion emails detected`,
confidence: 0.75,
action: {
type: 'enable_category',
category: 'promotions',
action: 'archive_read',
},
})
}
// Sort by confidence and return top 5
return suggestions
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5)
}
/**
* Check if email matches a company label condition
*/
matchesCompanyLabel(email, companyLabel) {
if (!companyLabel?.enabled || !companyLabel?.condition) return false
const { condition } = companyLabel
const from = email.from?.toLowerCase() || ''
const subject = email.subject?.toLowerCase() || ''
// Simple condition parser: supports "from:domain.com" and "subject:keyword"
if (condition.includes('from:')) {
const domain = condition.split('from:')[1]?.trim().split(' ')[0]
if (domain && from.includes(domain)) return true
}
if (condition.includes('subject:')) {
const keyword = condition.split('subject:')[1]?.trim().split(' ')[0]
if (keyword && subject.includes(keyword)) return true
}
// Support OR conditions
if (condition.includes(' OR ')) {
const parts = condition.split(' OR ')
return parts.some(part => this.matchesCompanyLabel(email, { ...companyLabel, condition: part.trim() }))
}
// Support AND conditions
if (condition.includes(' AND ')) {
const parts = condition.split(' AND ')
return parts.every(part => this.matchesCompanyLabel(email, { ...companyLabel, condition: part.trim() }))
}
// Simple domain match
if (condition.includes('@')) {
const domain = condition.split('@')[1]?.trim()
if (domain && from.includes(domain)) return true
}
return false
}
/**
* Categorize a single email
*/
async categorize(email, preferences = {}) {
if (!this.enabled) {
return {
category: ruleBasedCategory(email),
confidence: 0,
reason: 'AI not configured',
assignedTo: null,
}
}
const { from, subject, snippet } = email
// Build context from preferences
const preferenceContext = this._buildPreferenceContext(preferences)
const prompt = `You are an intelligent email sorting assistant. Analyze the following email and categorize it.
AVAILABLE CATEGORIES:
${Object.entries(CATEGORIES).map(([key, cat]) => `- ${key}: ${cat.name} - ${cat.description}`).join('\n')}
CLASSIFICATION RULES (important):
- security: Use ONLY for genuine account safety: password reset/change, 2FA/MFA codes, new device login, suspicious sign-in warnings from the service itself. Do NOT use security for marketing, newsletters, order/shipping "alerts", price alerts, social network notifications, or anything that merely says "alert" or "notification".
- social: Social networks, likes, follows, mentions, friend requests, activity digests.
- newsletters: Recurring digests, blogs, Substack, product updates that are not personal.
- promotions: Sales, discounts, ads, deals.
- review: When unsure or mixed — prefer review over guessing security.
${preferenceContext}
EMAIL:
From: ${from}
Subject: ${subject}
Preview: ${snippet?.substring(0, 500) || 'No preview'}
RESPONSE FORMAT (JSON ONLY):
{"category": "category_key", "confidence": 0.0-1.0, "reason": "brief explanation", "assignedTo": "name_label_id_or_name_or_null"}
If the email is clearly FOR a specific worker (e.g. "für Max", "an Anna", subject/body mentions them), set assignedTo to that worker's id or name. Otherwise set assignedTo to null or omit it.
Respond ONLY with the JSON object.`
const parseAndValidate = (content) => {
const result = JSON.parse(content)
if (!CATEGORIES[result.category]) {
result.category = 'review'
}
if (result.assignedTo && preferences.nameLabels?.length) {
const match = preferences.nameLabels.find(
(l) => l.enabled && (l.id === result.assignedTo || l.name === result.assignedTo)
)
if (!match) result.assignedTo = null
else result.assignedTo = match.id || match.name
}
return result
}
let attempt = 0
let used503Backoff = false
while (true) {
try {
const response = await this.client.chat.complete({
model: this.model,
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
maxTokens: 150,
responseFormat: { type: 'json_object' },
})
const content = response.choices[0]?.message?.content
return parseAndValidate(content)
} catch (error) {
if (!isRetryableError(error)) {
log.error('AI categorization failed', { error: error.message })
return {
category: ruleBasedCategory(email),
confidence: 0,
reason: 'Categorization error',
assignedTo: null,
}
}
if (is503Error(error)) {
if (!used503Backoff) {
used503Backoff = true
log.warn('Mistral 503 (service unavailable), retry in 5s', { attempt: attempt + 1 })
await sleep(5000)
continue
}
log.warn('Mistral 503 after retry, using rule-based fallback')
return {
category: ruleBasedCategory(email),
confidence: 0,
reason: '503 — rule-based fallback',
assignedTo: null,
}
}
if (attempt >= 2) {
log.warn('Mistral rate limit after retries, using rule-based fallback')
return {
category: ruleBasedCategory(email),
confidence: 0,
reason: 'Rate limit — rule-based fallback',
assignedTo: null,
}
}
if (attempt === 0) {
log.warn('Mistral rate limit (429), retry in 2s', { attempt: attempt + 1 })
await sleep(2000)
} else {
log.warn('Mistral rate limit (429), retry in 5s', { attempt: attempt + 1 })
await sleep(5000)
}
attempt++
}
}
}
/**
* Batch categorize multiple emails
*/
async batchCategorize(emails, preferences = {}) {
if (!this.enabled || emails.length === 0) {
return emails.map((e) => ({
email: e,
classification: {
category: ruleBasedCategory(e),
confidence: 0,
reason: 'AI not available',
assignedTo: null,
},
}))
}
// For small batches, process individually
if (emails.length <= 5) {
const results = []
for (const email of emails) {
const classification = await this.categorize(email, preferences)
results.push({ email, classification })
}
return results
}
// For larger batches, use batch request
const preferenceContext = this._buildPreferenceContext(preferences)
const emailList = emails.map((e, i) =>
`[${i}] From: ${e.from} | Subject: ${e.subject} | Preview: ${e.snippet?.substring(0, 200) || '-'}`
).join('\n')
const prompt = `You are an email sorting assistant. Categorize the following ${emails.length} emails.
CATEGORIES:
${Object.entries(CATEGORIES).map(([key, cat]) => `${key}: ${cat.name}${cat.description}`).join('\n')}
RULES: Use "security" ONLY for real account safety (password/2FA/login alerts). NOT for marketing alerts, shipping updates, or social notifications — use promotions, newsletters, social, or review instead.
${preferenceContext}
EMAILS:
${emailList}
RESPONSE FORMAT (JSON ARRAY ONLY):
[{"index": 0, "category": "key", "assignedTo": "id_or_name_or_null"}, ...]
If an email is clearly FOR a specific worker, set assignedTo to that worker's id or name. Otherwise omit or null.
Respond ONLY with the JSON array.`
const runBatchRequest = async () => {
const response = await this.client.chat.complete({
model: this.model,
messages: [{ role: 'user', content: prompt }],
temperature: 0.1,
maxTokens: emails.length * 50,
responseFormat: { type: 'json_object' },
})
const content = response.choices[0]?.message?.content
let parsed
try {
parsed = JSON.parse(content)
if (parsed.results) parsed = parsed.results
if (!Array.isArray(parsed)) {
throw new Error('Not an array')
}
} catch {
return this._fallbackBatch(emails, preferences)
}
return emails.map((email, i) => {
const result = parsed.find((r) => r.index === i)
const category = result?.category && CATEGORIES[result.category] ? result.category : 'review'
let assignedTo = result?.assignedTo || null
if (assignedTo && preferences.nameLabels?.length) {
const match = preferences.nameLabels.find(
(l) => l.enabled && (l.id === assignedTo || l.name === assignedTo)
)
assignedTo = match ? (match.id || match.name) : null
}
return {
email,
classification: { category, confidence: 0.8, reason: 'Batch', assignedTo },
}
})
}
let attempt = 0
let used503Backoff = false
while (true) {
try {
return await runBatchRequest()
} catch (error) {
if (!isRetryableError(error)) {
log.error('Batch categorization failed', { error: error.message })
return this._fallbackBatch(emails, preferences)
}
if (is503Error(error)) {
if (!used503Backoff) {
used503Backoff = true
log.warn('Mistral batch 503 (service unavailable), retry in 5s', { attempt: attempt + 1 })
await sleep(5000)
continue
}
log.warn('Mistral batch 503 after retry, rule-based per email')
return emails.map((email) => ({
email,
classification: {
category: ruleBasedCategory(email),
confidence: 0,
reason: '503 — rule-based fallback',
assignedTo: null,
},
}))
}
if (attempt >= 2) {
log.warn('Mistral batch rate limit after retries, rule-based per email')
return emails.map((email) => ({
email,
classification: {
category: ruleBasedCategory(email),
confidence: 0,
reason: 'Rate limit — rule-based fallback',
assignedTo: null,
},
}))
}
if (attempt === 0) {
log.warn('Mistral batch rate limit (429), retry in 2s', { attempt: attempt + 1 })
await sleep(2000)
} else {
log.warn('Mistral batch rate limit (429), retry in 5s', { attempt: attempt + 1 })
await sleep(5000)
}
attempt++
}
}
return emails.map((email) => ({
email,
classification: {
category: ruleBasedCategory(email),
confidence: 0,
reason: 'Rate limit — rule-based fallback',
assignedTo: null,
},
}))
}
/**
* Fallback to individual categorization
*/
async _fallbackBatch(emails, preferences) {
const results = []
for (const email of emails) {
const classification = await this.categorize(email, preferences)
results.push({ email, classification })
// Rate limiting pause
await new Promise(r => setTimeout(r, 100))
}
return results
}
/**
* Build preference context for prompt
*/
_buildPreferenceContext(preferences) {
const parts = []
// Get enabled categories
const enabledCategories = this.getEnabledCategories(preferences)
if (enabledCategories.length < Object.keys(CATEGORIES).length) {
const disabled = Object.keys(CATEGORIES).filter(k => !enabledCategories.includes(k))
parts.push(`DISABLED CATEGORIES (do not use): ${disabled.map(k => CATEGORIES[k].name).join(', ')}`)
parts.push(`ONLY USE THESE CATEGORIES: ${enabledCategories.map(k => `${k} (${CATEGORIES[k].name})`).join(', ')}`)
}
if (preferences.vipSenders?.length) {
parts.push(`VIP Senders (always categorize as "vip"): ${preferences.vipSenders.join(', ')}`)
}
if (preferences.blockedSenders?.length) {
parts.push(`Blocked Senders (categorize as "promotions"): ${preferences.blockedSenders.join(', ')}`)
}
if (preferences.customRules?.length) {
parts.push(`Custom Rules:\n${preferences.customRules.map(r => `- ${r.condition}: ${r.category}`).join('\n')}`)
}
if (preferences.priorityTopics?.length) {
parts.push(`Priority Topics (higher priority): ${preferences.priorityTopics.join(', ')}`)
}
// Company labels context
if (preferences.companyLabels?.length) {
const activeLabels = preferences.companyLabels.filter(l => l.enabled)
if (activeLabels.length > 0) {
parts.push(`Company Labels (apply these labels when conditions match):\n${activeLabels.map(l => `- ${l.name}: ${l.condition}${l.category || 'promotions'}`).join('\n')}`)
}
}
// Name labels (workers) assign email to a person when clearly for them
if (preferences.nameLabels?.length) {
const activeNameLabels = preferences.nameLabels.filter(l => l.enabled)
if (activeNameLabels.length > 0) {
parts.push(`NAME LABELS (workers) assign email to ONE person when the email is clearly FOR that person (e.g. "für Max", "an Anna", "Max bitte prüfen", subject/body mentions them):\n${activeNameLabels.map(l => `- id: "${l.id}", name: "${l.name}"${l.keywords?.length ? `, keywords: ${JSON.stringify(l.keywords)}` : ''}`).join('\n')}\nIf the email is for a specific worker, set "assignedTo" to that label's id or name. Otherwise omit assignedTo.`)
}
}
return parts.length > 0 ? `USER PREFERENCES:\n${parts.join('\n')}\n` : ''
}
/**
* Learn from user corrections
*/
async learnFromCorrection(email, originalCategory, correctedCategory, userId) {
log.info('Learning correction received', {
from: email.from,
original: originalCategory,
corrected: correctedCategory,
userId,
})
// In production, this would:
// 1. Store correction in database
// 2. Update user preferences
// 3. Potentially fine-tune the model
return { learned: true }
}
/**
* Get category statistics
*/
getCategoryStats(classifications) {
const stats = {}
for (const { classification } of classifications) {
const cat = classification.category
stats[cat] = (stats[cat] || 0) + 1
}
return stats
}
}
// Default export
export default AISorterService