Feature: Add AICategorizePreview page for AI transaction categorization

Add new page that allows users to:
- Generate AI categorization suggestions for uncategorized transactions
- Review proposals with confidence levels before applying
- Select specific transactions from Transactions page for AI review
- Choose to create mapping rules for high-confidence matches
- Support both OpenAI and LlamaCpp providers with model selection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-15 22:52:30 -05:00
parent 954af2f952
commit b5406106ec
2 changed files with 552 additions and 0 deletions

View File

@@ -0,0 +1,231 @@
@page
@model MoneyMap.Pages.AICategorizePreviewModel
@{
ViewData["Title"] = "AI Categorization Preview";
}
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>AI Categorization Preview</h2>
<div>
<a asp-page="/Recategorize" class="btn btn-outline-secondary">Back to Recategorize</a>
</div>
</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="card shadow-sm">
<div class="card-header">
<strong>Generate AI Suggestions</strong>
</div>
<div class="card-body">
@if (Model.TransactionIds != null && Model.TransactionIds.Length > 0)
{
<p>Generate AI categorization suggestions for <strong>@Model.SelectedTransactionCount selected transaction(s)</strong>. You can review and approve them before applying.</p>
<form method="post" asp-page-handler="GenerateForIds"
onsubmit="this.querySelector('button[type=submit]').disabled = true; this.querySelector('button[type=submit]').innerHTML = '<span class=\'spinner-border spinner-border-sm me-2\'></span>Analyzing transactions...';">
@foreach (var id in Model.TransactionIds!)
{
<input type="hidden" name="transactionIds" value="@id" />
}
@if (Model.AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase) && Model.AvailableModels.Any())
{
<div class="mb-3" style="max-width: 400px;">
<label for="model" class="form-label">Model</label>
<select name="model" id="model" class="form-select">
@foreach (var m in Model.AvailableModels)
{
var isSelected = m.Id == Model.SelectedModel;
<option value="@m.Id" selected="@isSelected">
@(m.IsLoaded ? "● " : "○ ")@m.Id
</option>
}
</select>
<div class="form-text">
<span style="color: #28a745;">●</span> Loaded
<span class="ms-2" style="color: #6c757d;">○</span> Not loaded
</div>
</div>
}
<button type="submit" class="btn btn-primary">
Generate Suggestions for @Model.SelectedTransactionCount Transaction(s)
</button>
</form>
}
else
{
<p>Generate AI categorization suggestions for uncategorized transactions. You can review and approve them before applying.</p>
<form method="post" asp-page-handler="Generate"
onsubmit="this.querySelector('button[type=submit]').disabled = true; this.querySelector('button[type=submit]').innerHTML = '<span class=\'spinner-border spinner-border-sm me-2\'></span>Analyzing transactions...';">
@if (Model.AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase) && Model.AvailableModels.Any())
{
<div class="mb-3" style="max-width: 400px;">
<label for="model" class="form-label">Model</label>
<select name="model" id="model" class="form-select">
@foreach (var m in Model.AvailableModels)
{
var isSelected = m.Id == Model.SelectedModel;
<option value="@m.Id" selected="@isSelected">
@(m.IsLoaded ? "● " : "○ ")@m.Id
</option>
}
</select>
<div class="form-text">
<span style="color: #28a745;">●</span> Loaded
<span class="ms-2" style="color: #6c757d;">○</span> Not loaded
</div>
</div>
}
<button type="submit" class="btn btn-primary">
Generate Suggestions (up to 50 uncategorized)
</button>
</form>
}
</div>
</div>
}
else
{
<form method="post" asp-page-handler="Apply">
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Review Proposals (@Model.Proposals.Count suggestions)</strong>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll(true)">Select All</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="selectAll(false)">Deselect All</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 40px;">
<input type="checkbox" class="form-check-input" id="selectAllCheckbox" onchange="selectAll(this.checked)" checked>
</th>
<th>Transaction</th>
<th>Current</th>
<th>Proposed</th>
<th>Merchant</th>
<th style="width: 100px;">Confidence</th>
<th style="width: 100px;">Create Rule</th>
</tr>
</thead>
<tbody>
@foreach (var proposal in Model.Proposals)
{
var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" :
proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary";
<tr>
<td>
<input type="checkbox" class="form-check-input proposal-checkbox"
name="selectedIds" value="@proposal.TransactionId" checked>
</td>
<td>
<div class="fw-bold">@proposal.TransactionName</div>
@if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo))
{
<small class="text-muted">@proposal.TransactionMemo</small>
}
<div class="small text-muted">
@proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C")
</div>
</td>
<td>
@if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory))
{
<span class="badge bg-secondary">@proposal.CurrentCategory</span>
}
else
{
<span class="text-muted small">(none)</span>
}
</td>
<td>
<span class="badge bg-primary">@proposal.ProposedCategory</span>
@if (!string.IsNullOrWhiteSpace(proposal.Reasoning))
{
<div class="small text-muted mt-1" title="@proposal.Reasoning">
@(proposal.Reasoning.Length > 60 ? proposal.Reasoning.Substring(0, 60) + "..." : proposal.Reasoning)
</div>
}
</td>
<td>
@if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant))
{
<div>@proposal.ProposedMerchant</div>
}
@if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern))
{
<code class="small">@proposal.ProposedPattern</code>
}
</td>
<td>
<span class="badge @confidenceClass">@proposal.Confidence.ToString("P0")</span>
</td>
<td>
<input type="checkbox" class="form-check-input"
name="createRules" value="@proposal.TransactionId"
@(proposal.CreateRule ? "checked" : "")
title="Create a mapping rule for this pattern">
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between">
<form method="post" asp-page-handler="Cancel" class="d-inline">
<button type="submit" class="btn btn-outline-secondary">Cancel</button>
</form>
<button type="submit" class="btn btn-success">
Apply Selected Categorizations
</button>
</div>
</div>
</form>
<div class="card shadow-sm">
<div class="card-header">
<strong>Legend</strong>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>Confidence Levels</h6>
<ul class="list-unstyled mb-0">
<li><span class="badge bg-success">80%+</span> High confidence - likely correct</li>
<li><span class="badge bg-warning text-dark">60-79%</span> Medium confidence - review recommended</li>
<li><span class="badge bg-secondary">&lt;60%</span> Low confidence - verify carefully</li>
</ul>
</div>
<div class="col-md-6">
<h6>Create Rule</h6>
<p class="small text-muted mb-0">
When checked, a category mapping rule will be created using the proposed pattern.
Future transactions matching this pattern will be automatically categorized.
</p>
</div>
</div>
</div>
</div>
}
@section Scripts {
<script>
function selectAll(checked) {
document.querySelectorAll('.proposal-checkbox').forEach(cb => cb.checked = checked);
document.getElementById('selectAllCheckbox').checked = checked;
}
</script>
}

