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:
120
ARCHITECTURE.md
120
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<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
|
||||
|
||||
121
MoneyMap/Pages/ReviewAISuggestions.cshtml
Normal file
121
MoneyMap/Pages/ReviewAISuggestions.cshtml
Normal 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>
|
||||
114
MoneyMap/Pages/ReviewAISuggestions.cshtml.cs
Normal file
114
MoneyMap/Pages/ReviewAISuggestions.cshtml.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
162
MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml
Normal file
162
MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml
Normal 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"><60%</span> - Low confidence, manual review strongly recommended</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
157
MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml.cs
Normal file
157
MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml.cs
Normal 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!;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
273
MoneyMap/Services/TransactionAICategorizer.cs
Normal file
273
MoneyMap/Services/TransactionAICategorizer.cs
Normal 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; }
|
||||
}
|
||||
@@ -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 =====
|
||||
|
||||
Reference in New Issue
Block a user