Implement Phase 1: AI-powered categorization with manual review

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 <noreply@anthropic.com>
This commit is contained in:
AJ
2025-10-12 10:47:31 -04:00
parent b4358fefd3
commit 5723ac26da
8 changed files with 955 additions and 0 deletions

View File

@@ -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<Transaction> 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

View File

@@ -0,0 +1,121 @@
@page
@model MoneyMap.Pages.ReviewAISuggestionsModel
@{
ViewData["Title"] = "AI Categorization Suggestions";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>AI Categorization Suggestions</h2>
<div class="d-flex gap-2">
<a asp-page="/Transactions" asp-route-category="(blank)" class="btn btn-outline-secondary">
View Uncategorized
</a>
<a asp-page="/Index" class="btn btn-outline-secondary">Back to Dashboard</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
<div class="card shadow-sm mb-3">
<div class="card-body">
<h5 class="card-title">How AI Categorization Works</h5>
<p class="card-text">
This tool uses AI to analyze your uncategorized transactions and suggest:
</p>
<ul>
<li><strong>Category</strong> - The most appropriate expense category</li>
<li><strong>Merchant Name</strong> - A normalized merchant name (e.g., "Walmart" from "WAL-MART #1234")</li>
<li><strong>Pattern Rule</strong> - An optional rule to auto-categorize similar transactions in the future</li>
</ul>
<p class="card-text">
<strong>Cost:</strong> Approximately $0.00015 per transaction (~1.5 cents per 100 transactions)
</p>
</div>
</div>
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Uncategorized Transactions (@Model.TotalUncategorized)</strong>
<form method="post" asp-page-handler="GenerateSuggestions" class="d-inline">
<button type="submit" class="btn btn-primary btn-sm">
Generate AI Suggestions (up to 20)
</button>
</form>
</div>
<div class="card-body">
@if (!Model.Transactions.Any())
{
<p class="text-muted">No uncategorized transactions found. Great job!</p>
}
else
{
<p class="text-muted mb-3">
Showing the @Model.Transactions.Count most recent uncategorized transactions.
Click "Generate AI Suggestions" to analyze up to 20 transactions.
</p>
<div class="table-responsive">
<table class="table table-hover table-sm">
<thead>
<tr>
<th>Date</th>
<th>Name</th>
<th>Memo</th>
<th class="text-end">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model.Transactions)
{
<tr>
<td>@item.Transaction.Date.ToString("yyyy-MM-dd")</td>
<td>@item.Transaction.Name</td>
<td class="text-truncate" style="max-width: 300px;">@item.Transaction.Memo</td>
<td class="text-end">
<span class="@(item.Transaction.Amount < 0 ? "text-danger" : "text-success")">
@item.Transaction.Amount.ToString("C")
</span>
</td>
<td>
<a asp-page="/EditTransaction" asp-route-id="@item.Transaction.Id" class="btn btn-sm btn-outline-primary">
Edit
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
<div class="card shadow-sm">
<div class="card-header">
<strong>Quick Tips</strong>
</div>
<div class="card-body">
<ul class="small mb-0">
<li>AI suggestions are based on transaction name, memo, amount, and date</li>
<li>You can accept, reject, or modify each suggestion</li>
<li>Creating rules helps auto-categorize future transactions</li>
<li>High confidence suggestions (>80%) are more reliable</li>
<li>You can manually edit any transaction from the Transactions page</li>
</ul>
</div>
</div>

View File

@@ -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<TransactionWithProposal> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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; }
}
}

View File

