WIP
This commit is contained in:
@@ -1,23 +1,19 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ROLAC.API.Data;
|
||||
using ROLAC.API.DTOs.Expense;
|
||||
|
||||
namespace ROLAC.API.Services.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Calls the Google Gemini <c>generateContent</c> API to translate an expense description and
|
||||
/// classify it into the church's existing expense category catalog (大項 / 系項). The full active
|
||||
/// catalog is sent in the prompt so the model can only choose from real ids; any id it returns is
|
||||
/// re-validated against the catalog before being surfaced, so a hallucinated id is dropped, not echoed.
|
||||
/// Translates and classifies an expense via the Google Gemini <c>generateContent</c> API, using
|
||||
/// Gemini's structured-output mode (<c>responseSchema</c>). The catalog, prompt, and id validation
|
||||
/// come from <see cref="ExpenseAiServiceBase"/>; this class only owns the Gemini HTTP call + parse.
|
||||
/// </summary>
|
||||
public sealed class GeminiExpenseAiService : IExpenseAiService
|
||||
public sealed class GeminiExpenseAiService : ExpenseAiServiceBase
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly GeminiOptions _options;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<GeminiExpenseAiService> _logger;
|
||||
|
||||
public GeminiExpenseAiService(
|
||||
@@ -25,57 +21,23 @@ public sealed class GeminiExpenseAiService : IExpenseAiService
|
||||
IOptions<GeminiOptions> options,
|
||||
AppDbContext db,
|
||||
ILogger<GeminiExpenseAiService> logger)
|
||||
: base(db)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ExpenseAiSuggestion> SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default)
|
||||
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
|
||||
{
|
||||
// Load the active catalog: the allow-list the model must classify into.
|
||||
var groups = await _db.ExpenseCategoryGroups
|
||||
.AsNoTracking()
|
||||
.Where(group => group.IsActive)
|
||||
.OrderBy(group => group.SortOrder)
|
||||
.Select(group => new
|
||||
{
|
||||
group.Id,
|
||||
group.Name_en,
|
||||
group.Name_zh,
|
||||
Subs = group.SubCategories
|
||||
.Where(sub => sub.IsActive)
|
||||
.OrderBy(sub => sub.SortOrder)
|
||||
.Select(sub => new { sub.Id, sub.Name_en, sub.Name_zh })
|
||||
.ToList(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_options.ApiKey))
|
||||
{
|
||||
_logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled.");
|
||||
return new ExpenseAiSuggestion();
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var catalogJson = JsonSerializer.Serialize(groups);
|
||||
var prompt =
|
||||
"You are a bookkeeping assistant for a church. Given an expense description (often in " +
|
||||
"Traditional Chinese) and its amount, do two things:\n" +
|
||||
"1. Translate the description into concise, natural accounting English (a short noun phrase, " +
|
||||
"not a full sentence).\n" +
|
||||
"2. 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 sub-categories):\n{catalogJson}\n\n" +
|
||||
"Respond with JSON: englishDescription (string), groupId (integer), subCategoryId (integer), " +
|
||||
"confidence (number 0..1).";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
contents = new[]
|
||||
@@ -90,12 +52,13 @@ public sealed class GeminiExpenseAiService : IExpenseAiService
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
chineseDescription = new { type = "string" },
|
||||
englishDescription = new { type = "string" },
|
||||
groupId = new { type = "integer" },
|
||||
subCategoryId = new { type = "integer" },
|
||||
confidence = new { type = "number" },
|
||||
},
|
||||
required = new[] { "englishDescription", "groupId", "subCategoryId", "confidence" },
|
||||
required = new[] { "chineseDescription", "englishDescription", "groupId", "subCategoryId", "confidence" },
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -112,7 +75,7 @@ public sealed class GeminiExpenseAiService : IExpenseAiService
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
_logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body);
|
||||
return new ExpenseAiSuggestion();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string.
|
||||
@@ -127,53 +90,27 @@ public sealed class GeminiExpenseAiService : IExpenseAiService
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
_logger.LogWarning("Gemini response contained no text part.");
|
||||
return new ExpenseAiSuggestion();
|
||||
return null;
|
||||
}
|
||||
|
||||
var parsed = JsonSerializer.Deserialize<GeminiAnswer>(
|
||||
text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
if (parsed is null) return new ExpenseAiSuggestion();
|
||||
if (parsed is null) return null;
|
||||
|
||||
var suggestion = new ExpenseAiSuggestion
|
||||
{
|
||||
EnglishDescription = string.IsNullOrWhiteSpace(parsed.EnglishDescription)
|
||||
? null
|
||||
: parsed.EnglishDescription.Trim(),
|
||||
Confidence = parsed.Confidence,
|
||||
};
|
||||
|
||||
// Re-validate the returned ids against the catalog; drop anything that doesn't line up.
|
||||
var group = groups.FirstOrDefault(candidate => candidate.Id == parsed.GroupId);
|
||||
if (group is not null)
|
||||
{
|
||||
suggestion.GroupId = group.Id;
|
||||
suggestion.GroupLabel = Label(group.Name_en, group.Name_zh);
|
||||
|
||||
var sub = group.Subs.FirstOrDefault(candidate => candidate.Id == parsed.SubCategoryId);
|
||||
if (sub is not null)
|
||||
{
|
||||
suggestion.SubCategoryId = sub.Id;
|
||||
suggestion.SubLabel = Label(sub.Name_en, sub.Name_zh);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestion;
|
||||
return new ModelAnswer(parsed.EnglishDescription, parsed.ChineseDescription, parsed.GroupId, parsed.SubCategoryId, parsed.Confidence);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Expense AI assist failed.");
|
||||
return new ExpenseAiSuggestion();
|
||||
_logger.LogError(ex, "Gemini expense AI assist failed.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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}";
|
||||
|
||||
/// <summary>Shape of the model's JSON answer (constrained by responseSchema).</summary>
|
||||
/// <summary>Shape of Gemini's JSON answer (constrained by responseSchema).</summary>
|
||||
private sealed class GeminiAnswer
|
||||
{
|
||||
public string? EnglishDescription { get; set; }
|
||||
public string? ChineseDescription { get; set; }
|
||||
public int GroupId { get; set; }
|
||||
public int SubCategoryId { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
|
||||
Reference in New Issue
Block a user