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:
@@ -120,6 +120,44 @@
|
|||||||
from "{{ data.oldCategory }}" to "{{ data.newCategory }}"
|
from "{{ data.oldCategory }}" to "{{ data.newCategory }}"
|
||||||
</v-alert>
|
</v-alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Categories List -->
|
||||||
|
<div v-else-if="type === 'categories' && Array.isArray(data)">
|
||||||
|
<v-table density="compact" class="text-caption rounded-lg mb-2">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Parent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="cat in data" :key="cat.id">
|
||||||
|
<td>{{ cat.name }}</td>
|
||||||
|
<td>
|
||||||
|
<v-chip size="x-small" :color="cat.type === 'Income' ? 'success' : cat.type === 'Expense' ? 'error' : 'info'" variant="tonal">
|
||||||
|
{{ cat.type }}
|
||||||
|
</v-chip>
|
||||||
|
</td>
|
||||||
|
<td class="text-medium-emphasis">{{ cat.parentName || '-' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Created -->
|
||||||
|
<div v-else-if="type === 'category_created'">
|
||||||
|
<v-alert type="success" variant="tonal" density="compact" class="mb-2 text-caption">
|
||||||
|
Created {{ data.type }} category "{{ data.name }}"{{ data.parentName ? ` under "${data.parentName}"` : '' }}
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Deleted -->
|
||||||
|
<div v-else-if="type === 'category_deleted'">
|
||||||
|
<v-alert type="warning" variant="tonal" density="compact" class="mb-2 text-caption">
|
||||||
|
Deleted {{ data.type }} category "{{ data.name }}"
|
||||||
|
</v-alert>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public class ChatService : IChatService
|
|||||||
private const string SystemPromptTemplate =
|
private const string SystemPromptTemplate =
|
||||||
"""
|
"""
|
||||||
You are a helpful personal finance assistant for Purrse.
|
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.
|
When the user asks about their finances, use the available tools to look up real data.
|
||||||
Always present financial amounts formatted as currency.
|
Always present financial amounts formatted as currency.
|
||||||
When showing transactions, include the date, payee, amount, and category.
|
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_account_balances" => await ExecuteGetAccountBalancesAsync(userId, args),
|
||||||
"get_dashboard_summary" => await ExecuteGetDashboardSummaryAsync(userId),
|
"get_dashboard_summary" => await ExecuteGetDashboardSummaryAsync(userId),
|
||||||
"update_transaction_category" => await ExecuteUpdateTransactionCategoryAsync(userId, args),
|
"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}'")
|
_ => ("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}'.");
|
$"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()
|
private static List<OllamaTool> BuildToolDefinitions()
|
||||||
{
|
{
|
||||||
return new List<OllamaTool>
|
return new List<OllamaTool>
|
||||||
@@ -698,6 +769,58 @@ public class ChatService : IChatService
|
|||||||
Required = new List<string> { "transaction_id", "category_name" }
|
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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user