feat(ai): DB-only config + runtime provider selection via factory
This commit is contained in:
@@ -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<IHttpContextAccessor>();
|
||||||
|
httpContextAccessor.Setup(accessor => accessor.HttpContext).Returns(httpContext);
|
||||||
|
return new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.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<ClaudeExpenseAiService>.Instance);
|
||||||
|
var gemini = new GeminiExpenseAiService(
|
||||||
|
new HttpClient(), cfg, db, NullLogger<GeminiExpenseAiService>.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<ClaudeExpenseAiService>(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<GeminiExpenseAiService>(svc);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,8 +11,8 @@ namespace ROLAC.API.Controllers;
|
|||||||
// member filing a reimbursement can reach. The endpoint only reads the category catalog.
|
// member filing a reimbursement can reach. The endpoint only reads the category catalog.
|
||||||
public class ExpenseAiController : ControllerBase
|
public class ExpenseAiController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IExpenseAiService _svc;
|
private readonly IExpenseAiServiceFactory _factory;
|
||||||
public ExpenseAiController(IExpenseAiService svc) => _svc = svc;
|
public ExpenseAiController(IExpenseAiServiceFactory factory) => _factory = factory;
|
||||||
|
|
||||||
[HttpPost("assist")]
|
[HttpPost("assist")]
|
||||||
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
|
public async Task<IActionResult> Assist([FromBody] ExpenseAiAssistRequest request, CancellationToken ct)
|
||||||
@@ -20,7 +20,8 @@ public class ExpenseAiController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(request.Text))
|
if (string.IsNullOrWhiteSpace(request.Text))
|
||||||
return BadRequest("Text is required.");
|
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);
|
return Ok(suggestion);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,24 +180,15 @@ builder.Services.AddHttpClient<ROLAC.API.Services.Notifications.IMessageChannel,
|
|||||||
ROLAC.API.Services.Notifications.LineMessageChannel>();
|
ROLAC.API.Services.Notifications.LineMessageChannel>();
|
||||||
|
|
||||||
// ── AI assist (expense translation + category suggestion) ──────────────────
|
// ── AI assist (expense translation + category suggestion) ──────────────────
|
||||||
// Backend proxy so the API key stays server-side. Two interchangeable providers (Claude / Gemini)
|
// Backend proxy so the API key stays server-side. Provider + model + key come from the
|
||||||
// implement IExpenseAiService; "Ai:Provider" selects which one is bound (default Claude).
|
// ChurchProfile DB record (editable via Church Profile → AI 設定); the factory picks Claude
|
||||||
builder.Services.Configure<ROLAC.API.Services.Ai.GeminiOptions>(config.GetSection("Gemini"));
|
// or Gemini per request based on ChurchProfile.AiProvider.
|
||||||
builder.Services.Configure<ROLAC.API.Services.Ai.ClaudeOptions>(config.GetSection("Claude"));
|
|
||||||
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseAiService>();
|
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.GeminiExpenseAiService>();
|
||||||
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseAiService>();
|
builder.Services.AddHttpClient<ROLAC.API.Services.Ai.ClaudeExpenseAiService>();
|
||||||
|
builder.Services.AddScoped<ROLAC.API.Services.Ai.IChurchAiConfigProvider,
|
||||||
var aiProvider = config["Ai:Provider"] ?? "Claude";
|
ROLAC.API.Services.Ai.ChurchAiConfigProvider>();
|
||||||
if (aiProvider.Equals("Gemini", StringComparison.OrdinalIgnoreCase))
|
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiServiceFactory,
|
||||||
{
|
ROLAC.API.Services.Ai.ExpenseAiServiceFactory>();
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiService>(
|
|
||||||
sp => sp.GetRequiredService<ROLAC.API.Services.Ai.GeminiExpenseAiService>());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.Services.AddScoped<ROLAC.API.Services.Ai.IExpenseAiService>(
|
|
||||||
sp => sp.GetRequiredService<ROLAC.API.Services.Ai.ClaudeExpenseAiService>());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Configurable role-based permissions (RBAC matrix)
|
// Configurable role-based permissions (RBAC matrix)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Ai;
|
namespace ROLAC.API.Services.Ai;
|
||||||
@@ -11,29 +10,33 @@ namespace ROLAC.API.Services.Ai;
|
|||||||
/// answer shape, so the model returns structured JSON in a <c>tool_use</c> block. The catalog,
|
/// answer shape, so the model returns structured JSON in a <c>tool_use</c> block. The catalog,
|
||||||
/// prompt, and id validation come from <see cref="ExpenseAiServiceBase"/>; this class only owns the
|
/// prompt, and id validation come from <see cref="ExpenseAiServiceBase"/>; this class only owns the
|
||||||
/// Claude HTTP call + parse. Forced tool use works on every Claude model, so the configured
|
/// Claude HTTP call + parse. Forced tool use works on every Claude model, so the configured
|
||||||
/// <see cref="ClaudeOptions.Model"/> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
|
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 HttpClient _http;
|
||||||
private readonly ClaudeOptions _options;
|
private readonly IChurchAiConfigProvider _config;
|
||||||
private readonly ILogger<ClaudeExpenseAiService> _logger;
|
private readonly ILogger<ClaudeExpenseAiService> _logger;
|
||||||
|
|
||||||
public ClaudeExpenseAiService(
|
public ClaudeExpenseAiService(
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
IOptions<ClaudeOptions> options,
|
IChurchAiConfigProvider config,
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ILogger<ClaudeExpenseAiService> logger)
|
ILogger<ClaudeExpenseAiService> logger)
|
||||||
: base(db)
|
: base(db)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_options = options.Value;
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
|
protected override async Task<ModelAnswer?> 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.");
|
_logger.LogWarning("Claude API key is not configured; expense AI assist is disabled.");
|
||||||
return null;
|
return null;
|
||||||
@@ -43,7 +46,7 @@ public sealed class ClaudeExpenseAiService : ExpenseAiServiceBase
|
|||||||
{
|
{
|
||||||
var payload = new
|
var payload = new
|
||||||
{
|
{
|
||||||
model = _options.Model,
|
model = cfg.ClaudeModel,
|
||||||
max_tokens = 1024,
|
max_tokens = 1024,
|
||||||
tools = new[]
|
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)
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
{
|
{
|
||||||
Content = JsonContent.Create(payload),
|
Content = JsonContent.Create(payload),
|
||||||
};
|
};
|
||||||
request.Headers.Add("x-api-key", _options.ApiKey);
|
request.Headers.Add("x-api-key", cfg.ClaudeApiKey);
|
||||||
request.Headers.Add("anthropic-version", _options.AnthropicVersion);
|
request.Headers.Add("anthropic-version", AnthropicVersion);
|
||||||
|
|
||||||
using var response = await _http.SendAsync(request, ct);
|
using var response = await _http.SendAsync(request, ct);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace ROLAC.API.Services.Ai;
|
|
||||||
|
|
||||||
/// <summary>Anthropic Claude API settings (bound from the "Claude" config section).</summary>
|
|
||||||
public sealed class ClaudeOptions
|
|
||||||
{
|
|
||||||
/// <summary>API key sent as the <c>x-api-key</c> header. Keep out of source control.</summary>
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
namespace ROLAC.API.Services.Ai;
|
||||||
|
|
||||||
|
/// <summary>Selects the active expense-AI provider per request from <c>ChurchProfile.AiProvider</c>.</summary>
|
||||||
|
public interface IExpenseAiServiceFactory
|
||||||
|
{
|
||||||
|
Task<IExpenseAiService> 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<IExpenseAiService> ResolveAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var cfg = await _config.GetAsync(ct);
|
||||||
|
return cfg.Provider.Equals("Gemini", StringComparison.OrdinalIgnoreCase) ? _gemini : _claude;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using ROLAC.API.Data;
|
using ROLAC.API.Data;
|
||||||
|
|
||||||
namespace ROLAC.API.Services.Ai;
|
namespace ROLAC.API.Services.Ai;
|
||||||
@@ -12,25 +11,28 @@ namespace ROLAC.API.Services.Ai;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GeminiExpenseAiService : ExpenseAiServiceBase
|
public sealed class GeminiExpenseAiService : ExpenseAiServiceBase
|
||||||
{
|
{
|
||||||
|
private const string BaseUrl = "https://generativelanguage.googleapis.com/v1beta";
|
||||||
|
|
||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
private readonly GeminiOptions _options;
|
private readonly IChurchAiConfigProvider _config;
|
||||||
private readonly ILogger<GeminiExpenseAiService> _logger;
|
private readonly ILogger<GeminiExpenseAiService> _logger;
|
||||||
|
|
||||||
public GeminiExpenseAiService(
|
public GeminiExpenseAiService(
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
IOptions<GeminiOptions> options,
|
IChurchAiConfigProvider config,
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ILogger<GeminiExpenseAiService> logger)
|
ILogger<GeminiExpenseAiService> logger)
|
||||||
: base(db)
|
: base(db)
|
||||||
{
|
{
|
||||||
_http = http;
|
_http = http;
|
||||||
_options = options.Value;
|
_config = config;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task<ModelAnswer?> CallModelAsync(string prompt, CancellationToken ct)
|
protected override async Task<ModelAnswer?> 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.");
|
_logger.LogWarning("Gemini API key is not configured; expense AI assist is disabled.");
|
||||||
return null;
|
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)
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
{
|
{
|
||||||
Content = JsonContent.Create(payload),
|
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);
|
using var response = await _http.SendAsync(request, ct);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace ROLAC.API.Services.Ai;
|
|
||||||
|
|
||||||
/// <summary>Google Gemini API settings (bound from the "Gemini" config section).</summary>
|
|
||||||
public sealed class GeminiOptions
|
|
||||||
{
|
|
||||||
/// <summary>API key sent as the <c>X-goog-api-key</c> header. Keep out of source control.</summary>
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user