Private
Public Access
1
0
Files
Purrse/src/Purrse.Api/Services/ChatService.cs
T
Catherine Renelle 2f389ab8e9 Security hardening: fix IDOR vulnerabilities, add rate limiting, SSRF protection, and encryption upgrades
- Fix ReconciliationController and BankSync LinkAccount/UpdateLinkedAccount IDOR (verify account ownership)
- Add ASP.NET Core rate limiting: strict on auth endpoints, global on all API routes
- Harden SSRF validation on Ollama URL (IPv6-mapped bypass, link-local, 0.0.0.0 blocking)
- Upgrade encryption from AES-CBC to AES-GCM with HKDF key derivation (backward-compatible decrypt)
- Add startup validation that rejects default JWT/encryption keys in Production
- Add security headers (X-Frame-Options, CSP, HSTS, Cache-Control, nosniff) to API and nginx
- Mask account numbers in API responses (show last 4 digits only)
- Add password strength validation (min 8 chars) and BCrypt work factor 12
- Hash refresh tokens (SHA-256) before database storage
- Set JWT ClockSkew to zero for immediate expiry enforcement
- Add file upload size limit (10 MB) and filename sanitization
- Cap transaction search PageSize to 200
- Fix FileWatcherService cross-user account matching in multi-user deployments
- Sanitize console.error calls to prevent token leakage in browser logs
- Parameterize raw SQL in SimpleFinConnectionMigration
- Make AuthService.GenerateAuthResponse fully async

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 12:53:20 -05:00

