diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 930585c..1a6e473 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -493,6 +493,8 @@ export interface AiCategorizationSettings { confidenceThreshold: number timeoutSeconds: number updatePayeeDefaults: boolean + systemPrompt: string | null + userPromptTemplate: string | null } export interface OllamaConnectionTestResult { diff --git a/frontend/src/views/settings/SettingsView.vue b/frontend/src/views/settings/SettingsView.vue index 7e38517..9d09d76 100644 --- a/frontend/src/views/settings/SettingsView.vue +++ b/frontend/src/views/settings/SettingsView.vue @@ -466,6 +466,44 @@ + + Prompt Templates + + + Customize the prompts sent to the AI model. Leave empty to use built-in defaults. + The category list is always appended to the system prompt, and the transaction list is always appended to the user prompt. + + + + + + + + Reset to Defaults + + + + AI categorization settings saved @@ -801,7 +839,9 @@ const aiSettings = reactive({ modelName: 'llama3.1:8b', confidenceThreshold: 0.7, timeoutSeconds: 30, - updatePayeeDefaults: true + updatePayeeDefaults: true, + systemPrompt: null, + userPromptTemplate: null }) const aiSaving = ref(false) const aiTesting = ref(false) @@ -811,6 +851,31 @@ const showAiSuccess = ref(false) const showAiError = ref(false) const aiErrorMessage = ref('') +const defaultSystemPrompt = `You are a personal finance transaction categorizer. Classify each transaction into the most appropriate category. + +You MUST respond with valid JSON in exactly this format: +{ + "classifications": [ + {"index": 0, "categoryName": "Groceries", "confidence": 0.9}, + {"index": 1, "categoryName": "Utilities", "confidence": 0.85} + ] +} + +Rules: +- "index" must be the transaction number (integer) from the list below +- "categoryName" must be one of the exact category names listed below +- "confidence" must be a number between 0 and 1 +- Include one entry for every transaction + +Available categories:` + +const defaultUserPromptTemplate = 'Classify these uncategorized transactions:' + +function resetPromptsToDefaults() { + aiSettings.systemPrompt = null + aiSettings.userPromptTemplate = null +} + async function loadAiSettings() { try { const { data } = await aiCategorizationApi.getSettings() diff --git a/src/Purrse.Api/Services/AiCategorizationService.cs b/src/Purrse.Api/Services/AiCategorizationService.cs index 8cb2591..9499232 100644 --- a/src/Purrse.Api/Services/AiCategorizationService.cs +++ b/src/Purrse.Api/Services/AiCategorizationService.cs @@ -16,6 +16,29 @@ public class AiCategorizationService : IAiCategorizationService private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; + internal const string DefaultSystemPrompt = + """ + You are a personal finance transaction categorizer. Classify each transaction into the most appropriate category. + + You MUST respond with valid JSON in exactly this format: + { + "classifications": [ + {"index": 0, "categoryName": "Groceries", "confidence": 0.9}, + {"index": 1, "categoryName": "Utilities", "confidence": 0.85} + ] + } + + Rules: + - "index" must be the transaction number (integer) from the list below + - "categoryName" must be one of the exact category names listed below + - "confidence" must be a number between 0 and 1 + - Include one entry for every transaction + + Available categories: + """; + + internal const string DefaultUserPromptTemplate = "Classify these uncategorized transactions:"; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -39,12 +62,13 @@ public class AiCategorizationService : IAiCategorizationService if (settings == null) { return new AiCategorizationSettingsResponse( - false, "http://localhost:11434", "llama3.1:8b", 0.7, 30, true); + false, "http://localhost:11434", "llama3.1:8b", 0.7, 30, true, null, null); } return new AiCategorizationSettingsResponse( settings.IsEnabled, settings.OllamaUrl, settings.ModelName, - settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults); + settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults, + settings.SystemPrompt, settings.UserPromptTemplate); } public async Task UpdateSettingsAsync(Guid userId, UpdateAiCategorizationSettingsRequest request) @@ -67,13 +91,16 @@ public class AiCategorizationService : IAiCategorizationService settings.ConfidenceThreshold = request.ConfidenceThreshold; settings.TimeoutSeconds = request.TimeoutSeconds; settings.UpdatePayeeDefaults = request.UpdatePayeeDefaults; + settings.SystemPrompt = string.IsNullOrWhiteSpace(request.SystemPrompt) ? null : request.SystemPrompt; + settings.UserPromptTemplate = string.IsNullOrWhiteSpace(request.UserPromptTemplate) ? null : request.UserPromptTemplate; settings.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); return new AiCategorizationSettingsResponse( settings.IsEnabled, settings.OllamaUrl, settings.ModelName, - settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults); + settings.ConfidenceThreshold, settings.TimeoutSeconds, settings.UpdatePayeeDefaults, + settings.SystemPrompt, settings.UserPromptTemplate); } public async Task TestConnectionAsync(Guid userId) @@ -158,8 +185,8 @@ public class AiCategorizationService : IAiCategorizationService List batch, int globalOffset) { - var systemMessage = BuildSystemMessage(categories); - var userMessage = BuildUserMessage(recentCategorized, batch); + var systemMessage = BuildSystemMessage(settings, categories); + var userMessage = BuildUserMessage(settings, recentCategorized, batch); var chatRequest = new OllamaChatRequest( settings.ModelName, @@ -179,6 +206,8 @@ public class AiCategorizationService : IAiCategorizationService var requestJson = JsonSerializer.Serialize(chatRequest, JsonOptions); var content = new StringContent(requestJson, Encoding.UTF8, "application/json"); + _logger.LogInformation("Sending {Count} transactions to Ollama for classification (batch offset {Offset})", batch.Count, globalOffset); + var response = await client.PostAsync($"{settings.OllamaUrl.TrimEnd('/')}/api/chat", content); response.EnsureSuccessStatusCode(); @@ -186,7 +215,12 @@ public class AiCategorizationService : IAiCategorizationService var chatResponse = JsonSerializer.Deserialize(responseJson, JsonOptions); if (chatResponse?.Message?.Content == null) + { + _logger.LogWarning("Ollama returned null message content for batch at offset {Offset}", globalOffset); return new List(); + } + + _logger.LogDebug("Ollama raw response for batch at offset {Offset}: {Response}", globalOffset, chatResponse.Message.Content); return ParseClassifications(chatResponse.Message.Content, categories, batch.Count, globalOffset, settings.ConfidenceThreshold); } @@ -197,24 +231,21 @@ public class AiCategorizationService : IAiCategorizationService } } - private static string BuildSystemMessage(List categories) + private static string BuildSystemMessage(AiCategorizationSettings settings, List categories) { var sb = new StringBuilder(); - sb.AppendLine("You are a personal finance transaction categorizer. Classify each transaction into the most appropriate category."); - sb.AppendLine("Respond with JSON: {\"classifications\": [{\"index\": 0, \"categoryId\": \"...\", \"categoryName\": \"...\", \"confidence\": 0.9}, ...]}"); - sb.AppendLine(); - sb.AppendLine("Available categories:"); + sb.AppendLine(settings.SystemPrompt ?? DefaultSystemPrompt); foreach (var category in categories) { - var parentInfo = category.Parent != null ? $", under \"{category.Parent.Name}\"" : ""; - sb.AppendLine($"- {category.Id} \"{category.Name}\" ({category.Type}{parentInfo})"); + var parentInfo = category.Parent != null ? $" (under \"{category.Parent.Name}\")" : ""; + sb.AppendLine($"- \"{category.Name}\"{parentInfo}"); } return sb.ToString(); } - private static string BuildUserMessage(List recentCategorized, List batch) + private static string BuildUserMessage(AiCategorizationSettings settings, List recentCategorized, List batch) { var sb = new StringBuilder(); @@ -232,7 +263,7 @@ public class AiCategorizationService : IAiCategorizationService sb.AppendLine(); } - sb.AppendLine("Classify these uncategorized transactions:"); + sb.AppendLine(settings.UserPromptTemplate ?? DefaultUserPromptTemplate); for (int i = 0; i < batch.Count; i++) { var t = batch[i]; @@ -243,7 +274,7 @@ public class AiCategorizationService : IAiCategorizationService return sb.ToString(); } - private static List ParseClassifications( + private List ParseClassifications( string responseContent, List categories, int batchSize, @@ -255,55 +286,130 @@ public class AiCategorizationService : IAiCategorizationService using var doc = JsonDocument.Parse(responseContent); var root = doc.RootElement; - if (!root.TryGetProperty("classifications", out var classificationsArray)) - return new List(); + // Try "classifications" wrapper first, then bare array + JsonElement classificationsArray; + if (root.TryGetProperty("classifications", out classificationsArray)) + { + // good + } + else if (root.ValueKind == JsonValueKind.Array) + { + classificationsArray = root; + } + else + { + // Try common alternative wrapper names + if (!root.TryGetProperty("results", out classificationsArray) && + !root.TryGetProperty("data", out classificationsArray)) + { + _logger.LogWarning("Ollama response has no 'classifications' array. Root properties: {Props}", + string.Join(", ", root.EnumerateObject().Select(p => p.Name))); + return new List(); + } + } var categoryLookup = categories.ToDictionary(c => c.Id); var categoryNameLookup = categories - .GroupBy(c => c.Name.ToLowerInvariant()) + .GroupBy(c => c.Name.Trim().ToLowerInvariant()) .ToDictionary(g => g.Key, g => g.First()); var results = new List(); + int skippedNoIndex = 0, skippedBadIndex = 0, skippedLowConf = 0, skippedNoMatch = 0; foreach (var item in classificationsArray.EnumerateArray()) { - if (!item.TryGetProperty("index", out var indexProp)) - continue; + // Parse index — handle both number and string + int localIndex; + if (item.TryGetProperty("index", out var indexProp)) + { + if (indexProp.ValueKind == JsonValueKind.Number) + localIndex = indexProp.GetInt32(); + else if (indexProp.ValueKind == JsonValueKind.String && int.TryParse(indexProp.GetString(), out var parsed)) + localIndex = parsed; + else { skippedNoIndex++; continue; } + } + else { skippedNoIndex++; continue; } - var localIndex = indexProp.GetInt32(); if (localIndex < 0 || localIndex >= batchSize) - continue; + { skippedBadIndex++; continue; } var globalIndex = globalOffset + localIndex; - double confidence = 0; + // Parse confidence — handle number, string, or missing (default 1.0) + double confidence = 1.0; if (item.TryGetProperty("confidence", out var confProp)) - confidence = confProp.GetDouble(); + { + if (confProp.ValueKind == JsonValueKind.Number) + confidence = confProp.GetDouble(); + else if (confProp.ValueKind == JsonValueKind.String && double.TryParse(confProp.GetString(), out var parsedConf)) + confidence = parsedConf; + } if (confidence < confidenceThreshold) - continue; + { skippedLowConf++; continue; } Guid? categoryId = null; string? categoryName = null; - // Try to resolve by categoryId first - if (item.TryGetProperty("categoryId", out var catIdProp) && catIdProp.ValueKind == JsonValueKind.String) + // Try to resolve by categoryId (also check snake_case variant) + foreach (var propName in new[] { "categoryId", "category_id" }) { - if (Guid.TryParse(catIdProp.GetString(), out var parsedId) && categoryLookup.ContainsKey(parsedId)) + if (categoryId.HasValue) break; + if (item.TryGetProperty(propName, out var catIdProp) && catIdProp.ValueKind == JsonValueKind.String) { - categoryId = parsedId; - categoryName = categoryLookup[parsedId].Name; + if (Guid.TryParse(catIdProp.GetString(), out var parsedId) && categoryLookup.ContainsKey(parsedId)) + { + categoryId = parsedId; + categoryName = categoryLookup[parsedId].Name; + } } } - // Fall back to name matching - if (!categoryId.HasValue && item.TryGetProperty("categoryName", out var catNameProp) && catNameProp.ValueKind == JsonValueKind.String) + // Fall back to name matching (also check snake_case variant) + if (!categoryId.HasValue) { - var name = catNameProp.GetString()?.ToLowerInvariant(); - if (name != null && categoryNameLookup.TryGetValue(name, out var matched)) + string? rawName = null; + foreach (var propName in new[] { "categoryName", "category_name", "category" }) { - categoryId = matched.Id; - categoryName = matched.Name; + if (item.TryGetProperty(propName, out var catNameProp) && catNameProp.ValueKind == JsonValueKind.String) + { + rawName = catNameProp.GetString(); + break; + } + } + + if (rawName != null) + { + var name = rawName.Trim().ToLowerInvariant(); + + // Exact match + if (categoryNameLookup.TryGetValue(name, out var matched)) + { + categoryId = matched.Id; + categoryName = matched.Name; + } + else + { + // Handle "Parent > Child" format — try the last segment + var lastSegment = name.Contains('>') ? name.Split('>').Last().Trim() : null; + if (lastSegment != null && categoryNameLookup.TryGetValue(lastSegment, out var segmentMatch)) + { + categoryId = segmentMatch.Id; + categoryName = segmentMatch.Name; + } + else + { + // Contains-based fuzzy match as last resort + var fuzzy = categories.FirstOrDefault(c => + c.Name.Trim().ToLowerInvariant().Contains(name) || + name.Contains(c.Name.Trim().ToLowerInvariant())); + if (fuzzy != null) + { + categoryId = fuzzy.Id; + categoryName = fuzzy.Name; + } + } + } } } @@ -311,12 +417,21 @@ public class AiCategorizationService : IAiCategorizationService { results.Add(new TransactionClassification(globalIndex, categoryId, categoryName, confidence)); } + else + { + skippedNoMatch++; + } } + _logger.LogInformation( + "Parsed {Matched} classifications from {Total} items. Skipped: {NoIndex} no index, {BadIndex} bad index, {LowConf} low confidence, {NoMatch} no category match", + results.Count, classificationsArray.GetArrayLength(), skippedNoIndex, skippedBadIndex, skippedLowConf, skippedNoMatch); + return results; } - catch (JsonException) + catch (JsonException ex) { + _logger.LogWarning(ex, "Failed to parse Ollama JSON response: {Content}", responseContent.Length > 500 ? responseContent[..500] : responseContent); return new List(); } } diff --git a/src/Purrse.Core/DTOs/AiCategorizationDtos.cs b/src/Purrse.Core/DTOs/AiCategorizationDtos.cs index 0c25cfc..47c141c 100644 --- a/src/Purrse.Core/DTOs/AiCategorizationDtos.cs +++ b/src/Purrse.Core/DTOs/AiCategorizationDtos.cs @@ -9,7 +9,9 @@ public record AiCategorizationSettingsResponse( string ModelName, double ConfidenceThreshold, int TimeoutSeconds, - bool UpdatePayeeDefaults); + bool UpdatePayeeDefaults, + string? SystemPrompt, + string? UserPromptTemplate); public record UpdateAiCategorizationSettingsRequest( bool IsEnabled, @@ -17,7 +19,9 @@ public record UpdateAiCategorizationSettingsRequest( string ModelName, double ConfidenceThreshold, int TimeoutSeconds, - bool UpdatePayeeDefaults); + bool UpdatePayeeDefaults, + string? SystemPrompt, + string? UserPromptTemplate); // Connection test public record OllamaConnectionTestResponse( diff --git a/src/Purrse.Core/Models/AiCategorizationSettings.cs b/src/Purrse.Core/Models/AiCategorizationSettings.cs index 0a16835..f466d02 100644 --- a/src/Purrse.Core/Models/AiCategorizationSettings.cs +++ b/src/Purrse.Core/Models/AiCategorizationSettings.cs @@ -10,6 +10,8 @@ public class AiCategorizationSettings public double ConfidenceThreshold { get; set; } = 0.7; public int TimeoutSeconds { get; set; } = 30; public bool UpdatePayeeDefaults { get; set; } = true; + public string? SystemPrompt { get; set; } + public string? UserPromptTemplate { get; set; } public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public User User { get; set; } = null!; diff --git a/src/Purrse.Data/Configurations/AiCategorizationSettingsConfiguration.cs b/src/Purrse.Data/Configurations/AiCategorizationSettingsConfiguration.cs index 6a19b33..8b202f6 100644 --- a/src/Purrse.Data/Configurations/AiCategorizationSettingsConfiguration.cs +++ b/src/Purrse.Data/Configurations/AiCategorizationSettingsConfiguration.cs @@ -12,6 +12,8 @@ public class AiCategorizationSettingsConfiguration : IEntityTypeConfiguration s.Id); builder.Property(s => s.OllamaUrl).HasMaxLength(500); builder.Property(s => s.ModelName).HasMaxLength(100); + builder.Property(s => s.SystemPrompt).HasMaxLength(4000); + builder.Property(s => s.UserPromptTemplate).HasMaxLength(4000); builder.HasOne(s => s.User) .WithOne(u => u.AiCategorizationSettings) diff --git a/src/Purrse.Data/Migrations/20260209002801_AddAiPromptTemplates.Designer.cs b/src/Purrse.Data/Migrations/20260209002801_AddAiPromptTemplates.Designer.cs new file mode 100644 index 0000000..45ec710 --- /dev/null +++ b/src/Purrse.Data/Migrations/20260209002801_AddAiPromptTemplates.Designer.cs @@ -0,0 +1,1472 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Purrse.Data; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + [DbContext(typeof(PurrseDbContext))] + [Migration("20260209002801_AddAiPromptTemplates")] + partial class AddAiPromptTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountNumber") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Balance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreditLimit") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("Institution") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsClosed") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.AiCategorizationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfidenceThreshold") + .HasColumnType("double precision"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("ModelName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OllamaUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("SystemPrompt") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("TimeoutSeconds") + .HasColumnType("integer"); + + b.Property("UpdatePayeeDefaults") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("UserPromptTemplate") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ai_categorization_settings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LoanDetailId") + .HasColumnType("uuid"); + + b.Property("PaymentAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("PaymentDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PaymentNumber") + .HasColumnType("integer"); + + b.Property("PrincipalAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("RemainingBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("LoanDetailId"); + + b.ToTable("amortization_entries", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EncryptedPlaidClientId") + .HasColumnType("text"); + + b.Property("EncryptedPlaidSecret") + .HasColumnType("text"); + + b.Property("PlaidEnvironment") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("bank_sync_settings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Year", "Month") + .IsUnique(); + + b.ToTable("budgets", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BudgetId") + .HasColumnType("uuid"); + + b.Property("BudgetedAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BudgetId"); + + b.HasIndex("CategoryId"); + + b.ToTable("budget_items", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("UserId", "Name", "ParentId") + .IsUnique(); + + b.ToTable("categories", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("DuplicateCount") + .HasColumnType("integer"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("FileType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ImportedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ImportedCount") + .HasColumnType("integer"); + + b.Property("ParsedDataJson") + .HasColumnType("text"); + + b.Property("SkippedCount") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalCount") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("UserId"); + + b.ToTable("import_batches", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("AsOfDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CostBasis") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.Property("Shares") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("SecurityId"); + + b.ToTable("investment_holdings", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalAccountId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountMask") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ExternalAccountName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExternalAccountType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastKnownBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SyncConnectionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("SyncConnectionId", "ExternalAccountId") + .IsUnique(); + + b.ToTable("linked_accounts", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("EscrowAmount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("ExtraPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("InterestRate") + .HasPrecision(8, 4) + .HasColumnType("numeric(8,4)"); + + b.Property("MaturityDate") + .HasColumnType("timestamp with time zone"); + + b.Property("MonthlyPayment") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginalBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("OriginationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("TermMonths") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId") + .IsUnique(); + + b.ToTable("loan_details", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefaultCategoryId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DefaultCategoryId"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("payees", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Alias") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Alias"); + + b.HasIndex("PayeeId"); + + b.ToTable("payee_aliases", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginRegistrationId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("PluginRegistrationId", "Key") + .IsUnique(); + + b.ToTable("plugin_configurations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntryAssembly") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("PluginId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("PluginId") + .IsUnique(); + + b.ToTable("plugin_registrations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("StatementBalance") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("StatementDate") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.ToTable("reconciliations", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("AutoPost") + .HasColumnType("boolean"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Frequency") + .HasColumnType("integer"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("NextDueDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReminderDaysBefore") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("UserId"); + + b.ToTable("scheduled_transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("SecurityType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Symbol") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.HasKey("Id"); + + b.HasIndex("Symbol") + .IsUnique(); + + b.ToTable("securities", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Price") + .HasPrecision(18, 4) + .HasColumnType("numeric(18,4)"); + + b.Property("SecurityId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SecurityId", "Date") + .IsUnique(); + + b.ToTable("security_prices", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EncryptedAccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstitutionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("InstitutionName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncError") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("PlaidItemId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Provider") + .HasColumnType("integer"); + + b.Property("SyncIntervalMinutes") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlaidItemId"); + + b.HasIndex("UserId"); + + b.ToTable("sync_connections", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("LinkedAccountId") + .HasColumnType("uuid"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("SyncConnectionId") + .HasColumnType("uuid"); + + b.Property("TransactionsFetched") + .HasColumnType("integer"); + + b.Property("TransactionsImported") + .HasColumnType("integer"); + + b.Property("TransactionsSkipped") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LinkedAccountId"); + + b.HasIndex("StartedAt"); + + b.HasIndex("SyncConnectionId"); + + b.ToTable("sync_logs", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("CheckNumber") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Fingerprint") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FitId") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("ImportBatchId") + .HasColumnType("uuid"); + + b.Property("IsVoid") + .HasColumnType("boolean"); + + b.Property("Memo") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("PayeeId") + .HasColumnType("uuid"); + + b.Property("PayeeName") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ReconciliationId") + .HasColumnType("uuid"); + + b.Property("ReferenceNumber") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TransferTransactionId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Date"); + + b.HasIndex("Fingerprint"); + + b.HasIndex("FitId"); + + b.HasIndex("ImportBatchId"); + + b.HasIndex("PayeeId"); + + b.HasIndex("ReconciliationId"); + + b.HasIndex("TransferTransactionId") + .IsUnique(); + + b.ToTable("transactions", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasPrecision(18, 2) + .HasColumnType("numeric(18,2)"); + + b.Property("CategoryId") + .HasColumnType("uuid"); + + b.Property("Memo") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TransactionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("TransactionId"); + + b.ToTable("transaction_splits", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("RefreshToken") + .HasColumnType("text"); + + b.Property("RefreshTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany("Accounts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.AiCategorizationSettings", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithOne("AiCategorizationSettings") + .HasForeignKey("Purrse.Core.Models.AiCategorizationSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.AmortizationEntry", b => + { + b.HasOne("Purrse.Core.Models.LoanDetail", "LoanDetail") + .WithMany("AmortizationEntries") + .HasForeignKey("LoanDetailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LoanDetail"); + }); + + modelBuilder.Entity("Purrse.Core.Models.BankSyncSettings", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithOne("BankSyncSettings") + .HasForeignKey("Purrse.Core.Models.BankSyncSettings", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.BudgetItem", b => + { + b.HasOne("Purrse.Core.Models.Budget", "Budget") + .WithMany("Items") + .HasForeignKey("BudgetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("BudgetItems") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Budget"); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.HasOne("Purrse.Core.Models.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Parent"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.InvestmentHolding", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("InvestmentHoldings") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Holdings") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Security"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LinkedAccount", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("LinkedAccounts") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection") + .WithMany("LinkedAccounts") + .HasForeignKey("SyncConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("SyncConnection"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithOne("LoanDetail") + .HasForeignKey("Purrse.Core.Models.LoanDetail", "AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.HasOne("Purrse.Core.Models.Category", "DefaultCategory") + .WithMany() + .HasForeignKey("DefaultCategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DefaultCategory"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PayeeAlias", b => + { + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Aliases") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Payee"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginConfiguration", b => + { + b.HasOne("Purrse.Core.Models.PluginRegistration", "PluginRegistration") + .WithMany("Configurations") + .HasForeignKey("PluginRegistrationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PluginRegistration"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("Reconciliations") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ScheduledTransaction", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany() + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("Payee"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SecurityPrice", b => + { + b.HasOne("Purrse.Core.Models.Security", "Security") + .WithMany("Prices") + .HasForeignKey("SecurityId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Security"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b => + { + b.HasOne("Purrse.Core.Models.User", "User") + .WithMany("SyncConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncLog", b => + { + b.HasOne("Purrse.Core.Models.LinkedAccount", "LinkedAccount") + .WithMany() + .HasForeignKey("LinkedAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.SyncConnection", "SyncConnection") + .WithMany("SyncLogs") + .HasForeignKey("SyncConnectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("LinkedAccount"); + + b.Navigation("SyncConnection"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.HasOne("Purrse.Core.Models.Account", "Account") + .WithMany("Transactions") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany("Transactions") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.ImportBatch", "ImportBatch") + .WithMany("Transactions") + .HasForeignKey("ImportBatchId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Payee", "Payee") + .WithMany("Transactions") + .HasForeignKey("PayeeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Reconciliation", "Reconciliation") + .WithMany("Transactions") + .HasForeignKey("ReconciliationId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "TransferTransaction") + .WithOne() + .HasForeignKey("Purrse.Core.Models.Transaction", "TransferTransactionId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Account"); + + b.Navigation("Category"); + + b.Navigation("ImportBatch"); + + b.Navigation("Payee"); + + b.Navigation("Reconciliation"); + + b.Navigation("TransferTransaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.TransactionSplit", b => + { + b.HasOne("Purrse.Core.Models.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Purrse.Core.Models.Transaction", "Transaction") + .WithMany("Splits") + .HasForeignKey("TransactionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Category"); + + b.Navigation("Transaction"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Account", b => + { + b.Navigation("InvestmentHoldings"); + + b.Navigation("LinkedAccounts"); + + b.Navigation("LoanDetail"); + + b.Navigation("Reconciliations"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Budget", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Category", b => + { + b.Navigation("BudgetItems"); + + b.Navigation("Children"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.ImportBatch", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.LoanDetail", b => + { + b.Navigation("AmortizationEntries"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Payee", b => + { + b.Navigation("Aliases"); + + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.PluginRegistration", b => + { + b.Navigation("Configurations"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Reconciliation", b => + { + b.Navigation("Transactions"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Security", b => + { + b.Navigation("Holdings"); + + b.Navigation("Prices"); + }); + + modelBuilder.Entity("Purrse.Core.Models.SyncConnection", b => + { + b.Navigation("LinkedAccounts"); + + b.Navigation("SyncLogs"); + }); + + modelBuilder.Entity("Purrse.Core.Models.Transaction", b => + { + b.Navigation("Splits"); + }); + + modelBuilder.Entity("Purrse.Core.Models.User", b => + { + b.Navigation("Accounts"); + + b.Navigation("AiCategorizationSettings"); + + b.Navigation("BankSyncSettings"); + + b.Navigation("SyncConnections"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Purrse.Data/Migrations/20260209002801_AddAiPromptTemplates.cs b/src/Purrse.Data/Migrations/20260209002801_AddAiPromptTemplates.cs new file mode 100644 index 0000000..145563d --- /dev/null +++ b/src/Purrse.Data/Migrations/20260209002801_AddAiPromptTemplates.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Purrse.Data.Migrations +{ + /// + public partial class AddAiPromptTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SystemPrompt", + table: "ai_categorization_settings", + type: "character varying(4000)", + maxLength: 4000, + nullable: true); + + migrationBuilder.AddColumn( + name: "UserPromptTemplate", + table: "ai_categorization_settings", + type: "character varying(4000)", + maxLength: 4000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SystemPrompt", + table: "ai_categorization_settings"); + + migrationBuilder.DropColumn( + name: "UserPromptTemplate", + table: "ai_categorization_settings"); + } + } +} diff --git a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs index adceb29..0810e39 100644 --- a/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs +++ b/src/Purrse.Data/Migrations/PurrseDbContextModelSnapshot.cs @@ -107,6 +107,10 @@ namespace Purrse.Data.Migrations .HasMaxLength(500) .HasColumnType("character varying(500)"); + b.Property("SystemPrompt") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + b.Property("TimeoutSeconds") .HasColumnType("integer"); @@ -119,6 +123,10 @@ namespace Purrse.Data.Migrations b.Property("UserId") .HasColumnType("uuid"); + b.Property("UserPromptTemplate") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + b.HasKey("Id"); b.HasIndex("UserId")
+ Customize the prompts sent to the AI model. Leave empty to use built-in defaults. + The category list is always appended to the system prompt, and the transaction list is always appended to the user prompt. +