Feature: Add LlamaCpp provider support to TransactionAICategorizer
Enhance AI categorization with multi-provider support: - Add configurable AI:CategorizationProvider setting (OpenAI/LlamaCpp) - Add CallLlamaCppAsync() for local LLM categorization - Improve prompt with existing categories for consistency - Include additional transaction context (card, account, transfer info) - Fix batch processing to avoid DbContext concurrency issues - Add model parameter to interface methods Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,8 +9,8 @@ namespace MoneyMap.Services;
|
||||
|
||||
public interface ITransactionAICategorizer
|
||||
{
|
||||
Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction);
|
||||
Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions);
|
||||
Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null);
|
||||
Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null);
|
||||
Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true);
|
||||
}
|
||||
|
||||
@@ -19,30 +19,45 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly MoneyMapContext _db;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly LlamaCppVisionClient _llamaClient;
|
||||
private readonly ILogger<TransactionAICategorizer> _logger;
|
||||
|
||||
public TransactionAICategorizer(
|
||||
HttpClient httpClient,
|
||||
MoneyMapContext db,
|
||||
IConfiguration config,
|
||||
LlamaCppVisionClient llamaClient,
|
||||
ILogger<TransactionAICategorizer> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_db = db;
|
||||
_config = config;
|
||||
_llamaClient = llamaClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction)
|
||||
public async Task<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction, string? model = null)
|
||||
{
|
||||
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var provider = _config["AI:CategorizationProvider"] ?? "OpenAI";
|
||||
var prompt = await BuildPromptAsync(transaction);
|
||||
|
||||
var prompt = BuildPrompt(transaction);
|
||||
var response = await CallOpenAIAsync(apiKey, prompt);
|
||||
AICategorizationResponse? response;
|
||||
|
||||
if (provider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model ?? "default");
|
||||
response = await CallLlamaCppAsync(prompt, model);
|
||||
}
|
||||
else
|
||||
{
|
||||
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogWarning("OpenAI API key not configured");
|
||||
return null;
|
||||
}
|
||||
response = await CallOpenAIAsync(apiKey, prompt);
|
||||
}
|
||||
|
||||
if (response == null)
|
||||
return null;
|
||||
@@ -60,23 +75,70 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions)
|
||||
public async Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions, string? model = null)
|
||||
{
|
||||
var proposals = new List<AICategoryProposal>();
|
||||
|
||||
// Process in batches of 5 to avoid rate limits
|
||||
var batches = transactions.Chunk(5);
|
||||
// Pre-fetch existing categories once to avoid concurrent DbContext access
|
||||
var existingCategories = await _db.CategoryMappings
|
||||
.Select(m => m.Category)
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var batch in batches)
|
||||
// Process transactions sequentially to avoid DbContext concurrency issues
|
||||
foreach (var transaction in transactions)
|
||||
{
|
||||
var tasks = batch.Select(t => ProposeCategorizationAsync(t));
|
||||
var results = await Task.WhenAll(tasks);
|
||||
proposals.AddRange(results.Where(r => r != null)!);
|
||||
var result = await ProposeCategorizationWithCategoriesAsync(transaction, existingCategories, model);
|
||||
if (result != null)
|
||||
proposals.Add(result);
|
||||
}
|
||||
|
||||
return proposals;
|
||||
}
|
||||
|
||||
private async Task<AICategoryProposal?> ProposeCategorizationWithCategoriesAsync(
|
||||
Transaction transaction,
|
||||
List<string> existingCategories,
|
||||
string? model = null)
|
||||
{
|
||||
var provider = _config["AI:CategorizationProvider"] ?? "OpenAI";
|
||||
var prompt = BuildPromptWithCategories(transaction, existingCategories);
|
||||
|
||||
AICategorizationResponse? response;
|
||||
|
||||
if (provider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation("Using LlamaCpp for transaction categorization with model {Model}", model ?? "default");
|
||||
response = await CallLlamaCppAsync(prompt, model);
|
||||
}
|
||||
else
|
||||
{
|
||||
var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY");
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
_logger.LogWarning("OpenAI API key not configured");
|
||||
return null;
|
||||
}
|
||||
response = await CallOpenAIAsync(apiKey, prompt);
|
||||
}
|
||||
|
||||
if (response == null)
|
||||
return null;
|
||||
|
||||
return new AICategoryProposal
|
||||
{
|
||||
TransactionId = transaction.Id,
|
||||
Category = response.Category ?? "",
|
||||
CanonicalMerchant = response.CanonicalMerchant,
|
||||
Pattern = response.Pattern,
|
||||
Priority = response.Priority,
|
||||
Confidence = response.Confidence,
|
||||
Reasoning = response.Reasoning,
|
||||
CreateRule = response.Confidence >= 0.7m
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApplyProposalResult> ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true)
|
||||
{
|
||||
var transaction = await _db.Transactions.FindAsync(transactionId);
|
||||
@@ -132,40 +194,74 @@ public class TransactionAICategorizer : ITransactionAICategorizer
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildPrompt(Transaction transaction)
|
||||
private async Task<string> BuildPromptAsync(Transaction transaction)
|
||||
{
|
||||
return $@"Analyze this financial transaction and suggest a category and merchant name.
|
||||
// Get existing categories from database for better suggestions
|
||||
var existingCategories = await _db.CategoryMappings
|
||||
.Select(m => m.Category)
|
||||
.Distinct()
|
||||
.OrderBy(c => c)
|
||||
.ToListAsync();
|
||||
|
||||
Transaction Details:
|
||||
- Name: ""{transaction.Name}""
|
||||
- Memo: ""{transaction.Memo}""
|
||||
- Amount: {transaction.Amount:C}
|
||||
- Date: {transaction.Date:yyyy-MM-dd}
|
||||
|
||||
Provide your analysis in JSON format:
|
||||
{{
|
||||
""category"": ""Category name (e.g., Restaurants, Groceries, Gas & Auto)"",
|
||||
""canonical_merchant"": ""Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')"",
|
||||
""pattern"": ""Pattern to match (e.g., 'WALMART' or 'SUBWAY')"",
|
||||
""priority"": 0,
|
||||
""confidence"": 0.95,
|
||||
""reasoning"": ""Brief explanation""
|
||||
}}
|
||||
|
||||
Common categories:
|
||||
- Restaurants, Fast Food, Coffee Shop
|
||||
- Groceries, Convenience Store
|
||||
- Gas & Auto, Automotive
|
||||
- Online shopping, Brick/mortar store
|
||||
- Health, Pharmacy
|
||||
- Entertainment, Streaming
|
||||
- Utilities, Banking, Insurance
|
||||
- Home Improvement, School
|
||||
|
||||
Return ONLY valid JSON, no additional text.";
|
||||
return BuildPromptWithCategories(transaction, existingCategories);
|
||||
}
|
||||
|
||||
private async Task<OpenAIResponse?> CallOpenAIAsync(string apiKey, string prompt)
|
||||
private string BuildPromptWithCategories(Transaction transaction, List<string> existingCategories)
|
||||
{
|
||||
var categoryList = existingCategories.Any()
|
||||
? string.Join(", ", existingCategories)
|
||||
: "Restaurants, Fast Food, Coffee Shop, Groceries, Convenience Store, Gas & Auto, Online shopping, Health, Entertainment, Utilities, Banking, Insurance";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Analyze this financial transaction and suggest a category and merchant name.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Transaction Details:");
|
||||
sb.AppendLine($"- Name: \"{transaction.Name}\"");
|
||||
sb.AppendLine($"- Memo: \"{transaction.Memo}\"");
|
||||
sb.AppendLine($"- Amount: {transaction.Amount:C}");
|
||||
sb.AppendLine($"- Date: {transaction.Date:yyyy-MM-dd}");
|
||||
sb.AppendLine($"- Type: {(transaction.IsCredit ? "Credit/Income" : "Debit/Expense")}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(transaction.Category))
|
||||
sb.AppendLine($"- Current Category: \"{transaction.Category}\"");
|
||||
|
||||
if (transaction.Merchant != null)
|
||||
sb.AppendLine($"- Current Merchant: \"{transaction.Merchant.Name}\"");
|
||||
|
||||
if (transaction.Card != null)
|
||||
sb.AppendLine($"- Card: {transaction.Card.Owner} - ****{transaction.Card.Last4}");
|
||||
|
||||
if (transaction.Account != null)
|
||||
sb.AppendLine($"- Account: {transaction.Account.DisplayLabel}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(transaction.Notes))
|
||||
sb.AppendLine($"- Notes: \"{transaction.Notes}\"");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(transaction.Last4))
|
||||
sb.AppendLine($"- Last 4 digits: {transaction.Last4}");
|
||||
|
||||
if (transaction.IsTransfer)
|
||||
sb.AppendLine($"- Transfer to: {transaction.TransferToAccount?.DisplayLabel ?? "Unknown"}");
|
||||
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Provide your analysis in JSON format:");
|
||||
sb.AppendLine("{");
|
||||
sb.AppendLine(" \"category\": \"Category name\",");
|
||||
sb.AppendLine(" \"canonical_merchant\": \"Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')\",");
|
||||
sb.AppendLine(" \"pattern\": \"Pattern to match future transactions (e.g., 'WALMART' or 'SUBWAY')\",");
|
||||
sb.AppendLine(" \"priority\": 0,");
|
||||
sb.AppendLine(" \"confidence\": 0.95,");
|
||||
sb.AppendLine(" \"reasoning\": \"Brief explanation\"");
|
||||
sb.AppendLine("}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Existing categories in this system: {categoryList}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Prefer using existing categories when appropriate. Return ONLY valid JSON, no additional text.");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private async Task<AICategorizationResponse?> CallOpenAIAsync(string apiKey, string prompt)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -203,13 +299,10 @@ Return ONLY valid JSON, no additional text.";
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return null;
|
||||
|
||||
// Parse the JSON response from the AI
|
||||
var result = JsonSerializer.Deserialize<OpenAIResponse>(content, new JsonSerializerOptions
|
||||
return JsonSerializer.Deserialize<AICategorizationResponse>(content, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
@@ -228,6 +321,39 @@ Return ONLY valid JSON, no additional text.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AICategorizationResponse?> CallLlamaCppAsync(string prompt, string? model = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var selectedModel = model ?? _config["AI:CategorizationModel"] ?? "qwen2.5-coder-32b-instruct-q6_k";
|
||||
var systemPrompt = "You are a financial transaction categorization expert. Always respond with valid JSON only.";
|
||||
var fullPrompt = $"{systemPrompt}\n\n{prompt}";
|
||||
|
||||
var result = await _llamaClient.SendTextPromptAsync(fullPrompt, selectedModel);
|
||||
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
_logger.LogWarning("LlamaCpp categorization failed: {Error}", result.ErrorMessage);
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<AICategorizationResponse>(result.Content ?? "", new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse LlamaCpp response JSON: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Unexpected error calling LlamaCpp: {Message}", ex.Message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI API response models
|
||||
private class OpenAIChatResponse
|
||||
{
|
||||
@@ -247,7 +373,7 @@ Return ONLY valid JSON, no additional text.";
|
||||
public string? Content { get; set; }
|
||||
}
|
||||
|
||||
private class OpenAIResponse
|
||||
private class AICategorizationResponse
|
||||
{
|
||||
[JsonPropertyName("category")]
|
||||
public string? Category { get; set; }
|
||||
|
||||
Reference in New Issue
Block a user