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:
Rafał Tyński
2026-10-04 02:52:45 +02:00
parent 182c26d9f8
commit 03e2d66ca4
11 changed files with 385 additions and 23 deletions
@@ -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;
}
}
}
+23
View File
@@ -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;
}
}
}