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 **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 ## Data Access Layer
### MoneyMapContext (Data/MoneyMapContext.cs) ### MoneyMapContext (Data/MoneyMapContext.cs)
@@ -505,6 +546,38 @@ EF Core DbContext managing all database entities.
**Location:** Pages/Recategorize.cshtml.cs:16-87 **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 ## Configuration
### appsettings.json ### appsettings.json
@@ -648,6 +721,9 @@ Category (nvarchar(max), NOT NULL)
Pattern (nvarchar(max), NOT NULL) Pattern (nvarchar(max), NOT NULL)
MerchantId (int, FK Merchants.Id, SET NULL) MerchantId (int, FK Merchants.Id, SET NULL)
Priority (int, NOT NULL, DEFAULT 0) 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 ## Key Workflows
@@ -740,6 +816,50 @@ Return DashboardData DTO
Render dashboard view 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 ## Design Patterns
### 1. Service Layer Pattern ### 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.AddScoped<IReceiptManager, ReceiptManager>();
builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>(); builder.Services.AddHttpClient<IReceiptParser, OpenAIReceiptParser>();
// AI categorization service
builder.Services.AddHttpClient<ITransactionAICategorizer, TransactionAICategorizer>();
var app = builder.Build(); var app = builder.Build();
// Seed default category mappings on startup // 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 // Merchant relationship
public int? MerchantId { get; set; } public int? MerchantId { get; set; }
public Models.Merchant? Merchant { 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 ===== // ===== Service Interface =====