@@ -0,0 +1,162 @@
@page
@model MoneyMap.Pages.ReviewAISuggestionsWithProposalsModel
@{
ViewData["Title"] = "Review AI Suggestions";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Review AI Suggestions</h2>
<div class="d-flex gap-2">
<form method="post" asp-page-handler="ApplyAll" class="d-inline" onsubmit="return confirm('Apply all high-confidence suggestions (≥80%)?');">
<button type="submit" class="btn btn-success">
Apply All High Confidence
</button>
</form>
<a asp-page="/ReviewAISuggestions" class="btn btn-outline-secondary">Back</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
@Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
}
@if (!Model.Proposals.Any())
{
<div class="alert alert-info">
<h5>No suggestions remaining</h5>
<p class="mb-0">
All AI suggestions have been processed.
<a asp-page="/ReviewAISuggestions" class="alert-link">Generate more suggestions</a>
</p>
</div>
}
else
{
<div class="alert alert-info mb-3">
<strong>Review each suggestion below.</strong> You can accept the AI's proposal, reject it, or modify it before applying.
High confidence suggestions (≥80%) are generally very reliable.
</div>
@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");
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>@item.Transaction.Name</strong>
<span class="text-muted ms-2">@item.Transaction.Date.ToString("yyyy-MM-dd")</span>
<span class="badge bg-@confidenceClass ms-2">@confidencePercent% Confidence</span>
</div>
<span class="@(item.Transaction.Amount < 0 ? "text-danger" : "text-success") fw-bold">
@item.Transaction.Amount.ToString("C")
</span>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Transaction Details</h6>
<dl class="row small">
<dt class="col-sm-3">Memo:</dt>
<dd class="col-sm-9">@item.Transaction.Memo</dd>
<dt class="col-sm-3">Amount:</dt>
<dd class="col-sm-9">@item.Transaction.Amount.ToString("C")</dd>
</dl>
</div>
<div class="col-md-6">
<h6>AI Suggestion</h6>
<dl class="row small">
<dt class="col-sm-4">Category:</dt>
<dd class="col-sm-8"><span class="badge bg-primary">@item.Proposal.Category</span></dd>
@if (!string.IsNullOrWhiteSpace(item.Proposal.CanonicalMerchant))
{
<dt class="col-sm-4">Merchant:</dt>
<dd class="col-sm-8">@item.Proposal.CanonicalMerchant</dd>
}
@if (!string.IsNullOrWhiteSpace(item.Proposal.Pattern))
{
<dt class="col-sm-4">Pattern:</dt>
<dd class="col-sm-8"><code>@item.Proposal.Pattern</code></dd>
}
@if (!string.IsNullOrWhiteSpace(item.Proposal.Reasoning))
{
<dt class="col-sm-4">Reasoning:</dt>
<dd class="col-sm-8"><em>@item.Proposal.Reasoning</em></dd>
}
</dl>
</div>
</div>
<hr />
<div class="d-flex gap-2 justify-content-end">
<form method="post" asp-page-handler="RejectProposal" class="d-inline">
<input type="hidden" name="transactionId" value="@item.Transaction.Id" />
<button type="submit" class="btn btn-outline-danger btn-sm">
Reject
</button>
</form>
<form method="post" asp-page-handler="ApplyProposal" class="d-inline">
<input type="hidden" name="transactionId" value="@item.Transaction.Id" />
<input type="hidden" name="category" value="@item.Proposal.Category" />
<input type="hidden" name="merchant" value="@item.Proposal.CanonicalMerchant" />
<input type="hidden" name="pattern" value="@item.Proposal.Pattern" />
<input type="hidden" name="confidence" value="@item.Proposal.Confidence" />
<input type="hidden" name="createRule" value="false" />
<button type="submit" class="btn btn-outline-primary btn-sm">
Apply (No Rule)
</button>
</form>
<form method="post" asp-page-handler="ApplyProposal" class="d-inline">
<input type="hidden" name="transactionId" value="@item.Transaction.Id" />
<input type="hidden" name="category" value="@item.Proposal.Category" />
<input type="hidden" name="merchant" value="@item.Proposal.CanonicalMerchant" />
<input type="hidden" name="pattern" value="@item.Proposal.Pattern" />
<input type="hidden" name="confidence" value="@item.Proposal.Confidence" />
<input type="hidden" name="createRule" value="true" />
<button type="submit" class="btn btn-primary btn-sm">
Apply + Create Rule
</button>
</form>
<a asp-page="/EditTransaction" asp-route-id="@item.Transaction.Id" class="btn btn-outline-secondary btn-sm">
Edit Manually
</a>
</div>
</div>
</div>
}
}
<div class="card shadow-sm mt-3">
<div class="card-header">
<strong>Understanding Confidence Scores</strong>
</div>
<div class="card-body">
<ul class="small mb-0">
<li><span class="badge bg-success">≥80%</span> - High confidence, very reliable</li>
<li><span class="badge bg-warning text-dark">60-79%</span> - Medium confidence, review recommended</li>
<li><span class="badge bg-danger">&lt;60%</span> - Low confidence, manual review strongly recommended</li>
</ul>
</div>
</div>

View File

@@ -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<TransactionWithProposal> Proposals { get; set; } = new();
[TempData]
public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public async Task<IActionResult> 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<List<AICategoryProposal>>(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<IActionResult> 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<List<AICategoryProposal>>(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<IActionResult> OnPostRejectProposalAsync(long transactionId)
{
// Remove this proposal from session
var proposalsJson = HttpContext.Session.GetString("AIProposals");
if (!string.IsNullOrWhiteSpace(proposalsJson))
{
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(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<IActionResult> OnPostApplyAllAsync()
{
var proposalsJson = HttpContext.Session.GetString("AIProposals");
if (string.IsNullOrWhiteSpace(proposalsJson))
{
ErrorMessage = "No AI suggestions found.";
return RedirectToPage("ReviewAISuggestions");
}
var proposals = JsonSerializer.Deserialize<List<AICategoryProposal>>(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!;
}
}

View File

@@ -34,6 +34,9 @@ builder.Services.AddScoped<IRecentTransactionsProvider, RecentTransactionsProvid
builder.Services.AddScoped<IReceiptManager, ReceiptManager>();
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
// AI categorization service
builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>();
var app = builder.Build();
// Seed default category mappings on startup

View File

@@ -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<AICategoryProposal?> ProposeCategorizationAsync(Transaction transaction);
Task<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions);
Task<ApplyProposalResult> 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<AICategoryProposal?> 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<List<AICategoryProposal>> ProposeBatchCategorizationAsync(List<Transaction> transactions)
{
var proposals = new List<AICategoryProposal>();
// 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<ApplyProposalResult> 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<OpenAIResponse?> 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<OpenAIChatResponse>(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<OpenAIResponse>(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; }
}

View File

@@ -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 =====