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