feat(expense-categories): AI 建議 for group/sub name + 990 line
ci-cd-vm / ci-cd (push) Successful in 2m25s

Add an AI assist button to the Edit/New Group (大項) and Subcategory
(小項) dialogs: the user enters a Chinese name, and the model refines
the Chinese, translates it to English, and suggests the matching IRS
Form 990 Part IX line. Suggestions surface in a confirm card; Apply
fills the Chinese name, English name, and 990 line fields.

Backend mirrors the existing expense-classification AI family but over
the Form 990 line catalog: IExpenseCategoryAiService + base (catalog
load, prompt, id validation) + Claude/Gemini providers + factory that
picks the provider from ChurchProfile.AiProvider. New write-gated
POST api/expense-categories/ai-suggest endpoint; sub-category requests
pass the parent group + its 990 line to bias the choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris Chen
2026-06-25 14:18:09 -07:00
parent c5b1a9372a
commit 73077295a4
14 changed files with 682 additions and 11 deletions
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc;
using ROLAC.API.Authorization;
using ROLAC.API.DTOs.Expense;
using ROLAC.API.Services;
using ROLAC.API.Services.Ai;
namespace ROLAC.API.Controllers;
@@ -13,12 +14,30 @@ namespace ROLAC.API.Controllers;
public class ExpenseCategoriesController : ControllerBase
{
private readonly IExpenseCategoryService _svc;
public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc;
private readonly IExpenseCategoryAiServiceFactory _aiFactory;
public ExpenseCategoriesController(IExpenseCategoryService svc, IExpenseCategoryAiServiceFactory aiFactory)
{
_svc = svc;
_aiFactory = aiFactory;
}
[HttpGet]
public async Task<IActionResult> GetAll([FromQuery] bool includeInactive = false)
=> Ok(await _svc.GetAllAsync(includeInactive));
// Suggest an English name + Form 990 line for a category being defined. Write-gated: category
// editing is finance/admin-only, unlike the member-facing expense-ai/assist endpoint.
[HttpPost("ai-suggest")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> AiSuggest([FromBody] ExpenseCategoryAiRequest r, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(r.Name_zh) && string.IsNullOrWhiteSpace(r.Name_en))
return BadRequest("A name is required.");
var svc = await _aiFactory.ResolveAsync(ct);
return Ok(await svc.SuggestAsync(r, ct));
}
[HttpPost("groups")]
[HasPermission(Modules.ExpenseCategories, PermissionActions.Write)]
public async Task<IActionResult> CreateGroup([FromBody] CreateExpenseGroupRequest r)