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; /// /// Calls the Google Gemini generateContent 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. /// public sealed class GeminiExpenseAiService : IExpenseAiService { private readonly HttpClient _http; private readonly GeminiOptions _options; private readonly AppDbContext _db; private readonly ILogger _logger; public GeminiExpenseAiService( HttpClient http, IOptions options, AppDbContext db, ILogger logger) { _http = http; _options = options.Value; _db = db; _logger = logger; } public async Task SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default) { // 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(); } 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[] { new { parts = new[] { new { text = prompt } } }, }, generationConfig = new { responseMimeType = "application/json", responseSchema = new { type = "object", properties = new { englishDescription = new { type = "string" }, groupId = new { type = "integer" }, subCategoryId = new { type = "integer" }, confidence = new { type = "number" }, }, required = new[] { "englishDescription", "groupId", "subCategoryId", "confidence" }, }, }, }; var url = $"{_options.BaseUrl}/models/{_options.Model}:generateContent"; using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload), }; request.Headers.Add("X-goog-api-key", _options.ApiKey); using var response = await _http.SendAsync(request, ct); if (!response.IsSuccessStatusCode) { var body = await response.Content.ReadAsStringAsync(ct); _logger.LogWarning("Gemini returned {Status}: {Body}", (int)response.StatusCode, body); return new ExpenseAiSuggestion(); } // Navigate candidates[0].content.parts[0].text — the model's JSON answer as a string. using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct)); var text = doc.RootElement .GetProperty("candidates")[0] .GetProperty("content") .GetProperty("parts")[0] .GetProperty("text") .GetString(); if (string.IsNullOrWhiteSpace(text)) { _logger.LogWarning("Gemini response contained no text part."); return new ExpenseAiSuggestion(); } var parsed = JsonSerializer.Deserialize( text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (parsed is null) return new ExpenseAiSuggestion(); 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; } catch (Exception ex) { _logger.LogError(ex, "Expense AI assist failed."); return new ExpenseAiSuggestion(); } } /// Mirror the frontend's bilingual() convention: "English / 中文" (or just English). private static string Label(string nameEn, string? nameZh) => string.IsNullOrWhiteSpace(nameZh) ? nameEn : $"{nameEn} / {nameZh}"; /// Shape of the model's JSON answer (constrained by responseSchema). private sealed class GeminiAnswer { public string? EnglishDescription { get; set; } public int GroupId { get; set; } public int SubCategoryId { get; set; } public double Confidence { get; set; } } }