diff --git a/MoneyMap/Pages/AICategorizePreview.cshtml b/MoneyMap/Pages/AICategorizePreview.cshtml new file mode 100644 index 0000000..df51991 --- /dev/null +++ b/MoneyMap/Pages/AICategorizePreview.cshtml @@ -0,0 +1,231 @@ +@page +@model MoneyMap.Pages.AICategorizePreviewModel +@{ + ViewData["Title"] = "AI Categorization Preview"; +} + +
+

AI Categorization Preview

+
+ Back to Recategorize +
+
+ +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +@if (!Model.Proposals.Any()) +{ +
+
+ Generate AI Suggestions +
+
+ @if (Model.TransactionIds != null && Model.TransactionIds.Length > 0) + { +

Generate AI categorization suggestions for @Model.SelectedTransactionCount selected transaction(s). You can review and approve them before applying.

+ +
+ @foreach (var id in Model.TransactionIds!) + { + + } + @if (Model.AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase) && Model.AvailableModels.Any()) + { +
+ + +
+ Loaded + Not loaded +
+
+ } + +
+ } + else + { +

Generate AI categorization suggestions for uncategorized transactions. You can review and approve them before applying.

+ +
+ @if (Model.AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase) && Model.AvailableModels.Any()) + { +
+ + +
+ Loaded + Not loaded +
+
+ } + +
+ } +
+
+} +else +{ +
+
+
+ Review Proposals (@Model.Proposals.Count suggestions) +
+ + +
+
+
+
+ + + + + + + + + + + + + + @foreach (var proposal in Model.Proposals) + { + var confidenceClass = proposal.Confidence >= 0.8m ? "bg-success" : + proposal.Confidence >= 0.6m ? "bg-warning text-dark" : "bg-secondary"; + + + + + + + + + + } + +
+ + TransactionCurrentProposedMerchantConfidenceCreate Rule
+ + +
@proposal.TransactionName
+ @if (!string.IsNullOrWhiteSpace(proposal.TransactionMemo)) + { + @proposal.TransactionMemo + } +
+ @proposal.TransactionDate.ToString("yyyy-MM-dd") | @proposal.TransactionAmount.ToString("C") +
+
+ @if (!string.IsNullOrWhiteSpace(proposal.CurrentCategory)) + { + @proposal.CurrentCategory + } + else + { + (none) + } + + @proposal.ProposedCategory + @if (!string.IsNullOrWhiteSpace(proposal.Reasoning)) + { +
+ @(proposal.Reasoning.Length > 60 ? proposal.Reasoning.Substring(0, 60) + "..." : proposal.Reasoning) +
+ } +
+ @if (!string.IsNullOrWhiteSpace(proposal.ProposedMerchant)) + { +
@proposal.ProposedMerchant
+ } + @if (!string.IsNullOrWhiteSpace(proposal.ProposedPattern)) + { + @proposal.ProposedPattern + } +
+ @proposal.Confidence.ToString("P0") + + +
+
+
+ +
+ + +
+
+ Legend +
+
+
+
+
Confidence Levels
+
    +
  • 80%+ High confidence - likely correct
  • +
  • 60-79% Medium confidence - review recommended
  • +
  • <60% Low confidence - verify carefully
  • +
+
+
+
Create Rule
+

+ When checked, a category mapping rule will be created using the proposed pattern. + Future transactions matching this pattern will be automatically categorized. +

+
+
+
+
+} + +@section Scripts { + +} diff --git a/MoneyMap/Pages/AICategorizePreview.cshtml.cs b/MoneyMap/Pages/AICategorizePreview.cshtml.cs new file mode 100644 index 0000000..d7add1a --- /dev/null +++ b/MoneyMap/Pages/AICategorizePreview.cshtml.cs @@ -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 Proposals { get; set; } = new(); + public string ModelUsed { get; set; } = ""; + public string AIProvider => _config["AI:CategorizationProvider"] ?? "OpenAI"; + public List 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 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(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>(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 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 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(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 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>(ProposalsJson); + if (storedProposals == null || storedProposals.Count == 0) + { + ErrorMessage = "No proposals to apply."; + return RedirectToPage("/Recategorize"); + } + + var selectedSet = selectedIds?.ToHashSet() ?? new HashSet(); + var createRulesSet = createRules?.ToHashSet() ?? new HashSet(); + + 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 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; } + } + } +}