From 73077295a405ed488fbc0655b20cea74d42598cb Mon Sep 17 00:00:00 2001 From: Chris Chen Date: Thu, 25 Jun 2026 14:18:09 -0700 Subject: [PATCH] =?UTF-8?q?feat(expense-categories):=20AI=20=E5=BB=BA?= =?UTF-8?q?=E8=AD=B0=20for=20group/sub=20name=20+=20990=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ExpenseCategoryAiServiceFactoryTests.cs | 69 ++++++++++ .../ExpenseCategoriesController.cs | 21 ++- API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs | 34 +++++ API/ROLAC.API/Program.cs | 6 + .../Ai/ClaudeExpenseCategoryAiService.cs | 124 ++++++++++++++++++ .../Ai/ExpenseCategoryAiServiceBase.cs | 116 ++++++++++++++++ .../Ai/ExpenseCategoryAiServiceFactory.cs | 30 +++++ .../Ai/GeminiExpenseCategoryAiService.cs | 119 +++++++++++++++++ .../Services/Ai/IExpenseCategoryAiService.cs | 17 +++ .../expense-form-dialog.component.html | 2 +- .../features/expense/models/expense.model.ts | 17 +++ .../expense-categories-page.component.html | 76 +++++++++-- .../expense-categories-page.component.ts | 58 +++++++- .../services/expense-category-api.service.ts | 4 + 14 files changed, 682 insertions(+), 11 deletions(-) create mode 100644 API/ROLAC.API.Tests/Services/ExpenseCategoryAiServiceFactoryTests.cs create mode 100644 API/ROLAC.API/Services/Ai/ClaudeExpenseCategoryAiService.cs create mode 100644 API/ROLAC.API/Services/Ai/ExpenseCategoryAiServiceBase.cs create mode 100644 API/ROLAC.API/Services/Ai/ExpenseCategoryAiServiceFactory.cs create mode 100644 API/ROLAC.API/Services/Ai/GeminiExpenseCategoryAiService.cs create mode 100644 API/ROLAC.API/Services/Ai/IExpenseCategoryAiService.cs diff --git a/API/ROLAC.API.Tests/Services/ExpenseCategoryAiServiceFactoryTests.cs b/API/ROLAC.API.Tests/Services/ExpenseCategoryAiServiceFactoryTests.cs new file mode 100644 index 0000000..4b871e7 --- /dev/null +++ b/API/ROLAC.API.Tests/Services/ExpenseCategoryAiServiceFactoryTests.cs @@ -0,0 +1,69 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using ROLAC.API.Data; +using ROLAC.API.Data.Interceptors; +using ROLAC.API.Entities; +using ROLAC.API.Services.Ai; +using ROLAC.API.Services.Logging; +using Xunit; + +namespace ROLAC.API.Tests.Services; + +public class ExpenseCategoryAiServiceFactoryTests +{ + // ChurchProfile is auditable, so the InMemory store rejects saves unless the + // required CreatedBy/UpdatedBy fields are populated. Wire the same audit + // interceptor the app uses so seeded entities save cleanly. + private static AppDbContext NewDb() + { + var httpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, "test-user") })), + }; + var httpContextAccessor = new Mock(); + httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext); + return new AppDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .ConfigureWarnings(warnings => warnings.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .AddInterceptors(new AuditSaveChangesInterceptor(new CurrentUserAccessor(httpContextAccessor.Object))) + .Options); + } + + private static ExpenseCategoryAiServiceFactory Build(AppDbContext db) + { + var cfg = new ChurchAiConfigProvider(db); + var claude = new ClaudeExpenseCategoryAiService( + new HttpClient(), cfg, db, NullLogger.Instance); + var gemini = new GeminiExpenseCategoryAiService( + new HttpClient(), cfg, db, NullLogger.Instance); + return new ExpenseCategoryAiServiceFactory(cfg, claude, gemini); + } + + [Fact] + public async Task Resolves_Claude_by_default() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Claude" }); + await db.SaveChangesAsync(); + + var svc = await Build(db).ResolveAsync(); + + Assert.IsType(svc); + } + + [Fact] + public async Task Resolves_Gemini_when_selected() + { + using var db = NewDb(); + db.ChurchProfiles.Add(new ChurchProfile { Name = "C", AiProvider = "Gemini" }); + await db.SaveChangesAsync(); + + var svc = await Build(db).ResolveAsync(); + + Assert.IsType(svc); + } +} diff --git a/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs b/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs index b209987..703118b 100644 --- a/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs +++ b/API/ROLAC.API/Controllers/ExpenseCategoriesController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using ROLAC.API.Authorization; using ROLAC.API.DTOs.Expense; using ROLAC.API.Services; +using ROLAC.API.Services.Ai; namespace ROLAC.API.Controllers; @@ -13,12 +14,30 @@ namespace ROLAC.API.Controllers; public class ExpenseCategoriesController : ControllerBase { private readonly IExpenseCategoryService _svc; - public ExpenseCategoriesController(IExpenseCategoryService svc) => _svc = svc; + private readonly IExpenseCategoryAiServiceFactory _aiFactory; + public ExpenseCategoriesController(IExpenseCategoryService svc, IExpenseCategoryAiServiceFactory aiFactory) + { + _svc = svc; + _aiFactory = aiFactory; + } [HttpGet] public async Task GetAll([FromQuery] bool includeInactive = false) => Ok(await _svc.GetAllAsync(includeInactive)); + // Suggest an English name + Form 990 line for a category being defined. Write-gated: category + // editing is finance/admin-only, unlike the member-facing expense-ai/assist endpoint. + [HttpPost("ai-suggest")] + [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)] + public async Task AiSuggest([FromBody] ExpenseCategoryAiRequest r, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(r.Name_zh) && string.IsNullOrWhiteSpace(r.Name_en)) + return BadRequest("A name is required."); + + var svc = await _aiFactory.ResolveAsync(ct); + return Ok(await svc.SuggestAsync(r, ct)); + } + [HttpPost("groups")] [HasPermission(Modules.ExpenseCategories, PermissionActions.Write)] public async Task CreateGroup([FromBody] CreateExpenseGroupRequest r) diff --git a/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs b/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs index 601e363..6e0ac5b 100644 --- a/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs +++ b/API/ROLAC.API/DTOs/Expense/ExpenseAiDtos.cs @@ -29,3 +29,37 @@ public class ExpenseAiSuggestion /// Model self-reported confidence in the classification, 0..1. public double Confidence { get; set; } } + +/// +/// Request body for the expense-category AI assist endpoint: refine the name, translate to English, +/// and suggest a Form 990 line for an expense category (大項/小項) being defined or edited. +/// +public class ExpenseCategoryAiRequest +{ + /// The user-typed Chinese name (the primary input). + public string Name_zh { get; set; } = ""; + /// The English name, if already typed (extra context for the model). + public string? Name_en { get; set; } + /// "group" (大項) or "sub" (小項); selects the prompt framing. + public string Level { get; set; } = "group"; + /// For a sub-category: the parent group's bilingual name, used for context. + public string? ParentGroupName { get; set; } + /// For a sub-category: the parent group's mapped Form 990 line id, used to bias the choice. + public int? ParentForm990LineId { get; set; } +} + +/// +/// AI suggestion for an expense category: a refined Chinese name, an English translation, and a +/// proposed Form 990 line. Line fields are null when the model returned an id outside the live catalog. +/// +public class CategoryAiSuggestion +{ + /// Typo-corrected, refined Traditional Chinese name. + public string? ChineseName { get; set; } + public string? EnglishName { get; set; } + public int? Form990LineId { get; set; } + /// Bilingual label of the suggested line, e.g. "16 — Occupancy / 場地". + public string? Form990LineLabel { get; set; } + /// Model self-reported confidence in the mapping, 0..1. + public double Confidence { get; set; } +} diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index cd78161..ccc04c8 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -190,6 +190,12 @@ builder.Services.AddScoped(); +// Category-mapping AI (define a 大項/小項: refine name + translate + suggest Form 990 line). +builder.Services.AddHttpClient(); +builder.Services.AddHttpClient(); +builder.Services.AddScoped(); + // --------------------------------------------------------------------------- // Configurable role-based permissions (RBAC matrix) // --------------------------------------------------------------------------- diff --git a/API/ROLAC.API/Services/Ai/ClaudeExpenseCategoryAiService.cs b/API/ROLAC.API/Services/Ai/ClaudeExpenseCategoryAiService.cs new file mode 100644 index 0000000..6b859e1 --- /dev/null +++ b/API/ROLAC.API/Services/Ai/ClaudeExpenseCategoryAiService.cs @@ -0,0 +1,124 @@ +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 Anthropic Claude +/// Messages API. It forces a single tool call (tool_choicemap_category) whose +/// input_schema matches our answer shape, so the model returns structured JSON in a +/// tool_use block. The catalog, prompt, and id validation come from +/// ; this class only owns the Claude HTTP call + parse. +/// +public sealed class ClaudeExpenseCategoryAiService : ExpenseCategoryAiServiceBase +{ + private const string BaseUrl = "https://api.anthropic.com/v1"; + private const string AnthropicVersion = "2023-06-01"; + + private readonly HttpClient _http; + private readonly IChurchAiConfigProvider _config; + private readonly ILogger _logger; + + public ClaudeExpenseCategoryAiService( + 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.ClaudeApiKey)) + { + _logger.LogWarning("Claude API key is not configured; category AI assist is disabled."); + return null; + } + + try + { + var payload = new + { + model = cfg.ClaudeModel, + max_tokens = 1024, + tools = new[] + { + new + { + name = "map_category", + description = "Record the refined Chinese name, English translation, and chosen Form 990 line id for the expense category.", + input_schema = 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" }, + }, + }, + }, + tool_choice = new { type = "tool", name = "map_category" }, + messages = new[] + { + new { role = "user", content = prompt }, + }, + }; + + var url = $"{BaseUrl}/messages"; + using var request = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonContent.Create(payload), + }; + request.Headers.Add("x-api-key", cfg.ClaudeApiKey); + request.Headers.Add("anthropic-version", AnthropicVersion); + + using var response = await _http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) + { + var body = await response.Content.ReadAsStringAsync(ct); + _logger.LogWarning("Claude returned {Status}: {Body}", (int)response.StatusCode, body); + return null; + } + + // The forced tool call lands in content[] as a tool_use block; its `input` is our object. + using var doc = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(ct)); + foreach (var block in doc.RootElement.GetProperty("content").EnumerateArray()) + { + if (block.GetProperty("type").GetString() != "tool_use") continue; + + var parsed = block.GetProperty("input").Deserialize( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (parsed is null) return null; + + return new ModelAnswer(parsed.ChineseName, parsed.EnglishName, parsed.Form990LineId, parsed.Confidence); + } + + _logger.LogWarning("Claude response contained no tool_use block."); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Claude category AI assist failed."); + return null; + } + } + + /// Shape of the map_category tool input the model fills in. + private sealed class ClaudeAnswer + { + public string? ChineseName { get; set; } + public string? EnglishName { get; set; } + public int? Form990LineId { get; set; } + public double Confidence { get; set; } + } +} diff --git a/API/ROLAC.API/Services/Ai/ExpenseCategoryAiServiceBase.cs b/API/ROLAC.API/Services/Ai/ExpenseCategoryAiServiceBase.cs new file mode 100644 index 0000000..6f3e1c9 --- /dev/null +++ b/API/ROLAC.API/Services/Ai/ExpenseCategoryAiServiceBase.cs @@ -0,0 +1,116 @@ +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using ROLAC.API.Data; +using ROLAC.API.DTOs.Expense; + +namespace ROLAC.API.Services.Ai; + +/// +/// Provider-independent category-AI logic: loads the active Form 990 line catalog, builds the +/// mapping prompt, and validates the model's chosen line id against that catalog. Concrete providers +/// (Gemini, Claude) only implement — the HTTP call plus response parsing — +/// so the catalog/prompt/validation code lives in exactly one place. Mirrors +/// , which does the same for the expense-entry classification task. +/// +public abstract class ExpenseCategoryAiServiceBase : IExpenseCategoryAiService +{ + private readonly AppDbContext _db; + + protected ExpenseCategoryAiServiceBase(AppDbContext db) => _db = db; + + /// One Form 990 line in the catalog passed to the model. + protected sealed record CatalogLine(int Id, string LineCode, string NameEn, string? NameZh); + + /// The model's raw answer, before its line id is validated against the catalog. + protected sealed record ModelAnswer(string? ChineseName, string? EnglishName, int? Form990LineId, double Confidence); + + public async Task SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default) + { + var catalog = await LoadCatalogAsync(ct); + var prompt = BuildPrompt(request, catalog); + + var answer = await CallModelAsync(prompt, ct); + if (answer is null) return new CategoryAiSuggestion(); + + return BuildSuggestion(answer, catalog); + } + + /// + /// Call the provider's API with and return its parsed answer, or null + /// on any failure (missing key, HTTP error, unparseable response). Implementations must not throw. + /// + protected abstract Task CallModelAsync(string prompt, CancellationToken ct); + + private async Task> LoadCatalogAsync(CancellationToken ct) + { + return await _db.Form990ExpenseLines + .AsNoTracking() + .Where(line => line.IsActive) + .OrderBy(line => line.SortOrder) + .Select(line => new CatalogLine(line.Id, line.LineCode, line.Name_en, line.Name_zh)) + .ToListAsync(ct); + } + + private static string BuildPrompt(ExpenseCategoryAiRequest request, List catalog) + { + var catalogJson = JsonSerializer.Serialize(catalog); + var levelLabel = request.Level.Equals("sub", StringComparison.OrdinalIgnoreCase) + ? "sub-category (小項)" + : "major category (大項)"; + + var context = new StringBuilder(); + context.Append($"This is an expense {levelLabel} in a church's bookkeeping chart of accounts.\n"); + if (!string.IsNullOrWhiteSpace(request.Name_zh)) + context.Append($"Chinese name entered: {request.Name_zh}\n"); + if (!string.IsNullOrWhiteSpace(request.Name_en)) + context.Append($"English name entered: {request.Name_en}\n"); + if (!string.IsNullOrWhiteSpace(request.ParentGroupName)) + context.Append($"It belongs under the parent major category: {request.ParentGroupName}\n"); + if (request.ParentForm990LineId is int parentLineId) + context.Append( + $"The parent major category is mapped to Form 990 line id {parentLineId}; prefer a consistent " + + "choice unless a more specific line clearly fits this sub-category.\n"); + + return + "You are a bookkeeping assistant for a church mapping its expense categories to the IRS Form 990 " + + "Part IX (Statement of Functional Expenses) lines. Given an expense category name (often in " + + "Traditional Chinese), do three things:\n" + + "1. Correct any typos in the name and refine it into natural Traditional Chinese — return it as " + + "chineseName.\n" + + "2. Translate that into a concise, natural accounting English noun phrase (not a full sentence) — " + + "return it as englishName.\n" + + "3. Choose the single best matching Form 990 line from the catalog below. You MUST pick a " + + "form990LineId that appears in the catalog. If nothing fits well, choose the closest general line " + + "(e.g. an \"Other expenses\" line) and lower your confidence.\n\n" + + context + + "\n" + + $"Form 990 line catalog (JSON; each line has an Id, LineCode, and English/Chinese names):\n{catalogJson}"; + } + + private static CategoryAiSuggestion BuildSuggestion(ModelAnswer answer, List catalog) + { + var suggestion = new CategoryAiSuggestion + { + ChineseName = string.IsNullOrWhiteSpace(answer.ChineseName) ? null : answer.ChineseName.Trim(), + EnglishName = string.IsNullOrWhiteSpace(answer.EnglishName) ? null : answer.EnglishName.Trim(), + Confidence = answer.Confidence, + }; + + // Re-validate the returned id against the catalog; drop a hallucinated id rather than returning it. + var line = catalog.FirstOrDefault(candidate => candidate.Id == answer.Form990LineId); + if (line is not null) + { + suggestion.Form990LineId = line.Id; + suggestion.Form990LineLabel = Label(line); + } + + return suggestion; + } + + /// Mirror the frontend dropdown label: "code — English / 中文" (or just "code — English"). + private static string Label(CatalogLine line) + => string.IsNullOrWhiteSpace(line.NameZh) + ? $"{line.LineCode} — {line.NameEn}" + : $"{line.LineCode} — {line.NameEn} / {line.NameZh}"; +} diff --git a/API/ROLAC.API/Services/Ai/ExpenseCategoryAiServiceFactory.cs b/API/ROLAC.API/Services/Ai/ExpenseCategoryAiServiceFactory.cs new file mode 100644 index 0000000..b9bd00c --- /dev/null +++ b/API/ROLAC.API/Services/Ai/ExpenseCategoryAiServiceFactory.cs @@ -0,0 +1,30 @@ +namespace ROLAC.API.Services.Ai; + +/// Selects the active category-AI provider per request from ChurchProfile.AiProvider. +public interface IExpenseCategoryAiServiceFactory +{ + Task ResolveAsync(CancellationToken ct = default); +} + +public sealed class ExpenseCategoryAiServiceFactory : IExpenseCategoryAiServiceFactory +{ + private readonly IChurchAiConfigProvider _config; + private readonly ClaudeExpenseCategoryAiService _claude; + private readonly GeminiExpenseCategoryAiService _gemini; + + public ExpenseCategoryAiServiceFactory( + IChurchAiConfigProvider config, + ClaudeExpenseCategoryAiService claude, + GeminiExpenseCategoryAiService gemini) + { + _config = config; + _claude = claude; + _gemini = gemini; + } + + public async Task ResolveAsync(CancellationToken ct = default) + { + var cfg = await _config.GetAsync(ct); + return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude; + } +} diff --git a/API/ROLAC.API/Services/Ai/GeminiExpenseCategoryAiService.cs b/API/ROLAC.API/Services/Ai/GeminiExpenseCategoryAiService.cs new file mode 100644 index 0000000..14a7eb1 --- /dev/null +++ b/API/ROLAC.API/Services/Ai/GeminiExpenseCategoryAiService.cs @@ -0,0 +1,119 @@ +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; } + } +} diff --git a/API/ROLAC.API/Services/Ai/IExpenseCategoryAiService.cs b/API/ROLAC.API/Services/Ai/IExpenseCategoryAiService.cs new file mode 100644 index 0000000..183005b --- /dev/null +++ b/API/ROLAC.API/Services/Ai/IExpenseCategoryAiService.cs @@ -0,0 +1,17 @@ +using ROLAC.API.DTOs.Expense; + +namespace ROLAC.API.Services.Ai; + +/// +/// AI assistance for defining an expense category (大項/小項): refine the Chinese name, translate it +/// to English, and suggest the matching IRS Form 990 Part IX line. +/// +public interface IExpenseCategoryAiService +{ + /// + /// Refine the entered name, translate it to concise accounting English, and choose the best Form 990 + /// line from the live catalog (biased by the group/sub context in ). + /// Never throws on an upstream/AI failure — returns a suggestion with null fields instead. + /// + Task SuggestAsync(ExpenseCategoryAiRequest request, CancellationToken ct = default); +} 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 0d85c84..5884e11 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 @@ -106,7 +106,7 @@
- - + +
+
+ + +
+
+
AI 建議 / Suggestion
+
+ English: + {{ groupAiSuggestion.englishName }} +
+
+ 中文: + {{ groupAiSuggestion.chineseName }} +
+
+ 990 Line: + {{ groupAiSuggestion.form990LineLabel }} +
+
信心 / Confidence: {{ groupAiSuggestion.confidence * 100 | number:'1.0-0' }}%
+
+ + +
+
+
- + +
+
+ + +
+
+
AI 建議 / Suggestion
+
+ English: + {{ subAiSuggestion.englishName }} +
+
+ 中文: + {{ subAiSuggestion.chineseName }} +
+
+ 990 Line: + {{ subAiSuggestion.form990LineLabel }} +
+
信心 / Confidence: {{ subAiSuggestion.confidence * 100 | number:'1.0-0' }}%
+
+ + +
+
+