feat(ai): DB-only config + runtime provider selection via factory
This commit is contained in:
@@ -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 <c>tool_use</c> block. The catalog,
|
||||
/// 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
|
||||
/// <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>
|
||||
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<ClaudeExpenseAiService> _logger;
|
||||
|
||||
public ClaudeExpenseAiService(
|
||||
HttpClient http,
|
||||
IOptions<ClaudeOptions> options,
|
||||
IChurchAiConfigProvider config,
|
||||
AppDbContext db,
|
||||
ILogger<ClaudeExpenseAiService> logger)
|
||||
: base(db)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
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.");
|
||||
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)
|
||||
|
||||
@@ -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.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ROLAC.API.Data;
|
||||
|
||||
namespace ROLAC.API.Services.Ai;
|
||||
@@ -12,25 +11,28 @@ namespace ROLAC.API.Services.Ai;
|
||||
/// </summary>
|
||||
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<GeminiExpenseAiService> _logger;
|
||||
|
||||
public GeminiExpenseAiService(
|
||||
HttpClient http,
|
||||
IOptions<GeminiOptions> options,
|
||||
IChurchAiConfigProvider config,
|
||||
AppDbContext db,
|
||||
ILogger<GeminiExpenseAiService> logger)
|
||||
: base(db)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
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.");
|
||||
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)
|
||||
|
||||
@@ -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