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.
89 lines
2.7 KiB
C#
89 lines
2.7 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|