feat(expense-categories): AI 建議 for group/sub name + 990 line
ci-cd-vm / ci-cd (push) Successful in 2m25s
ci-cd-vm / ci-cd (push) Successful in 2m25s
Add an AI assist button to the Edit/New Group (大項) and Subcategory (小項) dialogs: the user enters a Chinese name, and the model refines the Chinese, translates it to English, and suggests the matching IRS Form 990 Part IX line. Suggestions surface in a confirm card; Apply fills the Chinese name, English name, and 990 line fields. Backend mirrors the existing expense-classification AI family but over the Form 990 line catalog: IExpenseCategoryAiService + base (catalog load, prompt, id validation) + Claude/Gemini providers + factory that picks the provider from ChurchProfile.AiProvider. New write-gated POST api/expense-categories/ai-suggest endpoint; sub-category requests pass the parent group + its 990 line to bias the choice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using ROLAC.API.Data;
|
||||
|
||||
namespace ROLAC.API.Services.Ai;
|
||||
|
||||
/// <summary>
|
||||
/// Refines, translates, and maps an expense category to a Form 990 line 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="ExpenseCategoryAiServiceBase"/>; this class
|
||||
/// only owns the Gemini HTTP call + parse.
|
||||
/// </summary>
|
||||
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<GeminiExpenseCategoryAiService> _logger;
|
||||
|
||||
public GeminiExpenseCategoryAiService(
|
||||
HttpClient http,
|
||||
IChurchAiConfigProvider config,
|
||||
AppDbContext db,
|
||||
ILogger<GeminiExpenseCategoryAiService> logger)
|
||||
: base(db)
|
||||
{
|
||||
_http = http;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task<ModelAnswer?> 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<GeminiAnswer>(
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shape of Gemini's JSON answer (constrained by responseSchema).</summary>
|
||||
private sealed class GeminiAnswer
|
||||
{
|
||||
public string? ChineseName { get; set; }
|
||||
public string? EnglishName { get; set; }
|
||||
public int? Form990LineId { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user