From c5fad34658aceac1def03c99a6fe44d272ce823c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 17 Jan 2026 18:06:02 -0500 Subject: [PATCH] Feature: Add Settings page with centralized AI model configuration - Add Settings page for AI model selection with load status indicators - Add ModelWarmupService to preload configured model on app startup - Consolidate AI model config to single AI:ReceiptParsingModel setting - Simplify ViewReceipt and AICategorizePreview to use Settings model - Improve AI categorization confidence prompt for varied scores Co-Authored-By: Claude Opus 4.5 --- MoneyMap/Pages/AICategorizePreview.cshtml | 46 ++----- MoneyMap/Pages/AICategorizePreview.cshtml.cs | 24 ++-- MoneyMap/Pages/Settings.cshtml | 93 ++++++++++++++ MoneyMap/Pages/Settings.cshtml.cs | 114 ++++++++++++++++++ MoneyMap/Pages/ViewReceipt.cshtml | 37 +----- MoneyMap/Pages/ViewReceipt.cshtml.cs | 52 +------- MoneyMap/Program.cs | 3 + MoneyMap/Services/ModelWarmupService.cs | 63 ++++++++++ MoneyMap/Services/TransactionAICategorizer.cs | 13 +- 9 files changed, 306 insertions(+), 139 deletions(-) create mode 100644 MoneyMap/Pages/Settings.cshtml create mode 100644 MoneyMap/Pages/Settings.cshtml.cs create mode 100644 MoneyMap/Services/ModelWarmupService.cs diff --git a/MoneyMap/Pages/AICategorizePreview.cshtml b/MoneyMap/Pages/AICategorizePreview.cshtml index df51991..3f8de13 100644 --- a/MoneyMap/Pages/AICategorizePreview.cshtml +++ b/MoneyMap/Pages/AICategorizePreview.cshtml @@ -36,25 +36,10 @@ { } - @if (Model.AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase) && Model.AvailableModels.Any()) - { -
- - -
- Loaded - Not loaded -
-
- } +

+ Using: @Model.SelectedModel + Change +

@@ -66,25 +51,10 @@
- @if (Model.AIProvider.Equals("LlamaCpp", StringComparison.OrdinalIgnoreCase) && Model.AvailableModels.Any()) - { -
- - -
- Loaded - Not loaded -
-
- } +

+ Using: @Model.SelectedModel + Change +

