using System.Text.Json; using Microsoft.EntityFrameworkCore; using ROLAC.API.Data; using ROLAC.API.DTOs.Expense; namespace ROLAC.API.Services.Ai; /// /// Provider-independent expense-AI logic: loads the active category catalog, builds the /// classification prompt, and validates the model's chosen ids 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. /// public abstract class ExpenseAiServiceBase : IExpenseAiService { private readonly AppDbContext _db; protected ExpenseAiServiceBase(AppDbContext db) => _db = db; /// One sub-category in the catalog passed to the model. protected sealed record CatalogSub(int Id, string NameEn, string? NameZh); /// One major category (with its sub-categories) in the catalog passed to the model. protected sealed record CatalogGroup(int Id, string NameEn, string? NameZh, IReadOnlyList Subs); /// The model's raw answer, before its ids are validated against the catalog. protected sealed record ModelAnswer( string? EnglishDescription, string? ChineseDescription, int GroupId, int SubCategoryId, double Confidence); public async Task SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default) { var catalog = await LoadCatalogAsync(ct); var prompt = BuildPrompt(chineseText, amount, catalog); var answer = await CallModelAsync(prompt, ct); if (answer is null) return new ExpenseAiSuggestion(); 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.ExpenseCategoryGroups .AsNoTracking() .Where(group => group.IsActive) .OrderBy(group => group.SortOrder) .Select(group => new CatalogGroup( group.Id, group.Name_en, group.Name_zh, group.SubCategories .Where(sub => sub.IsActive) .OrderBy(sub => sub.SortOrder) .Select(sub => new CatalogSub(sub.Id, sub.Name_en, sub.Name_zh)) .ToList())) .ToListAsync(ct); } private static string BuildPrompt(string chineseText, decimal amount, List catalog) { var catalogJson = JsonSerializer.Serialize(catalog); return "You are a bookkeeping assistant for a church. Given an expense description (often in " + "Traditional Chinese) and its amount, do three things:\n" + "1. Correct any typos in the description and refine it into natural Traditional Chinese — " + "return it as chineseDescription.\n" + "2. Translate that into concise, natural accounting English (a short noun phrase, not a " + "full sentence) — return it as englishDescription.\n" + "3. Choose the single best matching major category (group) and sub-category from the catalog " + "below. You MUST pick a groupId and subCategoryId that appear in the catalog, and the " + "subCategoryId must belong to that groupId. If nothing fits well, choose the closest " + "\"Other / 其他\" option and lower your confidence.\n\n" + $"Expense description: {chineseText}\n" + $"Amount: {amount}\n\n" + $"Category catalog (JSON; each group has an Id, English/Chinese names, and its Subs):\n{catalogJson}"; } private static ExpenseAiSuggestion BuildSuggestion(ModelAnswer answer, List catalog) { var suggestion = new ExpenseAiSuggestion { EnglishDescription = string.IsNullOrWhiteSpace(answer.EnglishDescription) ? null : answer.EnglishDescription.Trim(), ChineseDescription = string.IsNullOrWhiteSpace(answer.ChineseDescription) ? null : answer.ChineseDescription.Trim(), Confidence = answer.Confidence, }; // Re-validate the returned ids against the catalog; drop anything that doesn't line up // (defends against a hallucinated id, or a sub-category that doesn't belong to the group). var group = catalog.FirstOrDefault(candidate => candidate.Id == answer.GroupId); if (group is not null) { suggestion.GroupId = group.Id; suggestion.GroupLabel = Label(group.NameEn, group.NameZh); var sub = group.Subs.FirstOrDefault(candidate => candidate.Id == answer.SubCategoryId); if (sub is not null) { suggestion.SubCategoryId = sub.Id; suggestion.SubLabel = Label(sub.NameEn, sub.NameZh); } } return suggestion; } /// Mirror the frontend's bilingual() convention: "English / 中文" (or just English). private static string Label(string nameEn, string? nameZh) => string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}"; }