220 lines
6.7 KiB
TypeScript
220 lines
6.7 KiB
TypeScript
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<TransactionMatch[]> {
|
|
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<YNABCategory | null> {
|
|
// 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<Map<string, YNABCategory | null>> {
|
|
const suggestions = new Map<string, YNABCategory | null>();
|
|
|
|
// 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;
|
|
}
|
|
}
|