import OpenAI from 'openai'; import type { AmazonOrder, YNABCategory, TransactionMatch } from './types'; export class CategorySuggester { private openai: OpenAI; constructor(apiKey: string) { this.openai = new OpenAI({ apiKey }); } /** * Suggest categories for transaction matches using OpenAI * @param matches - Array of transaction matches * @param categories - Available YNAB categories * @returns Updated matches with suggested categories */ async suggestCategories( matches: TransactionMatch[], categories: YNABCategory[] ): Promise { const updatedMatches: TransactionMatch[] = []; for (const match of matches) { if (!match.amazonOrder) { updatedMatches.push(match); continue; } try { const suggestedCategory = await this.suggestCategoryForOrder( match.amazonOrder, categories ); updatedMatches.push({ ...match, suggestedCategory, }); } catch (error) { console.error(`Error suggesting category for order ${match.amazonOrder.orderId}:`, error); updatedMatches.push(match); } } return updatedMatches; } /** * Suggest a category for a single Amazon order * @param order - Amazon order * @param categories - Available YNAB categories * @returns Suggested category or null */ private async suggestCategoryForOrder( order: AmazonOrder, categories: YNABCategory[] ): Promise { // Create a description of the order items const itemDescriptions = order.items .map((item) => `${item.title} (qty: ${item.quantity}, $${item.price.toFixed(2)})`) .join('\n'); // Create a list of available categories const categoryList = categories .map((cat) => `${cat.category_group_name} > ${cat.name} (ID: ${cat.id})`) .join('\n'); // Create the prompt for OpenAI const prompt = `You are a financial categorization assistant. Based on the following Amazon order items, suggest the most appropriate budget category from the available categories list. Amazon Order Items: ${itemDescriptions} Available Budget Categories: ${categoryList} Instructions: 1. Analyze the product titles to understand what type of items were purchased 2. Select the single most appropriate category from the available list 3. Respond with ONLY the category ID (nothing else) 4. If no category seems appropriate, respond with "null" Category ID:`; try { const response = await this.openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [ { role: 'system', content: 'You are a helpful assistant that categorizes purchases into budget categories. You always respond with just the category ID or "null".', }, { role: 'user', content: prompt, }, ], temperature: 0.3, max_tokens: 100, }); const categoryId = response.choices[0]?.message?.content?.trim(); if (!categoryId || categoryId === 'null') { console.warn(`Could not match "${itemDescriptions}"`) return null; } // Find and return the category const category = categories.find((cat) => cat.id === categoryId); return category || null; } catch (error) { console.error('Error calling OpenAI API:', error); return null; } } /** * Suggest a category with explanation (for debugging/testing) * @param order - Amazon order * @param categories - Available YNAB categories * @returns Suggested category with explanation */ async suggestCategoryWithExplanation( order: AmazonOrder, categories: YNABCategory[] ): Promise<{ category: YNABCategory | null; explanation: string }> { const itemDescriptions = order.items .map((item) => `${item.title} (qty: ${item.quantity}, $${item.price.toFixed(2)})`) .join('\n'); const categoryList = categories .map((cat) => `${cat.category_group_name} > ${cat.name} (ID: ${cat.id})`) .join('\n'); const prompt = `You are a financial categorization assistant. Based on the following Amazon order items, suggest the most appropriate budget category from the available categories list. Amazon Order Items: ${itemDescriptions} Available Budget Categories: ${categoryList} Instructions: 1. Analyze the product titles to understand what type of items were purchased 2. Select the single most appropriate category from the available list 3. Respond in JSON format: {"categoryId": "the-category-id", "explanation": "brief explanation of why this category fits"} 4. If no category seems appropriate, use null for categoryId Response:`; try { const response = await this.openai.chat.completions.create({ model: 'gpt-4o-mini', messages: [ { role: 'system', content: 'You are a helpful assistant that categorizes purchases into budget categories. You always respond with valid JSON.', }, { role: 'user', content: prompt, }, ], temperature: 0.3, max_tokens: 200, }); const content = response.choices[0]?.message?.content?.trim() || '{}'; const parsed = JSON.parse(content); const category = parsed.categoryId ? categories.find((cat) => cat.id === parsed.categoryId) || null : null; return { category, explanation: parsed.explanation || 'No explanation provided', }; } catch (error) { console.error('Error calling OpenAI API:', error); return { category: null, explanation: 'Error generating suggestion', }; } } /** * Batch suggest categories for multiple orders (more efficient) * @param orders - Array of Amazon orders * @param categories - Available YNAB categories * @returns Map of order ID to suggested category */ async batchSuggestCategories( orders: AmazonOrder[], categories: YNABCategory[] ): Promise> { const suggestions = new Map(); // Process in batches of 5 to avoid rate limits const batchSize = 5; for (let i = 0; i < orders.length; i += batchSize) { const batch = orders.slice(i, i + batchSize); const batchPromises = batch.map(async (order) => { const category = await this.suggestCategoryForOrder(order, categories); suggestions.set(order.orderId, category); }); await Promise.all(batchPromises); // Small delay between batches to respect rate limits if (i + batchSize < orders.length) { await new Promise((resolve) => setTimeout(resolve, 1000)); } } return suggestions; } }