diff --git a/API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs b/API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.cs new file mode 100644 index 0000000..b3661af --- /dev/null +++ b/API/ROLAC.API.Tests/Services/ExpenseAiServiceFactoryTests.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 ExpenseAiServiceFactoryTests +{ + // 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 ExpenseAiServiceFactory Build(AppDbContext db) + { + var cfg = new ChurchAiConfigProvider(db); + var claude = new ClaudeExpenseAiService( + new HttpClient(), cfg, db, NullLogger.Instance); + var gemini = new GeminiExpenseAiService( + new HttpClient(), cfg, db, NullLogger.Instance); + return new ExpenseAiServiceFactory(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/ExpenseAiController.cs b/API/ROLAC.API/Controllers/ExpenseAiController.cs index 5041fe9..799ddcc 100644 --- a/API/ROLAC.API/Controllers/ExpenseAiController.cs +++ b/API/ROLAC.API/Controllers/ExpenseAiController.cs @@ -11,8 +11,8 @@ namespace ROLAC.API.Controllers; // 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; + private readonly IExpenseAiServiceFactory _factory; + public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory; [HttpPost("assist")] public async Task Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct) @@ -20,7 +20,8 @@ public class ExpenseAiController : ControllerBase if (string.IsNullOrWhiteSpace(request.Text)) return BadRequest("Text is required."); - var suggestion = await _svc.SuggestAsync(request.Text, request.Amount, ct); + var svc = await _factory.ResolveAsync(ct); + var suggestion = await svc.SuggestAsync(request.Text, request.Amount, ct); return Ok(suggestion); } } diff --git a/API/ROLAC.API/Program.cs b/API/ROLAC.API/Program.cs index 359a3ac..cd78161 100644 --- a/API/ROLAC.API/Program.cs +++ b/API/ROLAC.API/Program.cs @@ -180,24 +180,15 @@ builder.Services.AddHttpClient(); // ── AI assist (expense translation + category suggestion) ────────────────── -// Backend proxy so the API key stays server-side. Two interchangeable providers (Claude / Gemini) -// implement IExpenseAiService; "Ai:Provider" selects which one is bound (default Claude). -builder.Services.Configure(config.GetSection("Gemini")); -builder.Services.Configure(config.GetSection("Claude")); +// Backend proxy so the API key stays server-side. Provider + model + key come from the +// ChurchProfile DB record (editable via Church Profile → AI 設定); the factory picks Claude +// or Gemini per request based on ChurchProfile.AiProvider. builder.Services.AddHttpClient(); builder.Services.AddHttpClient(); - -var aiProvider = config["Ai:Provider"] ?? "Claude"; -if (aiProvider.Equals("Gemini", StringComparison.OrdinalIgnoreCase)) -{ - builder.Services.AddScoped( - sp => sp.GetRequiredService()); -} -else -{ - builder.Services.AddScoped( - sp => sp.GetRequiredService()); -} +builder.Services.AddScoped(); +builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Configurable role-based permissions (RBAC matrix) diff --git a/API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs b/API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs index 21ee1d3..71f5930 100644 --- a/API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs +++ b/API/ROLAC.API/Services/Ai/ClaudeExpenseAiService.cs @@ -1,6 +1,5 @@ using System.Net.Http.Json; using System.Text.Json; -using Microsoft.Extensions.Options; using ROLAC.API.Data; namespace ROLAC.API.Services.Ai; @@ -11,29 +10,33 @@ namespace ROLAC.API.Services.Ai; /// 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. Forced tool use works on every Claude model, so the configured -/// can be swapped (e.g. to a cheaper model) without code changes. +/// model can be swapped (e.g. to a cheaper model) without code changes. /// public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase { + private const string BaseUrl = "https://api.anthropic.com/v1"; + private const string AnthropicVersion = "2023-06-01"; + private readonly HttpClient _http; - private readonly ClaudeOptions _options; + private readonly IChurchAiConfigProvider _config; private readonly ILogger _logger; public ClaudeExpenseAiService( HttpClient http, - IOptions options, + IChurchAiConfigProvider config, AppDbContext db, ILogger logger) : base(db) { _http = http; - _options = options.Value; + _config = config; _logger = logger; } protected override async Task CallModelAsync(string prompt, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(_options.ApiKey)) + var cfg = await _config.GetAsync(ct); + if (string.IsNullOrWhiteSpace(cfg.ClaudeApiKey)) { _logger.LogWarning("Claude API key is not configured; expense AI assist is disabled."); return null; @@ -43,7 +46,7 @@ public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase { var payload = new { - model = _options.Model, + model = cfg.ClaudeModel, max_tokens = 1024, tools = new[] { @@ -73,13 +76,13 @@ public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase }, }; - var url = $"{_options.BaseUrl}/messages"; + var url = $"{BaseUrl}/messages"; using var request = new HttpRequestMessage(HttpMethod.Post, url) { Content = JsonContent.Create(payload), }; - request.Headers.Add("x-api-key", _options.ApiKey); - request.Headers.Add("anthropic-version", _options.AnthropicVersion); + 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) diff --git a/API/ROLAC.API/Services/Ai/ClaudeOptions.cs b/API/ROLAC.API/Services/Ai/ClaudeOptions.cs deleted file mode 100644 index 20b9b2d..0000000 --- a/API/ROLAC.API/Services/Ai/ClaudeOptions.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ROLAC.API.Services.Ai; - -/// Anthropic Claude API settings (bound from the "Claude" config section). -public sealed class ClaudeOptions -{ - /// API key sent as the x-api-key header. Keep out of source control. - public string ApiKey { get; set; } = ""; - public string Model { get; set; } = "claude-opus-4-8"; - public string BaseUrl { get; set; } = "https://api.anthropic.com/v1"; - public string AnthropicVersion { get; set; } = "2023-06-01"; -} diff --git a/API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs b/API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs new file mode 100644 index 0000000..d9f04f2 --- /dev/null +++ b/API/ROLAC.API/Services/Ai/ExpenseAiServiceFactory.cs @@ -0,0 +1,30 @@ +namespace ROLAC.API.Services.Ai; + +/// Selects the active expense-AI provider per request from ChurchProfile.AiProvider. +public interface IExpenseAiServiceFactory +{ + Task ResolveAsync(CancellationToken ct = default); +} + +public sealed class ExpenseAiServiceFactory : IExpenseAiServiceFactory +{ + private readonly IChurchAiConfigProvider _config; + private readonly ClaudeExpenseAiService _claude; + private readonly GeminiExpenseAiService _gemini; + + public ExpenseAiServiceFactory( + IChurchAiConfigProvider config, + ClaudeExpenseAiService claude, + GeminiExpenseAiService 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/GeminiExpenseAiService.cs b/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs index e7f1c7b..934342a 100644 --- a/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs +++ b/API/ROLAC.API/Services/Ai/GeminiExpenseAiService.cs @@ -1,6 +1,5 @@ using System.Net.Http.Json; using System.Text.Json; -using Microsoft.Extensions.Options; using ROLAC.API.Data; namespace ROLAC.API.Services.Ai; @@ -12,25 +11,28 @@ namespace ROLAC.API.Services.Ai; /// public sealed class GeminiExpenseAiService : ExpenseAiServiceBase { + private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta"; + private readonly HttpClient _http; - private readonly GeminiOptions _options; + private readonly IChurchAiConfigProvider _config; private readonly ILogger _logger; public GeminiExpenseAiService( HttpClient http, - IOptions options, + IChurchAiConfigProvider config, AppDbContext db, ILogger logger) : base(db) { _http = http; - _options = options.Value; + _config = config; _logger = logger; } protected override async Task CallModelAsync(string prompt, CancellationToken ct) { - if (string.IsNullOrWhiteSpace(_options.ApiKey)) + var cfg = await _config.GetAsync(ct); + if (string.IsNullOrWhiteSpace(cfg.GeminiApiKey)) { _logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled."); return null; @@ -63,12 +65,12 @@ public sealed class GeminiExpenseAiService : ExpenseAiServiceBase }, }; - var url = $"{_options.BaseUrl}/models/{_options.Model}:generateContent"; + 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", _options.ApiKey); + request.Headers.Add("X-goog-api-key", cfg.GeminiApiKey); using var response = await _http.SendAsync(request, ct); if (!response.IsSuccessStatusCode) diff --git a/API/ROLAC.API/Services/Ai/GeminiOptions.cs b/API/ROLAC.API/Services/Ai/GeminiOptions.cs deleted file mode 100644 index da0fa2e..0000000 --- a/API/ROLAC.API/Services/Ai/GeminiOptions.cs +++ /dev/null @@ -1,10 +0,0 @@ -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-lite"; - public string BaseUrl { get; set; } = "https://generativelanguage.googleapis.com/v1beta"; -}