using System.Net.Http.Json; using System.Text.Json; using ROLAC.API.Data; namespace ROLAC.API.Services.Ai; /// /// Refines, translates, and maps an expense category to a Form 990 line via the Google Gemini /// generateContent API, using Gemini's structured-output mode (responseSchema). The /// catalog, prompt, and id validation come from ; this class /// only owns the Gemini HTTP call + parse. /// public sealed class GeminiExpenseCategoryAiService : ExpenseCategoryAiServiceBase { private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta"; private readonly HttpClient _http; private readonly IChurchAiConfigProvider _config; private readonly ILogger _logger; public GeminiExpenseCategoryAiService( HttpClient http, IChurchAiConfigProvider config, AppDbContext db, ILogger logger) : base(db) { _http = http; _config = config; _logger = logger; } protected override async Task CallModelAsync(string prompt, CancellationToken ct) { var cfg = await _config.GetAsync(ct); if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey)) { _logger.LogWarning("Gemini API key is not configured; category AI assist is disabled."); return null; } try { var payload = new { contents = new[] { new { parts = new[] { new { text = prompt } } }, }, generationConfig = new { responseMimeType = "application/json", responseSchema = new { type = "object", properties = new { chineseName = new { type = "string" }, englishName = new { type = "string" }, form990LineId = new { type = "integer" }, confidence = new { type = "number" }, }, required = new[] { "chineseName", "englishName", "form990LineId", "confidence" }, }, }, }; var url = $"{BaseUrl}/models/{cfg.GeminiModel}:generateContent"; using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload), }; request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey); 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 null; } // 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 null; } var parsed = JsonSerializer.Deserialize( text, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (parsed is null) return null; return new ModelAnswer(parsed.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence); } catch (Exception ex) { _logger.LogError(ex, "Gemini category AI assist failed."); return null; } } /// Shape of Gemini's JSON answer (constrained by responseSchema). private sealed class GeminiAnswer { public string? ChineseName { get; set; } public string? EnglishName { get; set; } public int? Form990LineId { get; set; } public double Confidence { get; set; } } }