diff --git a/API/ROLAC.API/Controllers/ExpenseAiController.cs b/API/ROLAC.API/Controllers/ExpenseAiController.cs new file mode 100644 index 0000000..5041fe9 --- /dev/null +++ b/API/ROLAC.API/Controllers/ExpenseAiController.cs @@ -0,0 +1,26 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using ROLAC.API.DTOs.Expense; +using ROLAC.API.Services.Ai; + +namespace ROLAC.API.Controllers; + +[ApiController] +[Route("api/expense-ai")] +[Authorize] // Open to any authenticated user — same audience as the expense-entry form, which any + // member filing a reimbursement can reach. The endpoint only reads the category catalog. +public class ExpenseAiController : ControllerBase +{ + private readonly IExpenseAiService _svc; + public ExpenseAiController(IExpenseAiService svc) => _svc = svc; + + [HttpPost("assist")] + public async Task Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Text)) + return BadRequest("Text is required."); + + var suggestion = await _svc.SuggestAsync(request.Text, request.Amount, ct); + return Ok(suggestion); + } +} diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs new file mode 100644 index 0000000..86e817a --- /dev/null +++ b/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; +namespace ROLAC.API.DTOs.Expense; + +/// Request body for the expense AI assist endpoint. +public class ExpenseAiAssistRequest +{ + /// The user's free-text expense description (typically Chinese). + [Required] public string Text { get; set; } = ""; + /// The expense amount, used as a hint when classifying the category. + public decimal Amount { get; set; } +} + +/// +/// AI suggestion for an expense: an English translation of the description plus a proposed +/// major category (大項) and sub-category (系項). Category ids are null when the model could +/// not confidently classify or returned an id outside the live catalog. +/// +public class ExpenseAiSuggestion +{ + public string? EnglishDescription { get; set; } + public int? GroupId { get; set; } + public int? SubCategoryId { get; set; } + /// Bilingual label of the suggested group, e.g. "Consumables / 消耗品". + public string? GroupLabel { get; set; } + /// Bilingual label of the suggested sub-category, e.g. "Batteries / 電池". + public string? SubLabel { get; set; } + /// Model self-reported confidence in the classification, 0..1. + public double Confidence { get; set; } +} diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 2bcd1f5..3d15ab6 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -179,6 +179,12 @@ builder.Services.AddScoped(); +// ── AI assist (Google Gemini) ────────────────────────────────────────────── +// Backend proxy for expense translation + category suggestion; the API key stays server-side. +builder.Services.Configure(config.GetSection("Gemini")); +builder.Services.AddHttpClient(); + // --------------------------------------------------------------------------- // Configurable role-based permissions (RBAC matrix) // --------------------------------------------------------------------------- diff --git a/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs b/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs new file mode 100644 index 0000000..e2e9418 --- /dev/null +++ b/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs @@ -0,0 +1,181 @@ +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; } + } +} diff --git a/API/ROLAC.API/Services/Ai/GeminiOptions.cs b/API/ROLAC.API/Services/Ai/GeminiOptions.cs new file mode 100644 index 0000000..1afa2d9 --- /dev/null +++ b/API/ROLAC.API/Services/Ai/GeminiOptions.cs @@ -0,0 +1,10 @@ +namespace ROLAC.API.Services.Ai; + +/// Google Gemini API settings (bound from the "Gemini" config section). +public sealed class GeminiOptions +{ + /// API key sent as the X-goog-api-key header. Keep out of source control. + public string ApiKey { get; set; } = ""; + public string Model { get; set; } = "gemini-2.5-flash"; + public string BaseUrl { get; set; } = "https://generativelanguage.googleapis.com/v1beta"; +} diff --git a/API/ROLAC.API/Services/Ai/IExpenseAiService.cs b/API/ROLAC.API/Services/Ai/IExpenseAiService.cs new file mode 100644 index 0000000..54cf1d9 --- /dev/null +++ b/API/ROLAC.API/Services/Ai/IExpenseAiService.cs @@ -0,0 +1,14 @@ +using ROLAC.API.DTOs.Expense; + +namespace ROLAC.API.Services.Ai; + +/// AI assistance for expense entry: translate a description and suggest a category. +public interface IExpenseAiService +{ + /// + /// Translate to concise accounting English and suggest the best + /// major/sub category from the live catalog, using as a hint. + /// Never throws on an upstream/AI failure — returns a suggestion with null fields instead. + /// + Task SuggestAsync(string chineseText, decimal amount, CancellationToken ct = default); +} diff --git a/API/ROLAC.API/appsettings.json b/API/ROLAC.API/appsettings.json index 5412db7..a92dd22 100644 --- a/API/ROLAC.API/appsettings.json +++ b/API/ROLAC.API/appsettings.json @@ -41,5 +41,10 @@ "Line": { "ChannelAccessToken": "", "ChannelSecret": "" + }, + "Gemini": { + "ApiKey": "", + "Model": "gemini-flash-latest", + "BaseUrl": "https://generativelanguage.googleapis.com/v1beta" } } diff --git a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html index fb5ccde..54ff5bf 100644 --- a/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html +++ b/APP/src/app/features/expense/components/expense-form-dialog/expense-form-dialog.component.html @@ -10,10 +10,37 @@ 連續登打 / Continuous Entry - - + +
+
+ + +
+ + +
+
AI 建議 / Suggestion
+
+ English: + {{ aiSuggestion?.englishDescription }} +
+
+ 分類 / Category: + {{ aiSuggestion?.groupLabel }} → {{ aiSuggestion?.subLabel }} +
+
信心 / Confidence: {{ (aiSuggestion?.confidence ?? 0) * 100 | number:'1.0-0' }}%
+
+ + +
+
+