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}";
}