From 5723ac26daf63f309d2e4e9d18e5bb7bc5940680 Mon Sep 17 00:00:00 2001 From: AJ Date: Sun, 12 Oct 2025 10:47:31 -0400 Subject: [PATCH] Implement Phase 1: AI-powered categorization with manual review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AI categorization service that suggests categories, merchants, and rules for uncategorized transactions. Users can review and approve suggestions before applying them. Features: - TransactionAICategorizer service using OpenAI GPT-4o-mini - Batch processing (5 transactions at a time) to avoid rate limits - Confidence scoring (0-100%) for each suggestion - AI suggests category, canonical merchant name, and pattern rule - ReviewAISuggestions page to list uncategorized transactions - ReviewAISuggestionsWithProposals page for manual review - Apply individual suggestions or bulk apply high confidence (≥80%) - Optional rule creation for future auto-categorization - Cost: ~$0.00015 per transaction (~$0.015 per 100) CategoryMapping enhancements: - Confidence field to track AI confidence score - CreatedBy field ("AI" or "User") to track rule origin - CreatedAt timestamp for audit trail Updated ARCHITECTURE.md with complete documentation of: - TransactionAICategorizer service details - ReviewAISuggestions page descriptions - AI categorization workflow (Phase 1) - Updated CategoryMappings schema Next steps (Phase 2): - Auto-apply high confidence suggestions - Background job processing - Batch API requests for better efficiency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ARCHITECTURE.md | 120 ++++++++ MoneyMap/Pages/ReviewAISuggestions.cshtml | 121 ++++++++ MoneyMap/Pages/ReviewAISuggestions.cshtml.cs | 114 ++++++++ .../ReviewAISuggestionsWithProposals.cshtml | 162 +++++++++++ ...ReviewAISuggestionsWithProposals.cshtml.cs | 157 ++++++++++ MoneyMap/Program.cs | 3 + MoneyMap/Services/TransactionAICategorizer.cs | 273 ++++++++++++++++++ MoneyMap/Services/TransactionCategorizer.cs | 5 + 8 files changed, 955 insertions(+) create mode 100644 MoneyMap/Pages/ReviewAISuggestions.cshtml create mode 100644 MoneyMap/Pages/ReviewAISuggestions.cshtml.cs create mode 100644 MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml create mode 100644 MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml.cs create mode 100644 MoneyMap/Services/TransactionAICategorizer.cs diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a6d08a9..1004a99 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -347,6 +347,47 @@ Pattern-based rules for auto-categorization with merchant linking. **Location:** Services/OpenAIReceiptParser.cs:23-302 +### TransactionAICategorizer (Services/TransactionAICategorizer.cs) +**Interface:** `ITransactionAICategorizer` + +**Responsibility:** AI-powered categorization for uncategorized transactions. + +**Key Methods:** +- `ProposeCategorizationAsync(Transaction transaction)` + - Analyzes transaction details (name, memo, amount, date) + - Calls OpenAI GPT-4o-mini with categorization prompt + - Returns `AICategoryProposal` with category, merchant, pattern, and confidence + - Auto-suggests rule creation for high confidence (≥70%) + +- `ProposeBatchCategorizationAsync(List transactions)` + - Processes transactions in batches of 5 to avoid rate limits + - Returns list of proposals for review + +- `ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule)` + - Updates transaction category and merchant + - Optionally creates CategoryMapping rule for future auto-categorization + - Returns `ApplyProposalResult` with success status + +**API Configuration:** +- Model: `gpt-4o-mini` +- Temperature: 0.1 (deterministic) +- Max tokens: 300 +- API key: Environment variable `OPENAI_API_KEY` or config `OpenAI:ApiKey` +- Cost: ~$0.00015 per transaction (~$0.015 per 100 transactions) + +**Prompt Strategy:** +- Provides transaction details (name, memo, amount, date) +- Requests JSON response with category, canonical_merchant, pattern, confidence, reasoning +- Includes common category examples for context +- High confidence threshold (≥70%) suggests automatic rule creation + +**CategoryMapping Enhancements:** +- `Confidence` (decimal?) - AI confidence score (0.0-1.0) +- `CreatedBy` (string?) - "AI" or "User" +- `CreatedAt` (DateTime?) - Rule creation timestamp + +**Location:** Services/TransactionAICategorizer.cs + ## Data Access Layer ### MoneyMapContext (Data/MoneyMapContext.cs) @@ -505,6 +546,38 @@ EF Core DbContext managing all database entities. **Location:** Pages/Recategorize.cshtml.cs:16-87 +### ReviewAISuggestions.cshtml / ReviewAISuggestionsModel +**Route:** `/ReviewAISuggestions` + +**Purpose:** AI-powered categorization suggestions for uncategorized transactions. + +**Features:** +- Lists up to 50 most recent uncategorized transactions +- Generate AI suggestions button (processes up to 20 at a time) +- Cost transparency (~$0.00015 per transaction) +- Link to view uncategorized transactions + +**Dependencies:** `ITransactionAICategorizer` + +**Location:** Pages/ReviewAISuggestions.cshtml.cs + +### ReviewAISuggestionsWithProposals.cshtml / ReviewAISuggestionsWithProposalsModel +**Route:** `/ReviewAISuggestionsWithProposals` + +**Purpose:** Review and apply AI categorization proposals. + +**Features:** +- Display AI proposals with confidence scores +- Color-coded confidence indicators (green ≥80%, yellow 60-79%, red <60%) +- Individual actions: Accept (with/without rule), Reject, Edit Manually +- Bulk action: Apply all high-confidence suggestions (≥80%) +- Shows AI reasoning for each suggestion +- Stores proposals in session for review workflow + +**Dependencies:** `ITransactionAICategorizer` + +**Location:** Pages/ReviewAISuggestionsWithProposals.cshtml.cs + ## Configuration ### appsettings.json @@ -648,6 +721,9 @@ Category (nvarchar(max), NOT NULL) Pattern (nvarchar(max), NOT NULL) MerchantId (int, FK → Merchants.Id, SET NULL) Priority (int, NOT NULL, DEFAULT 0) +Confidence (decimal(18,2), NULL) -- AI confidence score +CreatedBy (nvarchar(max), NULL) -- "AI" or "User" +CreatedAt (datetime2, NULL) -- Rule creation timestamp ``` ## Key Workflows @@ -740,6 +816,50 @@ Return DashboardData DTO Render dashboard view ``` +### 5. AI-Powered Categorization (Phase 1 - Manual Review) + +``` +User visits /ReviewAISuggestions + ↓ +ReviewAISuggestionsModel.OnGetAsync() + - Loads up to 50 recent uncategorized transactions + ↓ +User clicks "Generate AI Suggestions" + ↓ +ReviewAISuggestionsModel.OnPostGenerateSuggestionsAsync() + - Fetches up to 20 uncategorized transactions + - Calls TransactionAICategorizer.ProposeBatchCategorizationAsync() + ↓ +For each transaction (batches of 5): + TransactionAICategorizer.ProposeCategorizationAsync() + - Builds prompt with transaction details + - Calls OpenAI GPT-4o-mini API + - Parses JSON response + - Returns AICategoryProposal + ↓ +Store proposals in session +Redirect to /ReviewAISuggestionsWithProposals + ↓ +User reviews proposals with confidence scores + ↓ +User actions: + Option A: Apply + Create Rule + - Updates transaction category and merchant + - Creates CategoryMapping rule (CreatedBy="AI") + - Future similar transactions auto-categorized + Option B: Apply (No Rule) + - Updates transaction only + - No rule created + Option C: Reject + - Removes proposal from session + Option D: Edit Manually + - Redirects to EditTransaction page + ↓ +Proposal applied +Remove from session +Display success message +``` + ## Design Patterns ### 1. Service Layer Pattern diff --git a/MoneyMap/Pages/ReviewAISuggestions.cshtml b/MoneyMap/Pages/ReviewAISuggestions.cshtml new file mode 100644 index 0000000..1ef7053 --- /dev/null +++ b/MoneyMap/Pages/ReviewAISuggestions.cshtml @@ -0,0 +1,121 @@ +@page +@model MoneyMap.Pages.ReviewAISuggestionsModel +@{ + ViewData["Title"] = "AI Categorization Suggestions"; +} + +
+