1335 lines
59 KiB
C#

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<ChatService> _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:
- 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).
- The user's message may include [Current page data] showing what they see on screen. You may use this data to answer READ-ONLY questions (e.g. "what Sarku charges do you see?") without calling tools.
- To MODIFY data (update categories, create categories, delete categories), you MUST call the appropriate tool. NEVER claim you made a change without actually calling a tool. If you did not call update_transaction_category, do not say you updated anything.
- When no page data is provided, call the provided function tools to look up data. Do not fabricate data.
- 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.
- 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. You can reference data from previous tool results or page data 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<ChatService> 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<SendChatMessageResponse> 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<ChatMessage>();
}
// Load AI settings
var settings = await _db.AiCategorizationSettings.FirstOrDefaultAsync(s => s.UserId == userId);
var ollamaUrl = settings?.OllamaUrl ?? "http://localhost:11434";
ValidateOllamaUrl(ollamaUrl);
var modelName = settings?.ModelName ?? "llama3.1:8b";
var chatContextSize = settings?.ChatContextSize ?? 16384;
var chatBotName = settings?.ChatBotName;
// 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<OllamaToolMessage>();
// System prompt
var systemPrompt = string.Format(SystemPromptTemplate, DateTime.UtcNow.ToString("yyyy-MM-dd"));
if (!string.IsNullOrWhiteSpace(chatBotName))
{
systemPrompt += $"\nYour name is {chatBotName}.";
}
ollamaMessages.Add(new OllamaToolMessage
{
Role = "system",
Content = systemPrompt
});
// Last 20 conversation messages
var recentMessages = conversation.Messages
.OrderBy(m => m.CreatedAt)
.TakeLast(20)
.ToList();
foreach (var msg in recentMessages)
{
if (msg.Role == "tool")
{
// Replay tool results as assistant messages so the model reliably sees them.
// Many models ignore or mishandle the "tool" role in conversation replay.
ollamaMessages.Add(new OllamaToolMessage
{
Role = "assistant",
Content = $"[Tool Result]\n{msg.Content}"
});
continue;
}
var ollamaMsg = new OllamaToolMessage { Role = msg.Role, Content = msg.Content };
if (msg.ToolCallsJson != null)
{
ollamaMsg.ToolCalls = JsonSerializer.Deserialize<List<OllamaToolCall>>(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");
// Inject page context directly into the user message so it's adjacent to
// the question. Smaller models lose track of data in distant system messages.
var userContent = request.Message;
if (!string.IsNullOrEmpty(request.PageContext))
{
userContent = $"{request.Message}\n\n[Current page data]\n{request.PageContext}";
_logger.LogInformation("Page context attached — {Length} chars, preview: {Preview}",
request.PageContext.Length,
request.PageContext.Length > 200 ? request.PageContext[..200] + "..." : request.PageContext);
}
else
{
_logger.LogInformation("No page context attached to message");
}
ollamaMessages.Add(new OllamaToolMessage { Role = "user", Content = userContent });
// 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,
Options = new OllamaRequestOptions { NumCtx = chatContextSize }
};
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;
// If the model returned empty content and page context is present,
// the tool definitions may be overwhelming the model. Retry without
// tools so it can focus on answering from the page data.
if (string.IsNullOrWhiteSpace(rawContent) && !string.IsNullOrEmpty(request.PageContext) && i == 0)
{
_logger.LogWarning("Model returned empty content with page context present — retrying without tools");
var retryRequest = new OllamaToolChatRequest
{
Model = modelName,
Messages = ollamaMessages,
Stream = false,
Tools = null, // drop tools so the model focuses on the page data
Options = new OllamaRequestOptions { NumCtx = chatContextSize }
};
var retryResponse = await CallOllamaAsync(ollamaUrl, retryRequest);
rawContent = retryResponse?.Message?.Content;
}
if (string.IsNullOrWhiteSpace(rawContent))
{
_logger.LogWarning("Model returned empty content (no tool calls). ToolCalls: {ToolCalls}, Done: {Done}",
response?.Message?.ToolCalls?.Count ?? -1, response?.Done);
rawContent = "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 AND no page context data, it
// likely fabricated data. Nudge it to use tools. Skip when prior tool
// results or page context exist — the model has real data to reference.
if (toolResults.Count == 0 && i == 0 && !hasExistingToolResults && string.IsNullOrEmpty(request.PageContext))
{
_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<object>(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<object>(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<List<ChatConversationResponse>> 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<ChatConversationDetailResponse?> 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<ChatMessageResponse>();
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<JsonElement>(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<object>(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<JsonElement>(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<OllamaToolChatResponse?> 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<OllamaToolChatResponse>(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<string, object> 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;
}
// When the AI passes a date-only end date (midnight), bump to end of day
// so "2026-02-11" means the entire day, not just midnight.
var endDate = ParseDateArg(args, "end_date");
if (endDate.HasValue && endDate.Value.TimeOfDay == TimeSpan.Zero)
endDate = endDate.Value.Date.AddDays(1).AddTicks(-1);
var searchRequest = new TransactionSearchRequest(
AccountId: null,
StartDate: ParseDateArg(args, "start_date"),
EndDate: endDate,
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))
{
var memo = string.IsNullOrWhiteSpace(txn.Memo) ? "" : $" | \"{txn.Memo}\"";
summary.AppendLine($"- {txn.Date:yyyy-MM-dd} | {txn.PayeeName ?? "Unknown"} | {txn.Amount:C} | {txn.CategoryName ?? "Uncategorized"}{memo} (ID: {txn.Id})");
}
return ("transactions", result, summary.ToString());
}
private async Task<(string, object, string)> ExecuteGetSpendingByCategoryAsync(Guid userId, Dictionary<string, object> 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;
if (endDate.TimeOfDay == TimeSpan.Zero) endDate = endDate.Date.AddDays(1).AddTicks(-1);
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<string, object> args)
{
var startDate = ParseDateArg(args, "start_date") ?? DateTime.UtcNow.AddMonths(-6);
var endDate = ParseDateArg(args, "end_date") ?? DateTime.UtcNow;
if (endDate.TimeOfDay == TimeSpan.Zero) endDate = endDate.Date.AddDays(1).AddTicks(-1);
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<string, object> 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<string, object> 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<string, object> args)
{
var name = ParseStringArg(args, "name")
?? throw new ArgumentException("name is required");
var typeStr = ParseStringArg(args, "type") ?? "Expense";
if (!Enum.TryParse<Purrse.Core.Enums.CategoryType>(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<string, object> 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<string, object> 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<object>(), 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<string, object> 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<string, object> 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<string, object> 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<OllamaTool> BuildToolDefinitions()
{
return new List<OllamaTool>
{
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<string, OllamaPropertySchema>
{
["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<string, OllamaPropertySchema>
{
["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<string> { "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<string, OllamaPropertySchema>
{
["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<string> { "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<string, OllamaPropertySchema>()
}
}
},
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<string, OllamaPropertySchema>()
}
}
},
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<string, OllamaPropertySchema>
{
["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<string> { "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<string, OllamaPropertySchema>()
}
}
},
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<string, OllamaPropertySchema>
{
["name"] = new() { Type = "string", Description = "The name for the new category" },
["type"] = new() { Type = "string", Description = "Category type", Enum = new List<string> { "Income", "Expense", "Transfer" } },
["parent_name"] = new() { Type = "string", Description = "Optional parent category name to nest under" }
},
Required = new List<string> { "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<string, OllamaPropertySchema>
{
["name"] = new() { Type = "string", Description = "The exact name of the category to delete" }
},
Required = new List<string> { "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<string, OllamaPropertySchema>
{
["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<string, OllamaPropertySchema>
{
["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<string, OllamaPropertySchema>
{
["account_name"] = new() { Type = "string", Description = "The name of the loan account" }
},
Required = new List<string> { "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<string, OllamaPropertySchema>
{
["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<string> { "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<string, OllamaPropertySchema>()
}
}
},
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<string, OllamaPropertySchema>()
}
}
}
};
}
// 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<string, object> 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<string, object> 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<string, object> 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<string, object> 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<string, object> 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;
}
private static void ValidateOllamaUrl(string url)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
throw new InvalidOperationException("Invalid Ollama URL");
if (uri.Scheme != "http" && uri.Scheme != "https")
throw new InvalidOperationException("Ollama URL must use http or https");
var host = uri.Host.ToLowerInvariant();
if (host == "localhost" || host == "host.docker.internal")
return;
if (host == "metadata.google.internal")
throw new InvalidOperationException("Ollama URL points to a blocked address");
if (System.Net.IPAddress.TryParse(host, out var ip))
{
if (ip.IsIPv4MappedToIPv6)
ip = ip.MapToIPv4();
if (!IsAllowedIp(ip))
throw new InvalidOperationException("Ollama URL must point to a local or private network address");
return;
}
if (!host.Contains('.'))
return;
throw new InvalidOperationException("Ollama URL must point to a local or private network address");
}
private static bool IsAllowedIp(System.Net.IPAddress ip)
{
if (ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork)
return System.Net.IPAddress.IsLoopback(ip);
var bytes = ip.GetAddressBytes();
if (bytes[0] == 0) return false;
if (bytes[0] == 169 && bytes[1] == 254) return false;
if (bytes[0] == 127) return true;
if (bytes[0] == 10) return true;
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true;
if (bytes[0] == 192 && bytes[1] == 168) return true;
return false;
}
}