using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Expense; namespace ROLAC.API.Services.Ai; /// /// 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 — the HTTP call plus response parsing — /// so the catalog/prompt/validation code lives in exactly one place. Mirrors /// , which does the same for the expense-entry classification task. /// public abstract class ExpenseCategoryAiServiceBase : IExpenseCategoryAiService { private readonly AppDbContext _db; protected ExpenseCategoryAiServiceBase(AppDbContext db) => _db = db; /// One Form 990 line in the catalog passed to the model. protected sealed record CatalogLine(int Id, string LineCode, string NameEn, string? NameZh); /// The model's raw answer, before its line id is validated against the catalog. protected sealed record ModelAnswer(string? ChineseName, string? EnglishName, int? Form990LineId, double Confidence); public async Task 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); } /// /// Call the provider's API with and return its parsed answer, or null /// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw. /// protected abstract Task CallModelAsync(string prompt, CancellationToken ct); private async Task> 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 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 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; } /// Mirror the frontend dropdown label: "code — English / 中文" (or just "code — English"). private static string Label(CatalogLine line) => string.IsNullOrWhiteSpace(line.NameZh) ? $"{line.LineCode} — {line.NameEn}" : $"{line.LineCode} — {line.NameEn} / {line.NameZh}"; }