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; }
}
}