feat(backend): add LLM provider abstraction, contextual ranking and explanations
Add a pluggable LLM provider with an offline template fallback, prompt builder, contextual match scoring (area/location boosts, availability), per-solution explanations and in-memory request limiting for cost control.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
using TarninaHub.Application.Matchmaking;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory sliding-window request limiter (per instance) for LLM cost control.
|
||||
/// </summary>
|
||||
public sealed class InMemoryLlmRequestLimiter : ILlmRequestLimiter
|
||||
{
|
||||
private readonly int _maxPerWindow;
|
||||
private readonly TimeSpan _window;
|
||||
private readonly Queue<DateTimeOffset> _timestamps = new();
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
public InMemoryLlmRequestLimiter(int maxRequestsPerMinute, TimeSpan? window = null)
|
||||
{
|
||||
_maxPerWindow = Math.Max(1, maxRequestsPerMinute);
|
||||
_window = window ?? TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
public bool TryAcquire()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
while (_timestamps.Count > 0 && now - _timestamps.Peek() > _window)
|
||||
{
|
||||
_timestamps.Dequeue();
|
||||
}
|
||||
|
||||
if (_timestamps.Count >= _maxPerWindow)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_timestamps.Enqueue(now);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace TarninaHub.Infrastructure.Llm;
|
||||
|
||||
public sealed class LlmSettings
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Provider identifier, e.g. "openai" (OpenAI-compatible chat API).</summary>
|
||||
public string Provider { get; set; } = "openai";
|
||||
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
public string BaseUrl { get; set; } = "https://api.openai.com/v1";
|
||||
|
||||
public string Model { get; set; } = "gpt-4o-mini";
|
||||
|
||||
public int MaxTokens { get; set; } = 256;
|
||||
|
||||
public int MaxRequestsPerMinute { get; set; } = 30;
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 20;
|
||||
|
||||
public bool IsConfigured => Enabled && !string.IsNullOrWhiteSpace(ApiKey);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TarninaHub.Application.Matchmaking;
|
||||
|
||||
namespace TarninaHub.Infrastructure.Llm;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI-compatible chat completion provider. Interchangeable behind
|
||||
/// <see cref="ILlmProvider"/> (ADR-0005). The API key is read from
|
||||
/// configuration/secrets and never logged.
|
||||
/// </summary>
|
||||
public sealed class OpenAiCompatibleLlmProvider : ILlmProvider
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly LlmSettings _settings;
|
||||
private readonly ILogger<OpenAiCompatibleLlmProvider> _logger;
|
||||
|
||||
public OpenAiCompatibleLlmProvider(
|
||||
HttpClient httpClient,
|
||||
IOptions<LlmSettings> settings,
|
||||
ILogger<OpenAiCompatibleLlmProvider> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsConfigured => _settings.IsConfigured;
|
||||
|
||||
public async Task<string?> CompleteAsync(
|
||||
string systemPrompt,
|
||||
string userPrompt,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var endpoint = _settings.BaseUrl.TrimEnd('/') + "/chat/completions";
|
||||
var payload = new
|
||||
{
|
||||
model = _settings.Model,
|
||||
max_tokens = _settings.MaxTokens,
|
||||
messages = new object[]
|
||||
{
|
||||
new { role = "system", content = systemPrompt },
|
||||
new { role = "user", content = userPrompt },
|
||||
},
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||
{
|
||||
Content = JsonContent.Create(payload),
|
||||
};
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||
"Bearer",
|
||||
_settings.ApiKey);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("LLM provider returned status {Status}.", (int)response.StatusCode);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var document = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken);
|
||||
|
||||
return document
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString();
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(exception, "LLM completion failed; falling back to template rationale.");
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user