Add AI chat with Ollama tool calling for financial queries
Conversational AI chat that uses Ollama's native tool-calling API to query transactions, spending reports, account balances, dashboard summaries, and update transaction categories through natural language. Includes persistent conversation history, a full-page chat UI with sidebar, and rich inline rendering of tool results (tables, alerts, cards). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Purrse.Core.DTOs;
|
||||
using Purrse.Core.Interfaces.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace Purrse.Api.Controllers;
|
||||
|
||||
[Authorize]
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ChatController : ControllerBase
|
||||
{
|
||||
private readonly IChatService _chatService;
|
||||
private Guid UserId => Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
|
||||
public ChatController(IChatService chatService) => _chatService = chatService;
|
||||
|
||||
[HttpPost("send")]
|
||||
public async Task<ActionResult<SendChatMessageResponse>> SendMessage(SendChatMessageRequest request)
|
||||
=> Ok(await _chatService.SendMessageAsync(UserId, request));
|
||||
|
||||
[HttpGet("conversations")]
|
||||
public async Task<ActionResult<List<ChatConversationResponse>>> GetConversations()
|
||||
=> Ok(await _chatService.GetConversationsAsync(UserId));
|
||||
|
||||
[HttpGet("conversations/{id}")]
|
||||
public async Task<ActionResult<ChatConversationDetailResponse>> GetConversation(Guid id)
|
||||
{
|
||||
var conversation = await _chatService.GetConversationAsync(UserId, id);
|
||||
return conversation == null ? NotFound() : Ok(conversation);
|
||||
}
|
||||
|
||||
[HttpDelete("conversations/{id}")]
|
||||
public async Task<ActionResult> DeleteConversation(Guid id)
|
||||
{
|
||||
await _chatService.DeleteConversationAsync(UserId, id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ builder.Services.AddScoped<IDuplicateDetectionService, DuplicateDetectionService
|
||||
builder.Services.AddScoped<IEncryptionService, EncryptionService>();
|
||||
builder.Services.AddScoped<IAiCategorizationService, AiCategorizationService>();
|
||||
builder.Services.AddScoped<IBankSyncService, BankSyncService>();
|
||||
builder.Services.AddScoped<IChatService, ChatService>();
|
||||
builder.Services.AddHttpClient("Ollama");
|
||||
|
||||
// Built-in file parsers
|
||||
|
||||
@@ -0,0 +1,756 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
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 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 can search transactions, view spending reports, check account balances, and update transaction categories.
|
||||
When the user asks about their finances, use the available tools to look up real data.
|
||||
Always present financial amounts formatted as currency.
|
||||
When showing transactions, include the date, payee, amount, and category.
|
||||
Today's date is {0}.
|
||||
""";
|
||||
|
||||
public ChatService(
|
||||
PurrseDbContext db,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ITransactionService transactionService,
|
||||
ICategoryService categoryService,
|
||||
IAccountService accountService,
|
||||
IReportService reportService,
|
||||
IDashboardService dashboardService,
|
||||
ILogger<ChatService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_transactionService = transactionService;
|
||||
_categoryService = categoryService;
|
||||
_accountService = accountService;
|
||||
_reportService = reportService;
|
||||
_dashboardService = dashboardService;
|
||||
_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";
|
||||
var modelName = settings?.ModelName ?? "llama3.1:8b";
|
||||
|
||||
// Save user message
|
||||
var userMessage = new ChatMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ConversationId = conversation.Id,
|
||||
Role = "user",
|
||||
Content = request.Message,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.ChatMessages.Add(userMessage);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Build Ollama message list
|
||||
var ollamaMessages = new List<OllamaToolMessage>();
|
||||
|
||||
// System prompt
|
||||
ollamaMessages.Add(new OllamaToolMessage
|
||||
{
|
||||
Role = "system",
|
||||
Content = string.Format(SystemPromptTemplate, DateTime.UtcNow.ToString("yyyy-MM-dd"))
|
||||
});
|
||||
|
||||
// Last 20 conversation messages
|
||||
var recentMessages = conversation.Messages
|
||||
.OrderBy(m => m.CreatedAt)
|
||||
.TakeLast(20)
|
||||
.ToList();
|
||||
|
||||
foreach (var msg in recentMessages)
|
||||
{
|
||||
var ollamaMsg = new OllamaToolMessage { Role = msg.Role, Content = msg.Content };
|
||||
if (msg.ToolCallsJson != null)
|
||||
{
|
||||
ollamaMsg.ToolCalls = JsonSerializer.Deserialize<List<OllamaToolCall>>(msg.ToolCallsJson, OllamaJsonOptions);
|
||||
}
|
||||
ollamaMessages.Add(ollamaMsg);
|
||||
}
|
||||
|
||||
// Add current user message
|
||||
ollamaMessages.Add(new OllamaToolMessage { Role = "user", Content = request.Message });
|
||||
|
||||
// Build tool definitions
|
||||
var tools = BuildToolDefinitions();
|
||||
|
||||
// Collect tool results from this turn
|
||||
var toolResults = new List<(string type, string json)>();
|
||||
|
||||
// Tool-call loop (max 5 iterations)
|
||||
ChatMessage? assistantDbMessage = null;
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
var ollamaRequest = new OllamaToolChatRequest
|
||||
{
|
||||
Model = modelName,
|
||||
Messages = ollamaMessages,
|
||||
Stream = false,
|
||||
Tools = tools
|
||||
};
|
||||
|
||||
var response = await CallOllamaAsync(ollamaUrl, ollamaRequest);
|
||||
|
||||
if (response?.Message?.ToolCalls != null && response.Message.ToolCalls.Count > 0)
|
||||
{
|
||||
// Save assistant message with tool calls
|
||||
var assistantToolMsg = new ChatMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ConversationId = conversation.Id,
|
||||
Role = "assistant",
|
||||
Content = response.Message.Content ?? string.Empty,
|
||||
ToolCallsJson = JsonSerializer.Serialize(response.Message.ToolCalls, OllamaJsonOptions),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.ChatMessages.Add(assistantToolMsg);
|
||||
|
||||
// Add assistant message to Ollama context
|
||||
ollamaMessages.Add(new OllamaToolMessage
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = response.Message.Content ?? string.Empty,
|
||||
ToolCalls = response.Message.ToolCalls
|
||||
});
|
||||
|
||||
// Execute each tool call
|
||||
foreach (var toolCall in response.Message.ToolCalls)
|
||||
{
|
||||
var (toolResultType, data, summaryForLlm) = await ExecuteToolAsync(userId, toolCall);
|
||||
|
||||
var toolResultJsonObj = JsonSerializer.Serialize(new { type = toolResultType, data }, CamelCaseOptions);
|
||||
toolResults.Add((toolResultType, toolResultJsonObj));
|
||||
|
||||
// Save tool message
|
||||
var toolMsg = new ChatMessage
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ConversationId = conversation.Id,
|
||||
Role = "tool",
|
||||
Content = summaryForLlm,
|
||||
ToolResultJson = toolResultJsonObj,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.ChatMessages.Add(toolMsg);
|
||||
|
||||
// Add tool result to Ollama context
|
||||
ollamaMessages.Add(new OllamaToolMessage
|
||||
{
|
||||
Role = "tool",
|
||||
Content = summaryForLlm
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// No tool calls - save final assistant message
|
||||
var content = response?.Message?.Content ?? "I'm sorry, I couldn't process that request.";
|
||||
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.LogDebug("Sending chat request to Ollama: {Url}", $"{ollamaUrl}/api/chat");
|
||||
|
||||
var response = await client.PostAsync($"{ollamaUrl}/api/chat", content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<OllamaToolChatResponse>(responseBody, OllamaJsonOptions);
|
||||
}
|
||||
|
||||
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),
|
||||
_ => ("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;
|
||||
}
|
||||
|
||||
var searchRequest = new TransactionSearchRequest(
|
||||
AccountId: null,
|
||||
StartDate: ParseDateArg(args, "start_date"),
|
||||
EndDate: ParseDateArg(args, "end_date"),
|
||||
MinAmount: ParseDecimalArg(args, "min_amount"),
|
||||
MaxAmount: ParseDecimalArg(args, "max_amount"),
|
||||
PayeeName: ParseStringArg(args, "payee_name"),
|
||||
CategoryId: categoryId,
|
||||
Status: null,
|
||||
SearchText: ParseStringArg(args, "search_text"),
|
||||
Page: 1,
|
||||
PageSize: Math.Min(ParseIntArg(args, "page_size") ?? 25, 25)
|
||||
);
|
||||
|
||||
var result = await _transactionService.SearchAsync(userId, searchRequest);
|
||||
|
||||
var summary = new StringBuilder();
|
||||
summary.AppendLine($"Found {result.TotalCount} transaction(s).");
|
||||
foreach (var txn in result.Items.Take(25))
|
||||
{
|
||||
summary.AppendLine($"- {txn.Date:yyyy-MM-dd} | {txn.PayeeName ?? "Unknown"} | {txn.Amount:C} | {txn.CategoryName ?? "Uncategorized"} (ID: {txn.Id})");
|
||||
}
|
||||
|
||||
return ("transactions", result, summary.ToString());
|
||||
}
|
||||
|
||||
private async Task<(string, object, string)> ExecuteGetSpendingByCategoryAsync(Guid userId, Dictionary<string, object> args)
|
||||
{
|
||||
var startDate = ParseDateArg(args, "start_date") ?? new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);
|
||||
var endDate = ParseDateArg(args, "end_date") ?? DateTime.UtcNow;
|
||||
|
||||
var report = await _reportService.GetSpendingByCategoryAsync(userId, startDate, endDate);
|
||||
|
||||
var summary = new StringBuilder();
|
||||
summary.AppendLine($"Spending from {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}: Total {report.TotalSpending:C}");
|
||||
foreach (var cat in report.Categories.Take(15))
|
||||
{
|
||||
summary.AppendLine($"- {cat.CategoryName}: {cat.Amount:C} ({cat.Percentage:F1}%)");
|
||||
}
|
||||
|
||||
return ("spending_report", report, summary.ToString());
|
||||
}
|
||||
|
||||
private async Task<(string, object, string)> ExecuteGetIncomeVsExpenseAsync(Guid userId, Dictionary<string, object> args)
|
||||
{
|
||||
var startDate = ParseDateArg(args, "start_date") ?? DateTime.UtcNow.AddMonths(-6);
|
||||
var endDate = ParseDateArg(args, "end_date") ?? DateTime.UtcNow;
|
||||
|
||||
var report = await _reportService.GetIncomeVsExpenseAsync(userId, startDate, endDate);
|
||||
|
||||
var summary = new StringBuilder();
|
||||
summary.AppendLine($"Income vs Expenses from {startDate:yyyy-MM-dd} to {endDate:yyyy-MM-dd}:");
|
||||
foreach (var month in report.Months)
|
||||
{
|
||||
summary.AppendLine($"- {month.Year}-{month.Month:D2}: Income {month.Income:C}, Expenses {month.Expenses:C}, Net {month.Net:C}");
|
||||
}
|
||||
|
||||
return ("income_expense", report, summary.ToString());
|
||||
}
|
||||
|
||||
private async Task<(string, object, string)> ExecuteGetAccountBalancesAsync(Guid userId, Dictionary<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(5))
|
||||
{
|
||||
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 '{updated.PayeeName ?? "Unknown"}' ({updated.Date:yyyy-MM-dd}, {updated.Amount:C}) category from '{existing.CategoryName ?? "Uncategorized"}' to '{category.Name}'.");
|
||||
}
|
||||
|
||||
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 25, 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
return DateTime.TryParse(str, out var dt) ? dt : null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user