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>
158 lines
5.4 KiB
C#
158 lines
5.4 KiB
C#
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!;
|
|
}
|
|
}
|