feat(expense-categories): AI 建議 for group/sub name + 990 line
ci-cd-vm / ci-cd (push) Successful in 2m25s
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:
@@ -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}";
|
||||
}
|
||||
Reference in New Issue
Block a user