diff --git a/MoneyMap/Pages/AICategorizePreview.cshtml.cs b/MoneyMap/Pages/AICategorizePreview.cshtml.cs index d7add1a..01db1e4 100644 --- a/MoneyMap/Pages/AICategorizePreview.cshtml.cs +++ b/MoneyMap/Pages/AICategorizePreview.cshtml.cs @@ -12,26 +12,22 @@ namespace MoneyMap.Pages { 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"; + public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; [TempData] public string? StoredTransactionIds { get; set; } @@ -51,12 +47,6 @@ namespace MoneyMap.Pages 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)) { @@ -96,7 +86,7 @@ namespace MoneyMap.Pages return RedirectToPage(); } - public async Task OnPostGenerateAsync(string? model) + public async Task OnPostGenerateAsync() { var uncategorized = await _db.Transactions .Include(t => t.Card) @@ -114,7 +104,7 @@ namespace MoneyMap.Pages return RedirectToPage("/Recategorize"); } - var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(uncategorized, model); + var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(uncategorized, SelectedModel); if (proposals.Count == 0) { @@ -136,12 +126,12 @@ namespace MoneyMap.Pages }).ToList(); ProposalsJson = JsonSerializer.Serialize(storedProposals); - ModelUsed = model ?? SelectedModel; + ModelUsed = SelectedModel; return RedirectToPage(); } - public async Task OnPostGenerateForIdsAsync(long[]? transactionIds, string? model) + public async Task OnPostGenerateForIdsAsync(long[]? transactionIds) { // Try to get IDs from form first, then from TempData if ((transactionIds == null || transactionIds.Length == 0) && !string.IsNullOrEmpty(StoredTransactionIds)) @@ -171,7 +161,7 @@ namespace MoneyMap.Pages return RedirectToPage("/Transactions"); } - var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(transactions, model); + var proposals = await _aiCategorizer.ProposeBatchCategorizationAsync(transactions, SelectedModel); if (proposals.Count == 0) { @@ -193,7 +183,7 @@ namespace MoneyMap.Pages }).ToList(); ProposalsJson = JsonSerializer.Serialize(storedProposals); - ModelUsed = model ?? SelectedModel; + ModelUsed = SelectedModel; return RedirectToPage(); } diff --git a/MoneyMap/Pages/Settings.cshtml b/MoneyMap/Pages/Settings.cshtml new file mode 100644 index 0000000..c28d942 --- /dev/null +++ b/MoneyMap/Pages/Settings.cshtml @@ -0,0 +1,93 @@ +@page +@model MoneyMap.Pages.SettingsModel +@{ + ViewData["Title"] = "Settings"; +} + +

Settings

+ +@if (!string.IsNullOrEmpty(Model.SuccessMessage)) +{ + +} + +@if (!string.IsNullOrEmpty(Model.ErrorMessage)) +{ + +} + +
+
+
+
+ AI Model Configuration +
+
+

+ Select the AI model to use for receipt parsing and other AI features. + Models with are currently loaded and ready. +

+ + +
+ + +
+ Loaded + Not loaded +
+
+ +
+ + +
+ +
+
+ +
+
+ Connection Settings +
+
+
+
Models Endpoint
+
@Model.ModelsEndpoint
+
+

+ Configure in appsettings.json under AI:ModelsEndpoint +

+
+
+
+
diff --git a/MoneyMap/Pages/Settings.cshtml.cs b/MoneyMap/Pages/Settings.cshtml.cs new file mode 100644 index 0000000..6d26ccc --- /dev/null +++ b/MoneyMap/Pages/Settings.cshtml.cs @@ -0,0 +1,114 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using MoneyMap.Services; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace MoneyMap.Pages +{ + public class SettingsModel : PageModel + { + private readonly LlamaCppVisionClient _llamaClient; + private readonly IConfiguration _config; + private readonly IWebHostEnvironment _env; + private readonly ILogger _logger; + + public SettingsModel( + LlamaCppVisionClient llamaClient, + IConfiguration config, + IWebHostEnvironment env, + ILogger logger) + { + _llamaClient = llamaClient; + _config = config; + _env = env; + _logger = logger; + } + + public List AvailableModels { get; set; } = new(); + public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; + public string ModelsEndpoint => _config["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; + + [TempData] + public string? SuccessMessage { get; set; } + + [TempData] + public string? ErrorMessage { get; set; } + + public async Task OnGetAsync() + { + AvailableModels = await _llamaClient.GetAvailableModelsAsync(); + } + + public async Task OnPostSaveModelAsync(string model) + { + if (string.IsNullOrEmpty(model)) + { + ErrorMessage = "No model selected."; + return RedirectToPage(); + } + + await SaveSelectedModelAsync(model); + SuccessMessage = $"AI model updated to: {model}"; + return RedirectToPage(); + } + + public async Task OnPostLoadModelAsync(string model) + { + if (string.IsNullOrEmpty(model)) + { + ErrorMessage = "No model selected."; + return RedirectToPage(); + } + + // Save the model first + await SaveSelectedModelAsync(model); + + // Fire a warmup request in the background (don't await) + _ = WarmupModelAsync(model); + + SuccessMessage = $"Model {model} is being loaded. This may take a moment."; + return RedirectToPage(); + } + + private async Task WarmupModelAsync(string model) + { + try + { + _logger.LogInformation("Warming up model: {Model}", model); + await _llamaClient.SendTextPromptAsync("Hello", model); + _logger.LogInformation("Model warmup completed: {Model}", model); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Model warmup failed for {Model}: {Message}", model, ex.Message); + } + } + + private async Task SaveSelectedModelAsync(string model) + { + try + { + var appSettingsPath = Path.Combine(_env.ContentRootPath, "appsettings.json"); + var json = await System.IO.File.ReadAllTextAsync(appSettingsPath); + var jsonNode = JsonNode.Parse(json); + + if (jsonNode == null) return; + + if (jsonNode["AI"] == null) + { + jsonNode["AI"] = new JsonObject(); + } + + jsonNode["AI"]!["ReceiptParsingModel"] = model; + + var options = new JsonSerializerOptions { WriteIndented = true }; + await System.IO.File.WriteAllTextAsync(appSettingsPath, jsonNode.ToJsonString(options)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save model selection: {Message}", ex.Message); + } + } + } +} diff --git a/MoneyMap/Pages/ViewReceipt.cshtml b/MoneyMap/Pages/ViewReceipt.cshtml index fe65e61..e99530c 100644 --- a/MoneyMap/Pages/ViewReceipt.cshtml +++ b/MoneyMap/Pages/ViewReceipt.cshtml @@ -144,7 +144,7 @@ - +
Parse Receipt @@ -153,36 +153,11 @@ @if (Model.AvailableParsers.Any()) {
-
- - -
-
- - -
- Loaded - Not loaded -
-
+ +

+ Using: @Model.SelectedModel + Change +

diff --git a/MoneyMap/Pages/ViewReceipt.cshtml.cs b/MoneyMap/Pages/ViewReceipt.cshtml.cs index 45f8e1c..9460696 100644 --- a/MoneyMap/Pages/ViewReceipt.cshtml.cs +++ b/MoneyMap/Pages/ViewReceipt.cshtml.cs @@ -4,8 +4,6 @@ using Microsoft.EntityFrameworkCore; using MoneyMap.Data; using MoneyMap.Models; using MoneyMap.Services; -using System.Text.Json; -using System.Text.Json.Nodes; namespace MoneyMap.Pages { @@ -14,31 +12,24 @@ namespace MoneyMap.Pages private readonly MoneyMapContext _db; private readonly IReceiptManager _receiptManager; private readonly IEnumerable _parsers; - private readonly LlamaCppVisionClient _llamaClient; private readonly IConfiguration _config; - private readonly IWebHostEnvironment _env; public ViewReceiptModel( MoneyMapContext db, IReceiptManager receiptManager, IEnumerable parsers, - LlamaCppVisionClient llamaClient, - IConfiguration config, - IWebHostEnvironment env) + IConfiguration config) { _db = db; _receiptManager = receiptManager; _parsers = parsers; - _llamaClient = llamaClient; _config = config; - _env = env; } public Receipt? Receipt { get; set; } public List LineItems { get; set; } = new(); public List ParseLogs { get; set; } = new(); public List AvailableParsers { get; set; } = new(); - public List AvailableModels { get; set; } = new(); public string ReceiptUrl { get; set; } = ""; public string SelectedModel => _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; @@ -72,9 +63,6 @@ namespace MoneyMap.Pages FullName = p.GetType().Name }).ToList(); - // Get available LlamaCpp models - AvailableModels = await _llamaClient.GetAvailableModelsAsync(); - return Page(); } @@ -100,7 +88,7 @@ namespace MoneyMap.Pages return File(fileBytes, receipt.ContentType); } - public async Task OnPostParseAsync(long id, string parser, string? model = null) + public async Task OnPostParseAsync(long id, string parser) { var selectedParser = _parsers.FirstOrDefault(p => p.GetType().Name == parser); @@ -110,13 +98,8 @@ namespace MoneyMap.Pages return RedirectToPage(new { id }); } - // Save selected model to config if it changed - if (!string.IsNullOrEmpty(model) && model != SelectedModel) - { - await SaveSelectedModelAsync(model); - } - - var result = await selectedParser.ParseReceiptAsync(id, model); + // Use the configured model from settings + var result = await selectedParser.ParseReceiptAsync(id, SelectedModel); if (result.IsSuccess) { @@ -130,33 +113,6 @@ namespace MoneyMap.Pages return RedirectToPage(new { id }); } - private async Task SaveSelectedModelAsync(string model) - { - try - { - var appSettingsPath = Path.Combine(_env.ContentRootPath, "appsettings.json"); - var json = await System.IO.File.ReadAllTextAsync(appSettingsPath); - var jsonNode = JsonNode.Parse(json); - - if (jsonNode == null) return; - - // Ensure AI section exists - if (jsonNode["AI"] == null) - { - jsonNode["AI"] = new JsonObject(); - } - - jsonNode["AI"]!["ReceiptParsingModel"] = model; - - var options = new JsonSerializerOptions { WriteIndented = true }; - await System.IO.File.WriteAllTextAsync(appSettingsPath, jsonNode.ToJsonString(options)); - } - catch - { - // Silently fail - not critical if we can't save the preference - } - } - public class ParserOption { public string Name { get; set; } = ""; diff --git a/MoneyMap/Program.cs b/MoneyMap/Program.cs index e5a7fd3..e8221a2 100644 --- a/MoneyMap/Program.cs +++ b/MoneyMap/Program.cs @@ -71,6 +71,9 @@ builder.Services.AddScoped(); // AI categorization service builder.Services.AddHttpClient(); +// Model warmup service - preloads the configured AI model on startup +builder.Services.AddHostedService(); + // Financial audit API service builder.Services.AddScoped(); diff --git a/MoneyMap/Services/ModelWarmupService.cs b/MoneyMap/Services/ModelWarmupService.cs new file mode 100644 index 0000000..4a45382 --- /dev/null +++ b/MoneyMap/Services/ModelWarmupService.cs @@ -0,0 +1,63 @@ +namespace MoneyMap.Services +{ + /// + /// Background service that warms up the configured AI model on application startup. + /// Sends a simple prompt to preload the model without blocking the UI. + /// + public class ModelWarmupService : BackgroundService + { + private readonly IServiceProvider _serviceProvider; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public ModelWarmupService( + IServiceProvider serviceProvider, + IConfiguration configuration, + ILogger logger) + { + _serviceProvider = serviceProvider; + _configuration = configuration; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + var model = _configuration["AI:ReceiptParsingModel"]; + + // Only warm up local models (llamacpp: prefix) + if (string.IsNullOrEmpty(model) || !model.StartsWith("llamacpp:")) + { + _logger.LogInformation("Model warmup skipped - configured model is not a local model: {Model}", model ?? "(none)"); + return; + } + + // Small delay to let the app fully start + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + + _logger.LogInformation("Starting model warmup for: {Model}", model); + + try + { + using var scope = _serviceProvider.CreateScope(); + var llamaClient = scope.ServiceProvider.GetRequiredService(); + + // Send a simple prompt to load the model + var result = await llamaClient.SendTextPromptAsync("Hello", model); + + if (result.IsSuccess) + { + _logger.LogInformation("Model warmup completed successfully for: {Model}", model); + } + else + { + _logger.LogWarning("Model warmup failed for {Model}: {Error}", model, result.ErrorMessage); + } + } + catch (Exception ex) + { + // Don't crash the app if warmup fails - just log it + _logger.LogWarning(ex, "Model warmup failed for {Model}: {Message}", model, ex.Message); + } + } + } +} diff --git a/MoneyMap/Services/TransactionAICategorizer.cs b/MoneyMap/Services/TransactionAICategorizer.cs index e9c5d53..04dec26 100644 --- a/MoneyMap/Services/TransactionAICategorizer.cs +++ b/MoneyMap/Services/TransactionAICategorizer.cs @@ -243,6 +243,8 @@ public class TransactionAICategorizer : ITransactionAICategorizer if (transaction.IsTransfer) sb.AppendLine($"- Transfer to: {transaction.TransferToAccount?.DisplayLabel ?? "Unknown"}"); + sb.AppendLine(); + sb.AppendLine($"Existing categories in this system: {categoryList}"); sb.AppendLine(); sb.AppendLine("Provide your analysis in JSON format:"); sb.AppendLine("{"); @@ -250,13 +252,14 @@ public class TransactionAICategorizer : ITransactionAICategorizer sb.AppendLine(" \"canonical_merchant\": \"Clean merchant name (e.g., 'Walmart' from 'WAL-MART #1234')\","); sb.AppendLine(" \"pattern\": \"Pattern to match future transactions (e.g., 'WALMART' or 'SUBWAY')\","); sb.AppendLine(" \"priority\": 0,"); - sb.AppendLine(" \"confidence\": 0.95,"); + sb.AppendLine(" \"confidence\": 0.85,"); sb.AppendLine(" \"reasoning\": \"Brief explanation\""); sb.AppendLine("}"); sb.AppendLine(); - sb.AppendLine($"Existing categories in this system: {categoryList}"); - sb.AppendLine(); - sb.AppendLine("Prefer using existing categories when appropriate. Return ONLY valid JSON, no additional text."); + sb.AppendLine("Guidelines:"); + sb.AppendLine("- Prefer using existing categories when appropriate"); + sb.AppendLine("- confidence: Your certainty in this categorization (0.0-1.0). Use ~0.9+ for obvious matches like 'WALMART' -> Groceries. Use ~0.7-0.8 for likely matches. Use ~0.5-0.6 for uncertain/ambiguous transactions."); + sb.AppendLine("- Return ONLY valid JSON, no additional text."); return sb.ToString(); } @@ -325,7 +328,7 @@ public class TransactionAICategorizer : ITransactionAICategorizer { try { - var selectedModel = model ?? _config["AI:CategorizationModel"] ?? "qwen2.5-coder-32b-instruct-q6_k"; + var selectedModel = model ?? _config["AI:ReceiptParsingModel"] ?? "gpt-4o-mini"; var systemPrompt = "You are a financial transaction categorization expert. Always respond with valid JSON only."; var fullPrompt = $"{systemPrompt}\n\n{prompt}";