AI Categorization Suggestions

+ +
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+
How AI Categorization Works
+

+ This tool uses AI to analyze your uncategorized transactions and suggest: +

+
    +
  • Category - The most appropriate expense category
  • +
  • Merchant Name - A normalized merchant name (e.g., "Walmart" from "WAL-MART #1234")
  • +
  • Pattern Rule - An optional rule to auto-categorize similar transactions in the future
  • +
+

+ Cost: Approximately $0.00015 per transaction (~1.5 cents per 100 transactions) +

+
+
+ +
+
+ Uncategorized Transactions (@Model.TotalUncategorized) +
+ +
+
+
+ @if (!Model.Transactions.Any()) + { +

No uncategorized transactions found. Great job!

+ } + else + { +

+ Showing the @Model.Transactions.Count most recent uncategorized transactions. + Click "Generate AI Suggestions" to analyze up to 20 transactions. +

+ +
+ + + + + + + + + + + + @foreach (var item in Model.Transactions) + { + + + + + + + + } + +
DateNameMemoAmountActions
@item.Transaction.Date.ToString("yyyy-MM-dd")@item.Transaction.Name@item.Transaction.Memo + + @item.Transaction.Amount.ToString("C") + + + + Edit + +
+
+ } +
+
+ +
+
+ Quick Tips +
+
+
    +
  • AI suggestions are based on transaction name, memo, amount, and date
  • +
  • You can accept, reject, or modify each suggestion
  • +
  • Creating rules helps auto-categorize future transactions
  • +
  • High confidence suggestions (>80%) are more reliable
  • +
  • You can manually edit any transaction from the Transactions page
  • +
