Files
MoneyMap/MoneyMap/Pages/ReviewAISuggestionsWithProposals.cshtml.cs
AJ 5723ac26da 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>
2025-10-12 10:47:31 -04:00

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!;
}
}