Private
Public Access
1
0

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:
Catherine Renelle
2026-02-09 23:34:16 -05:00
parent 1ceeca0fb6
commit 846edad2a4
20 changed files with 3237 additions and 0 deletions
+756
View File
@@ -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;
}
}