+
+
diff --git a/MoneyMap/Pages/ReviewAISuggestions.cshtml.cs b/MoneyMap/Pages/ReviewAISuggestions.cshtml.cs new file mode 100644 index 0000000..dab80c8 --- /dev/null +++ b/MoneyMap/Pages/ReviewAISuggestions.cshtml.cs @@ -0,0 +1,114 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Services; + +namespace MoneyMap.Pages; + +public class ReviewAISuggestionsModel : PageModel +{ + private readonly MoneyMapContext _db; + private readonly ITransactionAICategorizer _aiCategorizer; + + public ReviewAISuggestionsModel(MoneyMapContext db, ITransactionAICategorizer aiCategorizer) + { + _db = db; + _aiCategorizer = aiCategorizer; + } + + public List Transactions { get; set; } = new(); + public bool IsGenerating { get; set; } + public int TotalUncategorized { get; set; } + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + // Get uncategorized transactions + var uncategorized = await _db.Transactions + .Include(t => t.Merchant) + .Where(t => string.IsNullOrEmpty(t.Category)) + .OrderByDescending(t => t.Date) + .Take(50) // Limit to 50 most recent + .ToListAsync(); + + TotalUncategorized = uncategorized.Count; + Transactions = uncategorized.Select(t => new TransactionWithProposal + { + Transaction = t, + Proposal = null // Will be populated via AJAX or on generate + }).ToList(); + } + + public async Task OnPostGenerateSuggestionsAsync() + { + // Get uncategorized transactions + var uncategorized = await _db.Transactions + .Where(t => string.IsNullOrEmpty(t.Category)) + .OrderByDescending(t => t.Date) + .Take(20) // Limit to 20 for cost control + .ToListAsync(); + + if (!uncategorized.Any()) + { + ErrorMessage = "No uncategorized transactions found."; + return RedirectToPage(); + } + + // Generate proposals + var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(uncategorized); + + // Store proposals in session for review + HttpContext.Session.SetString("AIProposals", System.Text.Json.JsonSerializer.Serialize(proposals)); + + SuccessMessage = $"Generated {proposals.Count} AI suggestions. Review them below."; + return RedirectToPage("ReviewAISuggestionsWithProposals"); + } + + public async Task OnPostApplyProposalAsync(long transactionId, string category, string? merchant, string? pattern, decimal confidence, bool createRule) + { + var proposal = new AICategoryProposal + { + TransactionId = transactionId, + Category = category, + CanonicalMerchant = merchant, + Pattern = pattern, + Confidence = confidence, + CreateRule = createRule + }; + + var result = await _aiCategorizer.ApplyProposalAsync(transactionId, proposal, createRule); + + if (result.Success) + { + SuccessMessage = result.RuleCreated + ? "Transaction categorized and rule created!" + : "Transaction categorized!"; + } + else + { + ErrorMessage = result.ErrorMessage ?? "Failed to apply suggestion."; + } + + return RedirectToPage(); + } + + public async Task OnPostRejectProposalAsync(long transactionId) + { + // Just refresh the page, removing this transaction from view + SuccessMessage = "Suggestion rejected."; + return RedirectToPage(); + } + + public class TransactionWithProposal + { + public Transaction Transaction { get; set; } = null!; + public AICategoryProposal? Proposal { get; set; } + } +} diff --git a/MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml b/MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml new file mode 100644 index 0000000..7e153da --- /dev/null +++ b/MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml @@ -0,0 +1,162 @@ +@page +@model MoneyMap.Pages.ReviewAISuggestionsWithProposalsModel +@{ + ViewData["Title"] = "Review AI Suggestions"; +} + +
+

Review AI Suggestions

+
+
+ +
+ Back +
+
+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +@if (!Model.Proposals.Any()) +{ +
+
No suggestions remaining
+

+ All AI suggestions have been processed. + Generate more suggestions +

+
+} +else +{ +
+ Review each suggestion below. You can accept the AI's proposal, reject it, or modify it before applying. + High confidence suggestions (≥80%) are generally very reliable. +
+ + @foreach (var item in Model.Proposals) + { + var confidenceClass = item.Proposal.Confidence >= 0.8m ? "success" : + item.Proposal.Confidence >= 0.6m ? "warning" : "danger"; + var confidencePercent = (item.Proposal.Confidence * 100).ToString("F0"); + +
+
+
+ @item.Transaction.Name + @item.Transaction.Date.ToString("yyyy-MM-dd") + @confidencePercent% Confidence +
+ + @item.Transaction.Amount.ToString("C") + +
+
+
+
+
Transaction Details
+
+
Memo:
+
@item.Transaction.Memo
+ +
Amount:
+
@item.Transaction.Amount.ToString("C")
+
+
+
+
AI Suggestion
+
+
Category:
+
@item.Proposal.Category
+ + @if (!string.IsNullOrWhiteSpace(item.Proposal.CanonicalMerchant)) + { +
Merchant:
+
@item.Proposal.CanonicalMerchant
+ } + + @if (!string.IsNullOrWhiteSpace(item.Proposal.Pattern)) + { +
Pattern:
+
@item.Proposal.Pattern
+ } + + @if (!string.IsNullOrWhiteSpace(item.Proposal.Reasoning)) + { +
Reasoning:
+
@item.Proposal.Reasoning
+ } +
+
+
+ +
+ +
+
+ + +
+ +
+ + + + + + + +
+ +
+ + + + + + + +
+ + + Edit Manually + +
+
+
+ } +} + +
+
+ Understanding Confidence Scores +
+
+
    +
  • ≥80% - High confidence, very reliable
  • +
  • 60-79% - Medium confidence, review recommended
  • +
  • <60% - Low confidence, manual review strongly recommended
  • +
+
+
diff --git a/MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml.cs b/MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml.cs new file mode 100644 index 0000000..8d380b4 --- /dev/null +++ b/MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml.cs @@ -0,0 +1,157 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; +using MoneyMap.Services; +using System.Text.Json; + +namespace MoneyMap.Pages; + +public class ReviewAISuggestionsWithProposalsModel : PageModel +{ + private readonly MoneyMapContext _db; + private readonly ITransactionAICategorizer _aiCategorizer; + + public ReviewAISuggestionsWithProposalsModel(MoneyMapContext db, ITransactionAICategorizer aiCategorizer) + { + _db = db; + _aiCategorizer = aiCategorizer; + } + + public List Proposals { get; set; } = new(); + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + // Load proposals from session + var proposalsJson = HttpContext.Session.GetString("AIProposals"); + if (string.IsNullOrWhiteSpace(proposalsJson)) + { + ErrorMessage = "No AI suggestions found. Please generate suggestions first."; + return RedirectToPage("ReviewAISuggestions"); + } + + var proposals = JsonSerializer.Deserialize>(proposalsJson); + if (proposals == null || !proposals.Any()) + { + ErrorMessage = "Failed to load AI suggestions."; + return RedirectToPage("ReviewAISuggestions"); + } + + // Load transactions for these proposals + var transactionIds = proposals.Select(p => p.TransactionId).ToList(); + var transactions = await _db.Transactions + .Include(t => t.Merchant) + .Where(t => transactionIds.Contains(t.Id)) + .ToListAsync(); + + Proposals = proposals.Select(p => new TransactionWithProposal + { + Transaction = transactions.FirstOrDefault(t => t.Id == p.TransactionId)!, + Proposal = p + }).Where(x => x.Transaction != null).ToList(); + + return Page(); + } + + public async Task OnPostApplyProposalAsync(long transactionId, string category, string? merchant, string? pattern, decimal confidence, bool createRule) + { + var proposal = new AICategoryProposal + { + TransactionId = transactionId, + Category = category, + CanonicalMerchant = merchant, + Pattern = pattern, + Confidence = confidence, + CreateRule = createRule + }; + + var result = await _aiCategorizer.ApplyProposalAsync(transactionId, proposal, createRule); + + if (result.Success) + { + // Remove this proposal from session + var proposalsJson = HttpContext.Session.GetString("AIProposals"); + if (!string.IsNullOrWhiteSpace(proposalsJson)) + { + var proposals = JsonSerializer.Deserialize>(proposalsJson); + if (proposals != null) + { + proposals.RemoveAll(p => p.TransactionId == transactionId); + HttpContext.Session.SetString("AIProposals", JsonSerializer.Serialize(proposals)); + } + } + + SuccessMessage = result.RuleCreated + ? "Transaction categorized and rule created!" + : "Transaction categorized!"; + } + else + { + ErrorMessage = result.ErrorMessage ?? "Failed to apply suggestion."; + } + + return RedirectToPage(); + } + + public async Task OnPostRejectProposalAsync(long transactionId) + { + // Remove this proposal from session + var proposalsJson = HttpContext.Session.GetString("AIProposals"); + if (!string.IsNullOrWhiteSpace(proposalsJson)) + { + var proposals = JsonSerializer.Deserialize>(proposalsJson); + if (proposals != null) + { + proposals.RemoveAll(p => p.TransactionId == transactionId); + HttpContext.Session.SetString("AIProposals", JsonSerializer.Serialize(proposals)); + } + } + + SuccessMessage = "Suggestion rejected."; + return RedirectToPage(); + } + + public async Task OnPostApplyAllAsync() + { + var proposalsJson = HttpContext.Session.GetString("AIProposals"); + if (string.IsNullOrWhiteSpace(proposalsJson)) + { + ErrorMessage = "No AI suggestions found."; + return RedirectToPage("ReviewAISuggestions"); + } + + var proposals = JsonSerializer.Deserialize>(proposalsJson); + if (proposals == null || !proposals.Any()) + { + ErrorMessage = "Failed to load AI suggestions."; + return RedirectToPage("ReviewAISuggestions"); + } + + int applied = 0; + foreach (var proposal in proposals.Where(p => p.Confidence >= 0.8m)) + { + var result = await _aiCategorizer.ApplyProposalAsync(proposal.TransactionId, proposal, proposal.CreateRule); + if (result.Success) + applied++; + } + + // Clear session + HttpContext.Session.Remove("AIProposals"); + + SuccessMessage = $"Applied {applied} high-confidence suggestions (≥80%)."; + return RedirectToPage("ReviewAISuggestions"); + } + + public class TransactionWithProposal + { + public Transaction Transaction { get; set; } = null!; + public AICategoryProposal Proposal { get; set; } = null!; + } +} diff --git a/MoneyMap/Program.cs b/MoneyMap/Program.cs index 5363704..c2802d1 100644 --- a/MoneyMap/Program.cs +++ b/MoneyMap/Program.cs @@ -34,6 +34,9 @@ builder.Services.AddScoped(); builder.Services.AddHttpClient(); +// AI categorization service +builder.Services.AddHttpClient(); + var app = builder.Build(); // Seed default category mappings on startup diff --git a/MoneyMap/Services/TransactionAICategorizer.cs b/MoneyMap/Services/TransactionAICategorizer.cs new file mode 100644 index 0000000..59dce8b --- /dev/null +++ b/MoneyMap/Services/TransactionAICategorizer.cs @@ -0,0 +1,273 @@ +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.EntityFrameworkCore; +using MoneyMap.Data; +using MoneyMap.Models; + +namespace MoneyMap.Services; + +public interface ITransactionAICategorizer +{ + Task ProposeCategorizationAsync(Transaction transaction); + Task> ProposeBatchCategorizationAsync(List transactions); + Task ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true); +} + +public class TransactionAICategorizer : ITransactionAICategorizer +{ + private readonly HttpClient _httpClient; + private readonly MoneyMapContext _db; + private readonly IConfiguration _config; + + public TransactionAICategorizer(HttpClient httpClient, MoneyMapContext db, IConfiguration config) + { + _httpClient = httpClient; + _db = db; + _config = config; + } + + public async Task ProposeCategorizationAsync(Transaction transaction) + { + var apiKey = _config["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY"); + if (string.IsNullOrWhiteSpace(apiKey)) + { + return null; + } + + var prompt = BuildPrompt(transaction); + var 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 // High confidence = auto-create rule + }; + } + + public async Task> ProposeBatchCategorizationAsync(List transactions) + { + var proposals = new List(); + + // Process in batches of 5 to avoid rate limits + var batches = transactions.Chunk(5); + + foreach (var batch in batches) + { + var tasks = batch.Select(t => ProposeCategorizationAsync(t)); + var results = await Task.WhenAll(tasks); + proposals.AddRange(results.Where(r => r != null)!); + } + + return proposals; + } + + public async Task ApplyProposalAsync(long transactionId, AICategoryProposal proposal, bool createRule = true) + { + var transaction = await _db.Transactions.FindAsync(transactionId); + if (transaction == null) + return new ApplyProposalResult { Success = false, ErrorMessage = "Transaction not found" }; + + // Update transaction category + transaction.Category = proposal.Category; + + // Handle merchant + if (!string.IsNullOrWhiteSpace(proposal.CanonicalMerchant)) + { + var merchant = await _db.Merchants.FirstOrDefaultAsync(m => m.Name == proposal.CanonicalMerchant); + if (merchant == null) + { + merchant = new Merchant { Name = proposal.CanonicalMerchant }; + _db.Merchants.Add(merchant); + await _db.SaveChangesAsync(); + } + transaction.MerchantId = merchant.Id; + } + + // Create category mapping rule if requested + if (createRule && !string.IsNullOrWhiteSpace(proposal.Pattern)) + { + // Check if rule already exists + var existingRule = await _db.CategoryMappings + .FirstOrDefaultAsync(m => m.Pattern == proposal.Pattern); + + if (existingRule == null) + { + var merchantId = transaction.MerchantId; + var newMapping = new CategoryMapping + { + Category = proposal.Category, + Pattern = proposal.Pattern, + MerchantId = merchantId, + Priority = proposal.Priority, + Confidence = proposal.Confidence, + CreatedBy = "AI", + CreatedAt = DateTime.UtcNow + }; + _db.CategoryMappings.Add(newMapping); + } + } + + await _db.SaveChangesAsync(); + + return new ApplyProposalResult + { + Success = true, + RuleCreated = createRule && !string.IsNullOrWhiteSpace(proposal.Pattern) + }; + } + + private string BuildPrompt(Transaction transaction) + { + return $@"Analyze this financial transaction and suggest a category and merchant name. + +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."; + } + + private async Task CallOpenAIAsync(string apiKey, string prompt) + { + try + { + var requestBody = new + { + model = "gpt-4o-mini", + messages = new[] + { + new { role = "system", content = "You are a financial transaction categorization expert. Always respond with valid JSON only." }, + new { role = "user", content = prompt } + }, + temperature = 0.1, + max_tokens = 300 + }; + + var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions"); + request.Headers.Add("Authorization", $"Bearer {apiKey}"); + request.Content = new StringContent( + JsonSerializer.Serialize(requestBody), + Encoding.UTF8, + "application/json" + ); + + var response = await _httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + return null; + + var json = await response.Content.ReadAsStringAsync(); + var apiResponse = JsonSerializer.Deserialize(json); + + if (apiResponse?.Choices == null || apiResponse.Choices.Length == 0) + return null; + + var content = apiResponse.Choices[0].Message?.Content; + if (string.IsNullOrWhiteSpace(content)) + return null; + + // Parse the JSON response from the AI + var result = JsonSerializer.Deserialize(content, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + return result; + } + catch + { + return null; + } + } + + // OpenAI API response models + private class OpenAIChatResponse + { + [JsonPropertyName("choices")] + public Choice[]? Choices { get; set; } + } + + private class Choice + { + [JsonPropertyName("message")] + public Message? Message { get; set; } + } + + private class Message + { + [JsonPropertyName("content")] + public string? Content { get; set; } + } + + private class OpenAIResponse + { + [JsonPropertyName("category")] + public string? Category { get; set; } + + [JsonPropertyName("canonical_merchant")] + public string? CanonicalMerchant { get; set; } + + [JsonPropertyName("pattern")] + public string? Pattern { get; set; } + + [JsonPropertyName("priority")] + public int Priority { get; set; } + + [JsonPropertyName("confidence")] + public decimal Confidence { get; set; } + + [JsonPropertyName("reasoning")] + public string? Reasoning { get; set; } + } +} + +public class AICategoryProposal +{ + public long TransactionId { get; set; } + public string Category { get; set; } = ""; + public string? CanonicalMerchant { get; set; } + public string? Pattern { get; set; } + public int Priority { get; set; } + public decimal Confidence { get; set; } + public string? Reasoning { get; set; } + public bool CreateRule { get; set; } +} + +public class ApplyProposalResult +{ + public bool Success { get; set; } + public bool RuleCreated { get; set; } + public string? ErrorMessage { get; set; } +} diff --git a/MoneyMap/Services/TransactionCategorizer.cs b/MoneyMap/Services/TransactionCategorizer.cs index ce5a474..7fd5312 100644 --- a/MoneyMap/Services/TransactionCategorizer.cs +++ b/MoneyMap/Services/TransactionCategorizer.cs @@ -19,6 +19,11 @@ namespace MoneyMap.Services // Merchant relationship public int? MerchantId { get; set; } public Models.Merchant? Merchant { get; set; } + + // AI categorization tracking + public decimal? Confidence { get; set; } // AI confidence score (0.0 - 1.0) + public string? CreatedBy { get; set; } // "User" or "AI" + public DateTime? CreatedAt { get; set; } // When rule was created } // ===== Service Interface =====