using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; using Purrse.Core.DTOs; using Purrse.Core.Interfaces.Services; using Purrse.Core.Models; using Purrse.Data; namespace Purrse.Api.Services; public class ChatService : IChatService { private readonly PurrseDbContext _db; private readonly IHttpClientFactory _httpClientFactory; private readonly ITransactionService _transactionService; private readonly ICategoryService _categoryService; private readonly IAccountService _accountService; private readonly IReportService _reportService; private readonly IDashboardService _dashboardService; private readonly IBudgetService _budgetService; private readonly ILoanService _loanService; private readonly IPayeeService _payeeService; private readonly IScheduledTransactionService _scheduledTransactionService; private readonly ILogger _logger; private static readonly JsonSerializerOptions OllamaJsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; private static readonly JsonSerializerOptions CamelCaseOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private const string SystemPromptTemplate = """ You are a helpful personal finance assistant for Purrse. You have tools to search transactions, view spending reports, check account balances, update transaction categories, manage categories, view budgets, check loan details, list payees, and view scheduled transactions. RULES: - To look up data, call the provided function tools. The system executes them and returns results. - NEVER write or output code. No Python, no JavaScript, no code blocks, no pseudocode. - Respond in plain conversational English only. - Format financial amounts as currency (e.g. $1,234.56). - Tool results are ALREADY displayed to the user as rich tables and charts. Do NOT repeat or list the same data in your text response. Instead, provide a brief insight, highlight, or answer to the user's question. For example, if the user asks "what did I spend on groceries?" and the tool returns transactions, say something like "You spent $342.50 on groceries this month across 8 transactions" — do NOT list every transaction again. - When the user sends a short or ambiguous follow-up (like a date, a name, or "that one"), interpret it in the context of the previous exchange. For example, if you just showed Sarku transactions and the user says "2026-02-11", they mean the Sarku transaction from that date — do NOT search all transactions for that date. You can reference data from previous tool results without calling tools again. Today's date is {0}. """; public ChatService( PurrseDbContext db, IHttpClientFactory httpClientFactory, ITransactionService transactionService, ICategoryService categoryService, IAccountService accountService, IReportService reportService, IDashboardService dashboardService, IBudgetService budgetService, ILoanService loanService, IPayeeService payeeService, IScheduledTransactionService scheduledTransactionService, ILogger logger) { _db = db; _httpClientFactory = httpClientFactory; _transactionService = transactionService; _categoryService = categoryService; _accountService = accountService; _reportService = reportService; _dashboardService = dashboardService; _budgetService = budgetService; _loanService = loanService; _payeeService = payeeService; _scheduledTransactionService = scheduledTransactionService; _logger = logger; } public async Task SendMessageAsync(Guid userId, SendChatMessageRequest request) { // Get or create conversation ChatConversation conversation; if (request.ConversationId.HasValue) { conversation = await _db.ChatConversations .Include(c => c.Messages.OrderBy(m => m.CreatedAt)) .FirstOrDefaultAsync(c => c.Id == request.ConversationId.Value && c.UserId == userId) ?? throw new KeyNotFoundException("Conversation not found"); } else { conversation = new ChatConversation { Id = Guid.NewGuid(), UserId = userId, Title = request.Message.Length > 50 ? request.Message[..50] + "..." : request.Message, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; _db.ChatConversations.Add(conversation); await _db.SaveChangesAsync(); // Reload with empty messages collection conversation.Messages = new List(); } // Load AI settings var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId); var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434"; var modelName = settings?.ModelName ?? "llama3.1:8b"; // Save user message var userMessage = new ChatMessage { Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = "user", Content = request.Message, CreatedAt = DateTime.UtcNow }; _db.ChatMessages.Add(userMessage); await _db.SaveChangesAsync(); // Build Ollama message list var ollamaMessages = new List(); // System prompt ollamaMessages.Add(new OllamaToolMessage { Role = "system", Content = string.Format(SystemPromptTemplate, DateTime.UtcNow.ToString("yyyy-MM-dd")) }); // Page context if (!string.IsNullOrEmpty(request.PageContext)) { ollamaMessages.Add(new OllamaToolMessage { Role = "system", Content = $"The user is currently viewing: {request.PageContext}" }); } // Last 20 conversation messages var recentMessages = conversation.Messages .OrderBy(m => m.CreatedAt) .TakeLast(20) .ToList(); foreach (var msg in recentMessages) { var ollamaMsg = new OllamaToolMessage { Role = msg.Role, Content = msg.Content }; if (msg.ToolCallsJson != null) { ollamaMsg.ToolCalls = JsonSerializer.Deserialize>(msg.ToolCallsJson, OllamaJsonOptions); } ollamaMessages.Add(ollamaMsg); } // Check if the conversation already has tool results from prior turns var hasExistingToolResults = recentMessages.Any(m => m.Role == "tool"); // Add current user message ollamaMessages.Add(new OllamaToolMessage { Role = "user", Content = request.Message }); // Build tool definitions var tools = BuildToolDefinitions(); // Collect tool results from this turn var toolResults = new List<(string type, string json)>(); // Tool-call loop (max 5 iterations) ChatMessage? assistantDbMessage = null; for (int i = 0; i < 5; i++) { var ollamaRequest = new OllamaToolChatRequest { Model = modelName, Messages = ollamaMessages, Stream = false, Tools = tools }; var response = await CallOllamaAsync(ollamaUrl, ollamaRequest); if (response?.Message?.ToolCalls != null && response.Message.ToolCalls.Count > 0) { // Save assistant message with tool calls var assistantToolMsg = new ChatMessage { Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = "assistant", Content = response.Message.Content ?? string.Empty, ToolCallsJson = JsonSerializer.Serialize(response.Message.ToolCalls, OllamaJsonOptions), CreatedAt = DateTime.UtcNow }; _db.ChatMessages.Add(assistantToolMsg); // Add assistant message to Ollama context ollamaMessages.Add(new OllamaToolMessage { Role = "assistant", Content = response.Message.Content ?? string.Empty, ToolCalls = response.Message.ToolCalls }); // Execute each tool call foreach (var toolCall in response.Message.ToolCalls) { var (toolResultType, data, summaryForLlm) = await ExecuteToolAsync(userId, toolCall); var toolResultJsonObj = JsonSerializer.Serialize(new { type = toolResultType, data }, CamelCaseOptions); toolResults.Add((toolResultType, toolResultJsonObj)); // Save tool message var toolMsg = new ChatMessage { Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = "tool", Content = summaryForLlm, ToolResultJson = toolResultJsonObj, CreatedAt = DateTime.UtcNow }; _db.ChatMessages.Add(toolMsg); // Add tool result to Ollama context ollamaMessages.Add(new OllamaToolMessage { Role = "tool", Content = summaryForLlm }); } await _db.SaveChangesAsync(); } else { var rawContent = response?.Message?.Content ?? "I'm sorry, I couldn't process that request."; // If the model responded without calling any tools on the first attempt // AND there are no prior tool results in the conversation, it likely // fabricated data. Nudge it to use tools. Skip the nudge when prior // tool results exist — the model may be referencing earlier data. if (toolResults.Count == 0 && i == 0 && !hasExistingToolResults) { _logger.LogWarning("Model responded without calling tools on first attempt, retrying with nudge"); ollamaMessages.Add(new OllamaToolMessage { Role = "assistant", Content = rawContent }); ollamaMessages.Add(new OllamaToolMessage { Role = "user", Content = "You did not use any tools. You MUST call the provided function tools to look up or modify my financial data. Do not make up data. Try again." }); continue; } // No tool calls - save final assistant message var content = StripCodeBlocks(rawContent); assistantDbMessage = new ChatMessage { Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = "assistant", Content = content, CreatedAt = DateTime.UtcNow }; // Aggregate tool results onto the final assistant message if (toolResults.Count > 0) { assistantDbMessage.ToolResultJson = toolResults.Count == 1 ? toolResults[0].json : JsonSerializer.Serialize(toolResults.Select(r => JsonSerializer.Deserialize(r.json)).ToList(), CamelCaseOptions); } _db.ChatMessages.Add(assistantDbMessage); await _db.SaveChangesAsync(); break; } } // If we exhausted the loop without a final message, create one if (assistantDbMessage == null) { assistantDbMessage = new ChatMessage { Id = Guid.NewGuid(), ConversationId = conversation.Id, Role = "assistant", Content = "I've gathered the information. Let me know if you need anything else.", CreatedAt = DateTime.UtcNow }; if (toolResults.Count > 0) { assistantDbMessage.ToolResultJson = toolResults.Count == 1 ? toolResults[0].json : JsonSerializer.Serialize(toolResults.Select(r => JsonSerializer.Deserialize(r.json)).ToList(), CamelCaseOptions); } _db.ChatMessages.Add(assistantDbMessage); await _db.SaveChangesAsync(); } // Update conversation timestamp conversation.UpdatedAt = DateTime.UtcNow; await _db.SaveChangesAsync(); // Determine tool result type for the response string? resultType = null; string? resultJson = null; if (toolResults.Count > 0) { resultType = toolResults.Count == 1 ? toolResults[0].type : "multiple"; resultJson = assistantDbMessage.ToolResultJson; } return new SendChatMessageResponse( conversation.Id, new ChatMessageResponse( assistantDbMessage.Id, assistantDbMessage.Role, assistantDbMessage.Content, resultType, resultJson, assistantDbMessage.CreatedAt ) ); } public async Task> GetConversationsAsync(Guid userId) { return await _db.ChatConversations .Where(c => c.UserId == userId) .OrderByDescending(c => c.UpdatedAt) .Select(c => new ChatConversationResponse(c.Id, c.Title, c.CreatedAt, c.UpdatedAt)) .ToListAsync(); } public async Task GetConversationAsync(Guid userId, Guid conversationId) { var conversation = await _db.ChatConversations .Include(c => c.Messages.OrderBy(m => m.CreatedAt)) .FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId); if (conversation == null) return null; // Return only user and assistant messages // Aggregate tool results from preceding tool messages onto assistant messages var messages = new List(); var pendingToolResults = new List<(string type, string json)>(); foreach (var msg in conversation.Messages.OrderBy(m => m.CreatedAt)) { if (msg.Role == "tool") { if (msg.ToolResultJson != null) { try { var parsed = JsonSerializer.Deserialize(msg.ToolResultJson); var type = parsed.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown"; pendingToolResults.Add((type, msg.ToolResultJson)); } catch { pendingToolResults.Add(("unknown", msg.ToolResultJson)); } } continue; } if (msg.Role == "assistant" && msg.ToolCallsJson != null && msg.Content == string.Empty) { // Skip intermediate assistant messages that only contain tool calls continue; } string? toolResultType = null; string? toolResultJson = null; if (msg.Role == "assistant" && pendingToolResults.Count > 0) { toolResultType = pendingToolResults.Count == 1 ? pendingToolResults[0].type : "multiple"; toolResultJson = pendingToolResults.Count == 1 ? pendingToolResults[0].json : JsonSerializer.Serialize(pendingToolResults.Select(r => JsonSerializer.Deserialize(r.json)).ToList(), CamelCaseOptions); pendingToolResults.Clear(); } else if (msg.Role == "assistant" && msg.ToolResultJson != null) { // Use stored tool result if present try { var parsed = JsonSerializer.Deserialize(msg.ToolResultJson); if (parsed.ValueKind == JsonValueKind.Array) { toolResultType = "multiple"; } else { toolResultType = parsed.TryGetProperty("type", out var t) ? t.GetString() : "unknown"; } toolResultJson = msg.ToolResultJson; } catch { toolResultJson = msg.ToolResultJson; } } messages.Add(new ChatMessageResponse( msg.Id, msg.Role, msg.Content, toolResultType, toolResultJson, msg.CreatedAt )); } return new ChatConversationDetailResponse( conversation.Id, conversation.Title, messages, conversation.CreatedAt, conversation.UpdatedAt ); } public async Task DeleteConversationAsync(Guid userId, Guid conversationId) { var conversation = await _db.ChatConversations .FirstOrDefaultAsync(c => c.Id == conversationId && c.UserId == userId) ?? throw new KeyNotFoundException("Conversation not found"); _db.ChatConversations.Remove(conversation); await _db.SaveChangesAsync(); } private async Task CallOllamaAsync(string ollamaUrl, OllamaToolChatRequest request) { var client = _httpClientFactory.CreateClient("Ollama"); client.Timeout = TimeSpan.FromMinutes(3); var json = JsonSerializer.Serialize(request, OllamaJsonOptions); var content = new StringContent(json, Encoding.UTF8, "application/json"); _logger.LogInformation("Ollama request — model: {Model}, tools: {ToolCount}, messages: {MessageCount}", request.Model, request.Tools?.Count ?? 0, request.Messages.Count); var response = await client.PostAsync($"{ollamaUrl}/api/chat", content); response.EnsureSuccessStatusCode(); var responseBody = await response.Content.ReadAsStringAsync(); _logger.LogInformation("Ollama response — length: {Length}, preview: {Preview}", responseBody.Length, responseBody.Length > 500 ? responseBody[..500] : responseBody); var parsed = JsonSerializer.Deserialize(responseBody, OllamaJsonOptions); _logger.LogInformation("Ollama parsed — tool_calls: {ToolCallCount}, content_length: {ContentLength}", parsed?.Message?.ToolCalls?.Count ?? 0, parsed?.Message?.Content?.Length ?? 0); return parsed; } private async Task<(string toolResultType, object data, string summaryForLlm)> ExecuteToolAsync(Guid userId, OllamaToolCall toolCall) { var name = toolCall.Function.Name; var args = toolCall.Function.Arguments; _logger.LogInformation("Executing tool: {ToolName} with args: {Args}", name, JsonSerializer.Serialize(args)); try { return name switch { "search_transactions" => await ExecuteSearchTransactionsAsync(userId, args), "get_spending_by_category" => await ExecuteGetSpendingByCategoryAsync(userId, args), "get_income_vs_expense" => await ExecuteGetIncomeVsExpenseAsync(userId, args), "get_account_balances" => await ExecuteGetAccountBalancesAsync(userId, args), "get_dashboard_summary" => await ExecuteGetDashboardSummaryAsync(userId), "update_transaction_category" => await ExecuteUpdateTransactionCategoryAsync(userId, args), "list_categories" => await ExecuteListCategoriesAsync(userId), "create_category" => await ExecuteCreateCategoryAsync(userId, args), "delete_category" => await ExecuteDeleteCategoryAsync(userId, args), "get_budget" => await ExecuteGetBudgetAsync(userId, args), "get_budget_summary" => await ExecuteGetBudgetSummaryAsync(userId, args), "get_loan_details" => await ExecuteGetLoanDetailsAsync(userId, args), "calculate_loan_payoff" => await ExecuteCalculateLoanPayoffAsync(userId, args), "list_payees" => await ExecuteListPayeesAsync(userId), "get_scheduled_transactions" => await ExecuteGetScheduledTransactionsAsync(userId), _ => ("error", new { error = $"Unknown tool: {name}" }, $"Error: Unknown tool '{name}'") }; } catch (Exception ex) { _logger.LogWarning(ex, "Tool execution failed: {ToolName}", name); return ("error", new { error = ex.Message }, $"Error executing {name}: {ex.Message}"); } } private async Task<(string, object, string)> ExecuteSearchTransactionsAsync(Guid userId, Dictionary args) { Guid? categoryId = null; var categoryName = ParseStringArg(args, "category_name"); if (categoryName != null) { var categories = await _categoryService.GetAllAsync(userId); var match = categories.FirstOrDefault(c => c.Name.Equals(categoryName, StringComparison.OrdinalIgnoreCase)); if (match != null) categoryId = match.Id; } var searchRequest = new TransactionSearchRequest( AccountId: null, StartDate: ParseDateArg(args, "start_date"), EndDate: ParseDateArg(args, "end_date"), MinAmount: ParseDecimalArg(args, "min_amount"), MaxAmount: ParseDecimalArg(args, "max_amount"), PayeeName: ParseStringArg(args, "payee_name"), CategoryId: categoryId, Status: null, SearchText: ParseStringArg(args, "search_text"), Page: 1, PageSize: Math.Min(ParseIntArg(args, "page_size") ?? 25, 50) ); var result = await _transactionService.SearchAsync(userId, searchRequest); var summary = new StringBuilder(); summary.AppendLine($"Found {result.TotalCount} transaction(s)."); foreach (var txn in result.Items.Take(25)) { summary.AppendLine($"- {txn.Date:yyyy-MM-dd} | {txn.PayeeName ?? "Unknown"} | {txn.Amount:C} | {txn.CategoryName ?? "Uncategorized"} (ID: {txn.Id})"); } return ("transactions", result, summary.ToString()); } private async Task<(string, object, string)> ExecuteGetSpendingByCategoryAsync(Guid userId, Dictionary args) { var startDate = ParseDateArg(args, "start_date") ?? new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc); var endDate = ParseDateArg(args, "end_date") ?? DateTime.UtcNow; var report = await _reportService.GetSpendingByCategoryAsync(userId, startDate, endDate); var summary = new StringBuilder(); summary.AppendLine($"Spending from {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}: Total {report.TotalSpending:C}"); foreach (var cat in report.Categories.Take(15)) { summary.AppendLine($"- {cat.CategoryName}: {cat.Amount:C} ({cat.Percentage:F1}%)"); } return ("spending_report", report, summary.ToString()); } private async Task<(string, object, string)> ExecuteGetIncomeVsExpenseAsync(Guid userId, Dictionary args) { var startDate = ParseDateArg(args, "start_date") ?? DateTime.UtcNow.AddMonths(-6); var endDate = ParseDateArg(args, "end_date") ?? DateTime.UtcNow; var report = await _reportService.GetIncomeVsExpenseAsync(userId, startDate, endDate); var summary = new StringBuilder(); summary.AppendLine($"Income vs Expenses from {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}:"); foreach (var month in report.Months) { summary.AppendLine($"- {month.Year}-{month.Month:D2}: Income {month.Income:C}, Expenses {month.Expenses:C}, Net {month.Net:C}"); } return ("income_expense", report, summary.ToString()); } private async Task<(string, object, string)> ExecuteGetAccountBalancesAsync(Guid userId, Dictionary args) { var accounts = await _accountService.GetAllAsync(userId); var summary = new StringBuilder(); summary.AppendLine($"You have {accounts.Count} account(s):"); foreach (var acct in accounts) { summary.AppendLine($"- {acct.Name} ({acct.Type}): {acct.Balance:C}"); } return ("accounts", accounts, summary.ToString()); } private async Task<(string, object, string)> ExecuteGetDashboardSummaryAsync(Guid userId) { var dashboard = await _dashboardService.GetDashboardAsync(userId); var summary = new StringBuilder(); summary.AppendLine($"Net Worth: {dashboard.NetWorth:C} (Assets: {dashboard.TotalAssets:C}, Liabilities: {dashboard.TotalLiabilities:C})"); summary.AppendLine($"Spending this month: {dashboard.SpendingSummary.ThisMonth:C} (last month: {dashboard.SpendingSummary.LastMonth:C})"); if (dashboard.UpcomingBills.Count > 0) { summary.AppendLine("Upcoming bills:"); foreach (var bill in dashboard.UpcomingBills.Take(10)) { summary.AppendLine($"- {bill.PayeeName}: {bill.Amount:C} due {bill.DueDate:yyyy-MM-dd}"); } } return ("dashboard", dashboard, summary.ToString()); } private async Task<(string, object, string)> ExecuteUpdateTransactionCategoryAsync(Guid userId, Dictionary args) { var transactionId = ParseGuidArg(args, "transaction_id") ?? throw new ArgumentException("transaction_id is required"); var categoryName = ParseStringArg(args, "category_name") ?? throw new ArgumentException("category_name is required"); // Resolve category by name var categories = await _categoryService.GetAllAsync(userId); var category = categories.FirstOrDefault(c => c.Name.Equals(categoryName, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException($"Category '{categoryName}' not found"); // Get existing transaction var existing = await _transactionService.GetByIdAsync(userId, transactionId) ?? throw new KeyNotFoundException($"Transaction not found"); // Build update request preserving all fields var updateRequest = new UpdateTransactionRequest( Date: existing.Date, Amount: existing.Amount, PayeeName: existing.PayeeName, PayeeId: existing.PayeeId, CategoryId: category.Id, Memo: existing.Memo, ReferenceNumber: existing.ReferenceNumber, CheckNumber: existing.CheckNumber, Type: existing.Type, Status: existing.Status, Splits: null ); var updated = await _transactionService.UpdateAsync(userId, transactionId, updateRequest); var result = new { transactionId = updated.Id, payeeName = updated.PayeeName, oldCategory = existing.CategoryName ?? "Uncategorized", newCategory = category.Name, amount = updated.Amount, date = updated.Date }; return ("category_update", result, $"Updated transaction ID:{updated.Id} '{updated.PayeeName ?? "Unknown"}' ({updated.Date:yyyy-MM-dd}, {updated.Amount:C}) category from '{existing.CategoryName ?? "Uncategorized"}' to '{category.Name}'. To undo, update transaction ID:{updated.Id} back to '{existing.CategoryName ?? "Uncategorized"}'."); } private async Task<(string, object, string)> ExecuteListCategoriesAsync(Guid userId) { var categories = await _categoryService.GetAllAsync(userId); var active = categories.Where(c => c.IsActive).ToList(); var summary = new StringBuilder(); summary.AppendLine($"You have {active.Count} active categories:"); var grouped = active.GroupBy(c => c.Type).OrderBy(g => g.Key); foreach (var group in grouped) { summary.AppendLine($"\n{group.Key}:"); foreach (var cat in group.OrderBy(c => c.SortOrder)) { var parent = cat.ParentName != null ? $" (under {cat.ParentName})" : ""; summary.AppendLine($"- {cat.Name}{parent} (ID: {cat.Id})"); } } return ("categories", active, summary.ToString()); } private async Task<(string, object, string)> ExecuteCreateCategoryAsync(Guid userId, Dictionary args) { var name = ParseStringArg(args, "name") ?? throw new ArgumentException("name is required"); var typeStr = ParseStringArg(args, "type") ?? "Expense"; if (!Enum.TryParse(typeStr, ignoreCase: true, out var categoryType)) categoryType = Purrse.Core.Enums.CategoryType.Expense; // Resolve optional parent category by name Guid? parentId = null; var parentName = ParseStringArg(args, "parent_name"); if (parentName != null) { var categories = await _categoryService.GetAllAsync(userId); var parent = categories.FirstOrDefault(c => c.Name.Equals(parentName, StringComparison.OrdinalIgnoreCase)); if (parent != null) parentId = parent.Id; } var created = await _categoryService.CreateAsync(userId, new CreateCategoryRequest(name, categoryType, parentId)); var result = new { id = created.Id, name = created.Name, type = created.Type.ToString(), parentName }; return ("category_created", result, $"Created new {categoryType} category '{created.Name}'" + (parentName != null ? $" under '{parentName}'" : "") + "."); } private async Task<(string, object, string)> ExecuteDeleteCategoryAsync(Guid userId, Dictionary args) { var categoryName = ParseStringArg(args, "name") ?? throw new ArgumentException("name is required"); var categories = await _categoryService.GetAllAsync(userId); var category = categories.FirstOrDefault(c => c.Name.Equals(categoryName, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException($"Category '{categoryName}' not found"); if (category.IsSystem) throw new InvalidOperationException($"Cannot delete system category '{category.Name}'"); await _categoryService.DeleteAsync(userId, category.Id); var result = new { id = category.Id, name = category.Name, type = category.Type.ToString() }; return ("category_deleted", result, $"Deleted category '{category.Name}'."); } private async Task<(string, object, string)> ExecuteGetBudgetAsync(Guid userId, Dictionary args) { var year = ParseIntArg(args, "year") ?? DateTime.UtcNow.Year; var month = ParseIntArg(args, "month") ?? DateTime.UtcNow.Month; var budget = await _budgetService.GetByMonthAsync(userId, year, month); if (budget == null) return ("budget", new { items = Array.Empty(), year, month }, $"No budget found for {year}-{month:D2}."); var summary = new StringBuilder(); summary.AppendLine($"Budget for {year}-{month:D2}:"); foreach (var item in budget.Items) { var remaining = item.BudgetedAmount - item.ActualAmount; var status = remaining >= 0 ? "under" : "over"; summary.AppendLine($"- {item.CategoryName}: Budgeted {item.BudgetedAmount:C}, Actual {item.ActualAmount:C}, {Math.Abs(remaining):C} {status} budget"); } return ("budget", budget, summary.ToString()); } private async Task<(string, object, string)> ExecuteGetBudgetSummaryAsync(Guid userId, Dictionary args) { var year = ParseIntArg(args, "year") ?? DateTime.UtcNow.Year; var month = ParseIntArg(args, "month") ?? DateTime.UtcNow.Month; var budget = await _budgetService.GetByMonthAsync(userId, year, month); if (budget == null) return ("budget_summary", new { year, month, totalBudgeted = 0m, totalActual = 0m, remaining = 0m, overBudgetCount = 0 }, $"No budget found for {year}-{month:D2}."); var totalBudgeted = budget.Items.Sum(i => i.BudgetedAmount); var totalActual = budget.Items.Sum(i => i.ActualAmount); var remaining = totalBudgeted - totalActual; var overBudgetCount = budget.Items.Count(i => i.ActualAmount > i.BudgetedAmount); var result = new { year, month, totalBudgeted, totalActual, remaining, overBudgetCount }; var monthName = new DateTime(year, month, 1).ToString("MMM yyyy"); var summaryText = $"Budget for {monthName}: {totalBudgeted:C} budgeted, {totalActual:C} spent, {remaining:C} remaining. {overBudgetCount} categories over budget."; return ("budget_summary", result, summaryText); } private async Task<(string, object, string)> ExecuteGetLoanDetailsAsync(Guid userId, Dictionary args) { var accountName = ParseStringArg(args, "account_name") ?? throw new ArgumentException("account_name is required"); var accounts = await _accountService.GetAllAsync(userId); var account = accounts.FirstOrDefault(a => a.Name.Equals(accountName, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException($"Account '{accountName}' not found"); var loan = await _loanService.GetLoanDetailAsync(userId, account.Id) ?? throw new KeyNotFoundException($"No loan details found for account '{accountName}'"); // Return without amortization schedule to keep payload small var result = new { loan.Id, loan.AccountId, loan.AccountName, loan.OriginalBalance, loan.CurrentBalance, loan.InterestRate, loan.TermMonths, loan.MonthlyPayment, loan.OriginationDate, loan.MaturityDate, loan.EscrowAmount, loan.ExtraPayment }; var summary = new StringBuilder(); summary.AppendLine($"Loan details for {loan.AccountName}:"); summary.AppendLine($"- Original Balance: {loan.OriginalBalance:C}"); summary.AppendLine($"- Current Balance: {loan.CurrentBalance:C}"); summary.AppendLine($"- Interest Rate: {loan.InterestRate:F2}%"); summary.AppendLine($"- Monthly Payment: {loan.MonthlyPayment:C}"); summary.AppendLine($"- Maturity Date: {loan.MaturityDate:yyyy-MM-dd}"); if (loan.EscrowAmount.HasValue) summary.AppendLine($"- Escrow: {loan.EscrowAmount.Value:C}"); return ("loan_details", result, summary.ToString()); } private async Task<(string, object, string)> ExecuteCalculateLoanPayoffAsync(Guid userId, Dictionary args) { var accountName = ParseStringArg(args, "account_name") ?? throw new ArgumentException("account_name is required"); var accounts = await _accountService.GetAllAsync(userId); var account = accounts.FirstOrDefault(a => a.Name.Equals(accountName, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException($"Account '{accountName}' not found"); var request = new PayoffScenarioRequest( ExtraMonthlyPayment: ParseDecimalArg(args, "extra_monthly_payment"), LumpSumPayment: ParseDecimalArg(args, "lump_sum_payment"), LumpSumDate: ParseDateArg(args, "lump_sum_date") ); var scenario = await _loanService.CalculatePayoffScenarioAsync(userId, account.Id, request); // Return without full schedule to keep payload small var result = new { scenario.OriginalPayoffDate, scenario.NewPayoffDate, scenario.TotalInterestOriginal, scenario.TotalInterestNew, scenario.InterestSaved, scenario.MonthsSaved }; var summary = new StringBuilder(); summary.AppendLine($"Payoff scenario for {accountName}:"); summary.AppendLine($"- Original Payoff Date: {scenario.OriginalPayoffDate:yyyy-MM-dd}"); summary.AppendLine($"- New Payoff Date: {scenario.NewPayoffDate:yyyy-MM-dd}"); summary.AppendLine($"- Interest Saved: {scenario.InterestSaved:C}"); summary.AppendLine($"- Months Saved: {scenario.MonthsSaved}"); return ("loan_payoff", result, summary.ToString()); } private async Task<(string, object, string)> ExecuteListPayeesAsync(Guid userId) { var payees = await _payeeService.GetAllAsync(userId); var active = payees.Where(p => p.IsActive).ToList(); var summary = new StringBuilder(); summary.AppendLine($"You have {active.Count} active payees:"); foreach (var payee in active) { var category = payee.DefaultCategoryName != null ? $" (default category: {payee.DefaultCategoryName})" : ""; summary.AppendLine($"- {payee.Name}{category}"); } return ("payees", active, summary.ToString()); } private async Task<(string, object, string)> ExecuteGetScheduledTransactionsAsync(Guid userId) { var scheduled = await _scheduledTransactionService.GetAllAsync(userId); var active = scheduled.Where(s => s.IsActive).ToList(); var summary = new StringBuilder(); summary.AppendLine($"You have {active.Count} active scheduled transactions:"); foreach (var txn in active) { summary.AppendLine($"- {txn.PayeeName ?? "Unknown"}: {txn.Amount:C} ({txn.Frequency}), next due {txn.NextDueDate:yyyy-MM-dd}, account: {txn.AccountName}"); } return ("scheduled_transactions", active, summary.ToString()); } private static List BuildToolDefinitions() { return new List { new() { Type = "function", Function = new OllamaFunction { Name = "search_transactions", Description = "Search for transactions by date range, payee, category, amount, or text. Returns matching transactions.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["start_date"] = new() { Type = "string", Description = "Start date in YYYY-MM-DD format" }, ["end_date"] = new() { Type = "string", Description = "End date in YYYY-MM-DD format" }, ["payee_name"] = new() { Type = "string", Description = "Payee name to filter by (partial match)" }, ["category_name"] = new() { Type = "string", Description = "Category name to filter by (exact match)" }, ["min_amount"] = new() { Type = "number", Description = "Minimum transaction amount" }, ["max_amount"] = new() { Type = "number", Description = "Maximum transaction amount" }, ["search_text"] = new() { Type = "string", Description = "General text search across payee, memo, etc." }, ["page_size"] = new() { Type = "integer", Description = "Number of results to return (max 50, default 25)" } } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "get_spending_by_category", Description = "Get a spending breakdown by category for a date range. Shows how much was spent in each category.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["start_date"] = new() { Type = "string", Description = "Start date in YYYY-MM-DD format" }, ["end_date"] = new() { Type = "string", Description = "End date in YYYY-MM-DD format" } }, Required = new List { "start_date", "end_date" } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "get_income_vs_expense", Description = "Get monthly income vs expense comparison for a date range. Shows income, expenses, and net for each month.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["start_date"] = new() { Type = "string", Description = "Start date in YYYY-MM-DD format" }, ["end_date"] = new() { Type = "string", Description = "End date in YYYY-MM-DD format" } }, Required = new List { "start_date", "end_date" } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "get_account_balances", Description = "Get all accounts with their current balances, types, and status.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary() } } }, new() { Type = "function", Function = new OllamaFunction { Name = "get_dashboard_summary", Description = "Get a dashboard overview including net worth, assets, liabilities, recent transactions, top spending categories, and upcoming bills.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary() } } }, new() { Type = "function", Function = new OllamaFunction { Name = "update_transaction_category", Description = "Update the category of a specific transaction. Use the transaction ID from search results.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["transaction_id"] = new() { Type = "string", Description = "The transaction ID (GUID) to update" }, ["category_name"] = new() { Type = "string", Description = "The new category name to assign" } }, Required = new List { "transaction_id", "category_name" } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "list_categories", Description = "List all available categories. Shows name, type (Income/Expense/Transfer), and parent category if any.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary() } } }, new() { Type = "function", Function = new OllamaFunction { Name = "create_category", Description = "Create a new category. Specify the name, type (Income, Expense, or Transfer), and optionally a parent category name to nest it under.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["name"] = new() { Type = "string", Description = "The name for the new category" }, ["type"] = new() { Type = "string", Description = "Category type", Enum = new List { "Income", "Expense", "Transfer" } }, ["parent_name"] = new() { Type = "string", Description = "Optional parent category name to nest under" } }, Required = new List { "name", "type" } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "delete_category", Description = "Delete a category by name. System categories cannot be deleted. Transactions using this category will become uncategorized.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["name"] = new() { Type = "string", Description = "The exact name of the category to delete" } }, Required = new List { "name" } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "get_budget", Description = "Get budget for a specific month with budgeted vs actual amounts per category.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["year"] = new() { Type = "integer", Description = "Year (defaults to current year)" }, ["month"] = new() { Type = "integer", Description = "Month number 1-12 (defaults to current month)" } } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "get_budget_summary", Description = "Get high-level budget vs actual totals for a month. Shows total budgeted, total spent, remaining, and count of categories over budget.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["year"] = new() { Type = "integer", Description = "Year (defaults to current year)" }, ["month"] = new() { Type = "integer", Description = "Month number 1-12 (defaults to current month)" } } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "get_loan_details", Description = "Get loan details for an account including balance, interest rate, monthly payment, and maturity date.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["account_name"] = new() { Type = "string", Description = "The name of the loan account" } }, Required = new List { "account_name" } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "calculate_loan_payoff", Description = "Calculate loan payoff scenarios with extra payments. Shows new payoff date, interest saved, and months saved.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary { ["account_name"] = new() { Type = "string", Description = "The name of the loan account" }, ["extra_monthly_payment"] = new() { Type = "number", Description = "Extra amount to pay each month" }, ["lump_sum_payment"] = new() { Type = "number", Description = "One-time lump sum payment amount" }, ["lump_sum_date"] = new() { Type = "string", Description = "Date for lump sum payment in YYYY-MM-DD format" } }, Required = new List { "account_name" } } } }, new() { Type = "function", Function = new OllamaFunction { Name = "list_payees", Description = "List all active payees with their default categories.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary() } } }, new() { Type = "function", Function = new OllamaFunction { Name = "get_scheduled_transactions", Description = "List all active scheduled/recurring transactions with payee, amount, frequency, and next due date.", Parameters = new OllamaFunctionParameters { Type = "object", Properties = new Dictionary() } } } }; } // Detect responses that contain code blocks or common code patterns private static bool LooksLikeCodeResponse(string content) { if (content.Contains("```")) return true; if (content.Contains("import ") && content.Contains("(")) return true; if (content.Contains("def ") && content.Contains("():")) return true; if (content.Contains("print(") || content.Contains("console.log(")) return true; return false; } // Strip fenced code blocks that smaller models sometimes generate instead of using tools private static readonly Regex CodeBlockRegex = new(@"```[\s\S]*?```", RegexOptions.Compiled); private static string StripCodeBlocks(string content) { var cleaned = CodeBlockRegex.Replace(content, "").Trim(); return string.IsNullOrWhiteSpace(cleaned) ? content.Trim() : cleaned; } // Argument parsing helpers private static string? ParseStringArg(Dictionary args, string key) { if (!args.TryGetValue(key, out var value)) return null; if (value is JsonElement el) return el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString(); return value?.ToString(); } private static DateTime? ParseDateArg(Dictionary args, string key) { var str = ParseStringArg(args, key); if (str == null) return null; if (!DateTime.TryParse(str, out var dt)) return null; return DateTime.SpecifyKind(dt, DateTimeKind.Utc); } private static Guid? ParseGuidArg(Dictionary args, string key) { var str = ParseStringArg(args, key); if (str == null) return null; return Guid.TryParse(str, out var guid) ? guid : null; } private static decimal? ParseDecimalArg(Dictionary args, string key) { if (!args.TryGetValue(key, out var value)) return null; if (value is JsonElement el) { return el.ValueKind switch { JsonValueKind.Number => el.GetDecimal(), JsonValueKind.String => decimal.TryParse(el.GetString(), out var d) ? d : null, _ => null }; } return decimal.TryParse(value?.ToString(), out var dec) ? dec : null; } private static int? ParseIntArg(Dictionary args, string key) { if (!args.TryGetValue(key, out var value)) return null; if (value is JsonElement el) { return el.ValueKind switch { JsonValueKind.Number => el.GetInt32(), JsonValueKind.String => int.TryParse(el.GetString(), out var i) ? i : null, _ => null }; } return int.TryParse(value?.ToString(), out var result) ? result : null; } }