2f389ab8e9
- 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>
1335 lines
59 KiB
C#
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;
|
|
}
|
|
}
|