120 lines
5.5 KiB
C#
120 lines
5.5 KiB
C#
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}";
|
|
}
|