WIP
This commit is contained in:
@@ -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}";
|
||||
}
|
||||
Reference in New Issue
Block a user