View File

@@ -0,0 +1,321 @@
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 AICategorizePreviewModel : PageModel
{
private readonly MoneyMapContext _db;
private readonly ITransactionAICategorizer _aiCategorizer;
private readonly LlamaCppVisionClient _llamaClient;
private readonly IConfiguration _config;
public AICategorizePreviewModel(
MoneyMapContext db,
ITransactionAICategorizer aiCategorizer,
LlamaCppVisionClient llamaClient,
IConfiguration config)
{
_db = db;
_aiCategorizer = aiCategorizer;
_llamaClient = llamaClient;
_config = config;
}
public List<ProposalViewModel> Proposals { get; set; } = new();
public string ModelUsed { get; set; } = "";
public string AIProvider => _config["AI:CategorizationProvider"] ?? "OpenAI";
public List<LlamaCppModel> AvailableModels { get; set; } = new();
public string SelectedModel => _config["AI:CategorizationModel"] ?? "qwen2.5-coder-32b-instruct-q6_k";
[TempData]
public string? StoredTransactionIds { get; set; }
public long[]? TransactionIds { get; set; }
[TempData]
public string? ProposalsJson { get; set; }
[TempData]
public string? SuccessMessage { get; set; }
[TempData]
public string? ErrorMessage { get; set; }
public int SelectedTransactionCount { get; set; }
public async Task<IActionResult> OnGetAsync()
{
// Load models for the dropdown
if (AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase))
{
AvailableModels = await _llamaClient.GetAvailableModelsAsync();
}
// Load transaction IDs from TempData if available
if (!string.IsNullOrEmpty(StoredTransactionIds))
{
TransactionIds = JsonSerializer.Deserialize<long[]>(StoredTransactionIds);
// Keep in TempData for the generate form
StoredTransactionIds = JsonSerializer.Serialize(TransactionIds);
}
// Count selected transactions if IDs provided
if (TransactionIds != null && TransactionIds.Length > 0)
{
SelectedTransactionCount = await _db.Transactions
.CountAsync(t => TransactionIds.Contains(t.Id));
}
// Check if we have proposals from TempData
if (!string.IsNullOrEmpty(ProposalsJson))
{
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(ProposalsJson);
if (storedProposals != null)
{
await LoadProposalViewModels(storedProposals);
}
}
return Page();
}
public IActionResult OnPostStoreIds(long[] transactionIds)
{
if (transactionIds == null || transactionIds.Length == 0)
{
return RedirectToPage("/Transactions");
}
StoredTransactionIds = JsonSerializer.Serialize(transactionIds);
return RedirectToPage();
}
public async Task<IActionResult> OnPostGenerateAsync(string? model)
{
var uncategorized = await _db.Transactions
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Merchant)
.Include(t => t.TransferToAccount)
.Where(t => string.IsNullOrWhiteSpace(t.Category))
.OrderByDescending(t => t.Date)
.Take(50)
.ToListAsync();
if (uncategorized.Count == 0)
{
ErrorMessage = "No uncategorized transactions to process.";
return RedirectToPage("/Recategorize");
}
var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(uncategorized, model);
if (proposals.Count == 0)
{
ErrorMessage = "AI could not generate any categorization proposals.";
return RedirectToPage("/Recategorize");
}
// Store proposals in TempData
var storedProposals = proposals.Select(p => new StoredProposal
{
TransactionId = p.TransactionId,
Category = p.Category,
CanonicalMerchant = p.CanonicalMerchant,
Pattern = p.Pattern,
Priority = p.Priority,
Confidence = p.Confidence,
Reasoning = p.Reasoning,
CreateRule = p.CreateRule
}).ToList();
ProposalsJson = JsonSerializer.Serialize(storedProposals);
ModelUsed = model ?? SelectedModel;
return RedirectToPage();
}
public async Task<IActionResult> OnPostGenerateForIdsAsync(long[]? transactionIds, string? model)
{
// Try to get IDs from form first, then from TempData
if ((transactionIds == null || transactionIds.Length == 0) && !string.IsNullOrEmpty(StoredTransactionIds))
{
transactionIds = JsonSerializer.Deserialize<long[]>(StoredTransactionIds);
}
if (transactionIds == null || transactionIds.Length == 0)
{
ErrorMessage = "No transactions selected.";
return RedirectToPage("/Transactions");
}
var transactions = await _db.Transactions
.Include(t => t.Card)
.Include(t => t.Account)
.Include(t => t.Merchant)
.Include(t => t.TransferToAccount)
.Where(t => transactionIds.Contains(t.Id))
.OrderByDescending(t => t.Date)
.Take(50)
.ToListAsync();
if (transactions.Count == 0)
{
ErrorMessage = "Selected transactions not found.";
return RedirectToPage("/Transactions");
}
var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(transactions, model);
if (proposals.Count == 0)
{
ErrorMessage = "AI could not generate any categorization proposals.";
return RedirectToPage("/Transactions");
}
// Store proposals in TempData
var storedProposals = proposals.Select(p => new StoredProposal
{
TransactionId = p.TransactionId,
Category = p.Category,
CanonicalMerchant = p.CanonicalMerchant,
Pattern = p.Pattern,
Priority = p.Priority,
Confidence = p.Confidence,
Reasoning = p.Reasoning,
CreateRule = p.CreateRule
}).ToList();
ProposalsJson = JsonSerializer.Serialize(storedProposals);
ModelUsed = model ?? SelectedModel;
return RedirectToPage();
}
public async Task<IActionResult> OnPostApplyAsync(long[] selectedIds, long[] createRules)
{
if (string.IsNullOrEmpty(ProposalsJson))
{
ErrorMessage = "No proposals to apply. Please generate new suggestions.";
return RedirectToPage("/Recategorize");
}
var storedProposals = JsonSerializer.Deserialize<List<StoredProposal>>(ProposalsJson);
if (storedProposals == null || storedProposals.Count == 0)
{
ErrorMessage = "No proposals to apply.";
return RedirectToPage("/Recategorize");
}
var selectedSet = selectedIds?.ToHashSet() ?? new HashSet<long>();
var createRulesSet = createRules?.ToHashSet() ?? new HashSet<long>();
int applied = 0;
int rulesCreated = 0;
foreach (var stored in storedProposals)
{
if (!selectedSet.Contains(stored.TransactionId))
continue;
var proposal = new AICategoryProposal
{
TransactionId = stored.TransactionId,
Category = stored.Category,
CanonicalMerchant = stored.CanonicalMerchant,
Pattern = stored.Pattern,
Priority = stored.Priority,
Confidence = stored.Confidence,
Reasoning = stored.Reasoning,
CreateRule = stored.CreateRule
};
// Check if user wants to create rule for this one
var shouldCreateRule = createRulesSet.Contains(stored.TransactionId);
var result = await _aiCategorizer.ApplyProposalAsync(stored.TransactionId, proposal, shouldCreateRule);
if (result.Success)
{
applied++;
if (result.RuleCreated)
rulesCreated++;
}
}
SuccessMessage = $"Applied {applied} categorizations. Created {rulesCreated} new mapping rules.";
return RedirectToPage("/Recategorize");
}
public IActionResult OnPostCancel()
{
return RedirectToPage("/Recategorize");
}
private async Task LoadProposalViewModels(List<StoredProposal> storedProposals)
{
var transactionIds = storedProposals.Select(p => p.TransactionId).ToList();
var transactions = await _db.Transactions
.Where(t => transactionIds.Contains(t.Id))
.ToDictionaryAsync(t => t.Id);
foreach (var stored in storedProposals)
{
if (transactions.TryGetValue(stored.TransactionId, out var txn))
{
Proposals.Add(new ProposalViewModel
{
TransactionId = stored.TransactionId,
TransactionName = txn.Name,
TransactionMemo = txn.Memo,
TransactionAmount = txn.Amount,
TransactionDate = txn.Date,
CurrentCategory = txn.Category,
ProposedCategory = stored.Category,
ProposedMerchant = stored.CanonicalMerchant,
ProposedPattern = stored.Pattern,
Confidence = stored.Confidence,
Reasoning = stored.Reasoning,
CreateRule = stored.CreateRule
});
}
}
// Keep proposals in TempData for the apply action
ProposalsJson = JsonSerializer.Serialize(storedProposals);
}
public class ProposalViewModel
{
public long TransactionId { get; set; }
public string TransactionName { get; set; } = "";
public string? TransactionMemo { get; set; }
public decimal TransactionAmount { get; set; }
public DateTime TransactionDate { get; set; }
public string? CurrentCategory { get; set; }
public string ProposedCategory { get; set; } = "";
public string? ProposedMerchant { get; set; }
public string? ProposedPattern { get; set; }
public decimal Confidence { get; set; }
public string? Reasoning { get; set; }
public bool CreateRule { get; set; }
}
public class StoredProposal
{
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; }
}
}
}