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)
@@ -29,3 +29,37 @@ public class ExpenseAiSuggestion
/// <summary>Model self-reported confidence in the classification, 0..1.</summary>
public double Confidence { get; set; }
}
/// <summary>
/// Request body for the expense-category AI assist endpoint: refine the name, translate to English,
/// and suggest a Form 990 line for an expense category (大項/小項) being defined or edited.
/// </summary>
public class ExpenseCategoryAiRequest
{
/// <summary>The user-typed Chinese name (the primary input).</summary>
public string Name_zh { get; set; } = "";
/// <summary>The English name, if already typed (extra context for the model).</summary>
public string? Name_en { get; set; }
/// <summary>"group" (大項) or "sub" (小項); selects the prompt framing.</summary>
public string Level { get; set; } = "group";
/// <summary>For a sub-category: the parent group's bilingual name, used for context.</summary>
public string? ParentGroupName { get; set; }
/// <summary>For a sub-category: the parent group's mapped Form 990 line id, used to bias the choice.</summary>
public int? ParentForm990LineId { get; set; }
}
/// <summary>
/// AI suggestion for an expense category: a refined Chinese name, an English translation, and a
/// proposed Form 990 line. Line fields are null when the model returned an id outside the live catalog.
/// </summary>
public class CategoryAiSuggestion
{
/// <summary>Typo-corrected, refined Traditional Chinese name.</summary>
public string? ChineseName { get; set; }
public string? EnglishName { get; set; }
public int? Form990LineId { get; set; }
/// <summary>Bilingual label of the suggested line, e.g. "16 — Occupancy / 場地".</summary>
public string? Form990LineLabel { get; set; }
/// <summary>Model self-reported confidence in the mapping, 0..1.</summary>
public double Confidence { get; set; }
}
+6
View File
@@ -190,6 +190,12 @@ builder.Services.AddScoped<ROLAC.API.Services.Ai.IChurchAiConfigProvider,
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
ROLAC.API.Services.Ai.ExpenseAiServiceFactory>();
// Category-mapping AI (define a 大項/小項: refine name + translate + suggest Form 990 line).
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseCategoryAiService>();
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseCategoryAiService>();
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseCategoryAiServiceFactory,
ROLAC.API.Services.Ai.ExpenseCategoryAiServiceFactory>();
// ---------------------------------------------------------------------------
// Configurable role-based permissions (RBAC matrix)
// ---------------------------------------------------------------------------
@@ -0,0 +1,124 @@
using System.Net.Http.Json;
using System.Text.Json;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Refines, translates, and maps an expense category to a Form 990 line via the Anthropic Claude
/// Messages API. It forces a single tool call (<c>tool_choice</c> → <c>map_category</c>) whose
/// <c>input_schema</c> matches our answer shape, so the model returns structured JSON in a
/// <c>tool_use</c> block. The catalog, prompt, and id validation come from
/// <see cref="ExpenseCategoryAiServiceBase"/>; this class only owns the Claude HTTP call + parse.
/// </summary>
public sealed class ClaudeExpenseCategoryAiService : ExpenseCategoryAiServiceBase
{
private const string BaseUrl = "https://api.anthropic.com/v1";
private const string AnthropicVersion = "2023-06-01";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<ClaudeExpenseCategoryAiService> _logger;
public ClaudeExpenseCategoryAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<ClaudeExpenseCategoryAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
{
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey))
{
_logger.LogWarning("Claude API key is not configured; category AI assist is disabled.");
return null;
}
try
{
var payload = new
{
model = cfg.ClaudeModel,
max_tokens = 1024,
tools = new[]
{
new
{
name = "map_category",
description = "Record the refined Chinese name, English translation, and chosen Form 990 line id for the expense category.",
input_schema = new
{
type = "object",
properties = new
{
chineseName = new { type = "string" },
englishName = new { type = "string" },
form990LineId = new { type = "integer" },
confidence = new { type = "number" },
},
required = new[] { "chineseName", "englishName", "form990LineId", "confidence" },
},
},
},
tool_choice = new { type = "tool", name = "map_category" },
messages = new[]
{
new { role = "user", content = prompt },
},
};
var url = $"{BaseUrl}/messages";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Add("x-api-key", cfg.ClaudeApiKey);
request.Headers.Add("anthropic-version", AnthropicVersion);
using var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogWarning("Claude returned {Status}: {Body}", (int)response.StatusCode, body);
return null;
}
// The forced tool call lands in content[] as a tool_use block; its `input` is our object.
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
foreach (var block in doc.RootElement.GetProperty("content").EnumerateArray())
{
if (block.GetProperty("type").GetString() != "tool_use") continue;
var parsed = block.GetProperty("input").Deserialize<ClaudeAnswer>(
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (parsed is null) return null;
return new ModelAnswer(parsed.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence);
}
_logger.LogWarning("Claude response contained no tool_use block.");
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Claude category AI assist failed.");
return null;
}
}
/// <summary>Shape of the map_category tool input the model fills in.</summary>
private sealed class ClaudeAnswer
{
public string? ChineseName { get; set; }
public string? EnglishName { get; set; }
public int? Form990LineId { get; set; }
public double Confidence { get; set; }
}
}
@@ -0,0 +1,116 @@
using System.Text;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Provider-independent category-AI logic: loads the active Form 990 line catalog, builds the
/// mapping prompt, and validates the model's chosen line id against that catalog. Concrete providers
/// (Gemini, Claude) only implement <see cref="CallModelAsync"/> — the HTTP call plus response parsing —
/// so the catalog/prompt/validation code lives in exactly one place. Mirrors
/// <see cref="ExpenseAiServiceBase"/>, which does the same for the expense-entry classification task.
/// </summary>
public abstract class ExpenseCategoryAiServiceBase : IExpenseCategoryAiService
{
private readonly AppDbContext _db;
protected ExpenseCategoryAiServiceBase(AppDbContext db) => _db = db;
/// <summary>One Form 990 line in the catalog passed to the model.</summary>
protected sealed record CatalogLine(int Id, string LineCode, string NameEn, string? NameZh);
/// <summary>The model's raw answer, before its line id is validated against the catalog.</summary>
protected sealed record ModelAnswer(string? ChineseName, string? EnglishName, int? Form990LineId, double Confidence);
public async Task<CategoryAiSuggestion> SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default)
{
var catalog = await LoadCatalogAsync(ct);
var prompt = BuildPrompt(request, catalog);
var answer = await CallModelAsync(prompt, ct);
if (answer is null) return new CategoryAiSuggestion();
return BuildSuggestion(answer, catalog);
}
/// <summary>
/// Call the provider's API with <paramref name="prompt"/> and return its parsed answer, or null
/// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw.
/// </summary>
protected abstract Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct);
private async Task<List<CatalogLine>> LoadCatalogAsync(CancellationToken ct)
{
return await _db.Form990ExpenseLines
.AsNoTracking()
.Where(line => line.IsActive)
.OrderBy(line => line.SortOrder)
.Select(line => new CatalogLine(line.Id, line.LineCode, line.Name_en, line.Name_zh))
.ToListAsync(ct);
}
private static string BuildPrompt(ExpenseCategoryAiRequest request, List<CatalogLine> catalog)
{
var catalogJson = JsonSerializer.Serialize(catalog);
var levelLabel = request.Level.Equals("sub", StringComparison.OrdinalIgnoreCase)
? "sub-category (小項)"
: "major category (大項)";
var context = new StringBuilder();
context.Append($"This is an expense {levelLabel} in a church's bookkeeping chart of accounts.\n");
if (!string.IsNullOrWhiteSpace(request.Name_zh))
context.Append($"Chinese name entered: {request.Name_zh}\n");
if (!string.IsNullOrWhiteSpace(request.Name_en))
context.Append($"English name entered: {request.Name_en}\n");
if (!string.IsNullOrWhiteSpace(request.ParentGroupName))
context.Append($"It belongs under the parent major category: {request.ParentGroupName}\n");
if (request.ParentForm990LineId is int parentLineId)
context.Append(
$"The parent major category is mapped to Form 990 line id {parentLineId}; prefer a consistent " +
"choice unless a more specific line clearly fits this sub-category.\n");
return
"You are a bookkeeping assistant for a church mapping its expense categories to the IRS Form 990 " +
"Part IX (Statement of Functional Expenses) lines. Given an expense category name (often in " +
"Traditional Chinese), do three things:\n" +
"1. Correct any typos in the name and refine it into natural Traditional Chinese — return it as " +
"chineseName.\n" +
"2. Translate that into a concise, natural accounting English noun phrase (not a full sentence) — " +
"return it as englishName.\n" +
"3. Choose the single best matching Form 990 line from the catalog below. You MUST pick a " +
"form990LineId that appears in the catalog. If nothing fits well, choose the closest general line " +
"(e.g. an \"Other expenses\" line) and lower your confidence.\n\n" +
context +
"\n" +
$"Form 990 line catalog (JSON; each line has an Id, LineCode, and English/Chinese names):\n{catalogJson}";
}
private static CategoryAiSuggestion BuildSuggestion(ModelAnswer answer, List<CatalogLine> catalog)
{
var suggestion = new CategoryAiSuggestion
{
ChineseName = string.IsNullOrWhiteSpace(answer.ChineseName) ? null : answer.ChineseName.Trim(),
EnglishName = string.IsNullOrWhiteSpace(answer.EnglishName) ? null : answer.EnglishName.Trim(),
Confidence = answer.Confidence,
};
// Re-validate the returned id against the catalog; drop a hallucinated id rather than returning it.
var line = catalog.FirstOrDefault(candidate => candidate.Id == answer.Form990LineId);
if (line is not null)
{
suggestion.Form990LineId = line.Id;
suggestion.Form990LineLabel = Label(line);
}
return suggestion;
}
/// <summary>Mirror the frontend dropdown label: "code — English / 中文" (or just "code — English").</summary>
private static string Label(CatalogLine line)
=> string.IsNullOrWhiteSpace(line.NameZh)
? $"{line.LineCode} — {line.NameEn}"
: $"{line.LineCode} — {line.NameEn} / {line.NameZh}";
}
@@ -0,0 +1,30 @@
namespace ROLAC.API.Services.Ai;
/// <summary>Selects the active category-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
public interface IExpenseCategoryAiServiceFactory
{
Task<IExpenseCategoryAiService> ResolveAsync(CancellationToken ct = default);
}
public sealed class ExpenseCategoryAiServiceFactory : IExpenseCategoryAiServiceFactory
{
private readonly IChurchAiConfigProvider _config;
private readonly ClaudeExpenseCategoryAiService _claude;
private readonly GeminiExpenseCategoryAiService _gemini;
public ExpenseCategoryAiServiceFactory(
IChurchAiConfigProvider config,
ClaudeExpenseCategoryAiService claude,
GeminiExpenseCategoryAiService gemini)
{
_config = config;
_claude = claude;
_gemini = gemini;
}
public async Task<IExpenseCategoryAiService> ResolveAsync(CancellationToken ct = default)
{
var cfg = await _config.GetAsync(ct);
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
}
}
@@ -0,0 +1,119 @@
using System.Net.Http.Json;
using System.Text.Json;
using ROLAC.API.Data;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// Refines, translates, and maps an expense category to a Form 990 line via the Google Gemini
/// <c>generateContent</c> API, using Gemini's structured-output mode (<c>responseSchema</c>). The
/// catalog, prompt, and id validation come from <see cref="ExpenseCategoryAiServiceBase"/>; this class
/// only owns the Gemini HTTP call + parse.
/// </summary>
public sealed class GeminiExpenseCategoryAiService : ExpenseCategoryAiServiceBase
{
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
private readonly HttpClient _http;
private readonly IChurchAiConfigProvider _config;
private readonly ILogger<GeminiExpenseCategoryAiService> _logger;
public GeminiExpenseCategoryAiService(
HttpClient http,
IChurchAiConfigProvider config,
AppDbContext db,
ILogger<GeminiExpenseCategoryAiService> logger)
: base(db)
{
_http = http;
_config = config;
_logger = logger;
}
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
{
var cfg = await _config.GetAsync(ct);
if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey))
{
_logger.LogWarning("Gemini API key is not configured; category AI assist is disabled.");
return null;
}
try
{
var payload = new
{
contents = new[]
{
new { parts = new[] { new { text = prompt } } },
},
generationConfig = new
{
responseMimeType = "application/json",
responseSchema = new
{
type = "object",
properties = new
{
chineseName = new { type = "string" },
englishName = new { type = "string" },
form990LineId = new { type = "integer" },
confidence = new { type = "number" },
},
required = new[] { "chineseName", "englishName", "form990LineId", "confidence" },
},
},
};
var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent";
using var request = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = JsonContent.Create(payload),
};
request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey);
using var response = await _http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body);
return null;
}
// Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string.
using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct));
var text = doc.RootElement
.GetProperty("candidates")[0]
.GetProperty("content")
.GetProperty("parts")[0]
.GetProperty("text")
.GetString();
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("Gemini response contained no text part.");
return null;
}
var parsed = JsonSerializer.Deserialize<GeminiAnswer>(
text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (parsed is null) return null;
return new ModelAnswer(parsed.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence);
}
catch (Exception ex)
{
_logger.LogError(ex, "Gemini category AI assist failed.");
return null;
}
}
/// <summary>Shape of Gemini's JSON answer (constrained by responseSchema).</summary>
private sealed class GeminiAnswer
{
public string? ChineseName { get; set; }
public string? EnglishName { get; set; }
public int? Form990LineId { get; set; }
public double Confidence { get; set; }
}
}
@@ -0,0 +1,17 @@
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// AI assistance for defining an expense category (大項/小項): refine the Chinese name, translate it
/// to English, and suggest the matching IRS Form 990 Part IX line.
/// </summary>
public interface IExpenseCategoryAiService
{
/// <summary>
/// Refine the entered name, translate it to concise accounting English, and choose the best Form 990
/// line from the live catalog (biased by the group/sub context in <paramref name="request"/>).
/// Never throws on an upstream/AI failure — returns a suggestion with null fields instead.
/// </summary>
Task<CategoryAiSuggestion> SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default);
}