This commit is contained in:
Chris Chen
2026-06-25 12:38:13 -07:00
parent a89e936f4d
commit bdccb79029
11 changed files with 416 additions and 103 deletions
@@ -0,0 +1,119 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.DTOs.Expense;
namespace ROLAC.API.Services.Ai;
/// <summary>
/// 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 <see cref="CallModelAsync"/> — the HTTP call plus
/// response parsing — so the catalog/prompt/validation code lives in exactly one place.
/// </summary>
public abstract class ExpenseAiServiceBase : IExpenseAiService
{
private readonly AppDbContext _db;
protected ExpenseAiServiceBase(AppDbContext db) => _db = db;
/// <summary>One sub-category in the catalog passed to the model.</summary>
protected sealed record CatalogSub(int Id, string NameEn, string? NameZh);
/// <summary>One major category (with its sub-categories) in the catalog passed to the model.</summary>
protected sealed record CatalogGroup(int Id, string NameEn, string? NameZh, IReadOnlyList<CatalogSub> Subs);
/// <summary>The model's raw answer, before its ids are validated against the catalog.</summary>
protected sealed record ModelAnswer(
string? EnglishDescription, string? ChineseDescription, int GroupId, int SubCategoryId, double Confidence);
public async Task<ExpenseAiSuggestion> 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);
}
/// <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<CatalogGroup>> 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<CatalogGroup> 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<CatalogGroup> 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;
}
/// <summary>Mirror the frontend's bilingual() convention: "English / 中文" (or just English).</summary>
private static string Label(string nameEn, string? nameZh)
=> string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}";
}