Private
Public Access
1
0

Add category management tools to AI chat (list, create, delete)

Three new tools: list_categories shows all categories with type and
parent, create_category adds a new one with optional parent nesting,
delete_category removes non-system categories by name. Frontend renders
category tables, success alerts for creation, and warning alerts for
deletion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-09 23:51:18 -05:00
parent 713f2bfadc
commit 26fb3dc1e1
2 changed files with 162 additions and 1 deletions
+124 -1
View File
@@ -33,7 +33,7 @@ public class ChatService : IChatService
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.
You can search transactions, view spending reports, check account balances, update transaction categories, and manage categories (list, create, delete).
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.
@@ -424,6 +424,9 @@ public class ChatService : IChatService
"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),
_ => ("error", new { error = $"Unknown tool: {name}" }, $"Error: Unknown tool '{name}'")
};
}
@@ -586,6 +589,74 @@ public class ChatService : IChatService
$"Updated transaction '{updated.PayeeName ?? "Unknown"}' ({updated.Date:yyyy-MM-dd}, {updated.Amount:C}) category from '{existing.CategoryName ?? "Uncategorized"}' to '{category.Name}'.");
}
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 static List<OllamaTool> BuildToolDefinitions()
{
return new List<OllamaTool>
@@ -698,6 +769,58 @@ public class ChatService : IChatService
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" }
}
}
}
};
}