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,13 @@
namespace TarninaHub.Application.Matchmaking;
public interface ILlmProvider
{
/// <summary>True when a real provider is configured (API key etc.).</summary>
bool IsConfigured { get; }
/// <summary>Returns the completion text or null when unavailable.</summary>
Task<string?> CompleteAsync(
string systemPrompt,
string userPrompt,
CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
namespace TarninaHub.Application.Matchmaking;
public interface ILlmRequestLimiter
{
/// <summary>
/// Tries to reserve one LLM request within the current window.
/// Returns false when the limit is exceeded (caller should fall back).
/// </summary>
bool TryAcquire();
}
@@ -0,0 +1,33 @@
namespace TarninaHub.Application.Matchmaking;
public sealed record LlmPrompt(string System, string User);
public static class MatchPromptBuilder
{
private const string SystemPrompt =
"Jesteś asystentem Małopolskiego Hubu Innowacji Społecznych. " +
"Wyjaśnij po polsku, prostym językiem (maks. 2 zdania), dlaczego proponowana " +
"innowacja pasuje do zgłoszonej potrzeby. Nie wymyślaj faktów.";
public static LlmPrompt BuildExplanation(
string problemTitle,
string problemDescription,
string challengeId,
string innovationTitle,
string innovationSummary,
string stage,
double score)
{
var user =
$"Potrzeba: {problemTitle}\n" +
$"Opis: {problemDescription}\n" +
$"Obszar: {challengeId}\n" +
$"Proponowana innowacja: {innovationTitle}\n" +
$"Streszczenie: {innovationSummary}\n" +
$"Etap: {stage}\n" +
$"Ocena dopasowania: {score:0.00}\n" +
"Uzasadnij krótko dopasowanie.";
return new LlmPrompt(SystemPrompt, user);
}
}
@@ -0,0 +1,37 @@
namespace TarninaHub.Application.Matchmaking;
/// <summary>
/// Combines semantic similarity with context signals (area, location) and
/// availability into a final 0..1 match score.
/// </summary>
public static class MatchScoring
{
private const double ChallengeBoost = 0.10;
private const double LocationBoost = 0.05;
public static double Combine(
double semanticScore,
bool challengeMatch,
bool locationMatch,
bool available)
{
if (!available)
{
return 0;
}
var score = semanticScore;
if (challengeMatch)
{
score += ChallengeBoost;
}
if (locationMatch)
{
score += LocationBoost;
}
return Math.Clamp(score, 0, 1);
}
}
@@ -0,0 +1,15 @@
namespace TarninaHub.Application.Matchmaking;
/// <summary>
/// Default provider used when no external LLM is configured. Keeps the system
/// fully offline/deterministic; matchmaking falls back to template rationales.
/// </summary>
public sealed class NullLlmProvider : ILlmProvider
{
public bool IsConfigured => false;
public Task<string?> CompleteAsync(
string systemPrompt,
string userPrompt,
CancellationToken cancellationToken) => Task.FromResult<string?>(null);
}
@@ -0,0 +1,29 @@
namespace TarninaHub.Application.Matchmaking;
/// <summary>
/// Deterministic, offline explanation of "why this fits". Used as the default
/// and as a fallback when the LLM is unavailable or rate-limited.
/// </summary>
public static class RationaleBuilder
{
public static string Build(bool challengeMatch, bool locationMatch, double score)
{
var reasons = new List<string>();
if (challengeMatch)
{
reasons.Add("ten sam obszar problemowy");
}
if (locationMatch)
{
reasons.Add("zbliżona lokalizacja");
}
reasons.Add(score >= 0.3
? "wysoka zbieżność opisu potrzeby"
: "zbieżność semantyczna opisu");
return "Proponowane dopasowanie: " + string.Join(", ", reasons) + ".";
}
}
@@ -5,6 +5,7 @@ using TarninaHub.Application.Abstractions;
using TarninaHub.Application.Matchmaking;
using TarninaHub.Infrastructure.Auth;
using TarninaHub.Infrastructure.Events;
using TarninaHub.Infrastructure.Llm;
using TarninaHub.Infrastructure.Persistence;
using TarninaHub.Infrastructure.Settings;
@@ -19,6 +20,23 @@ public static class DependencyInjection
services.Configure<SurrealDbSettings>(configuration.GetSection("SurrealDb"));
services.Configure<AuthSettings>(configuration.GetSection("Auth"));
var llmSettings = configuration.GetSection("Llm").Get<LlmSettings>() ?? new LlmSettings();
services.Configure<LlmSettings>(configuration.GetSection("Llm"));
services.AddSingleton<ILlmRequestLimiter>(
new InMemoryLlmRequestLimiter(llmSettings.MaxRequestsPerMinute));
if (llmSettings.IsConfigured)
{
services.AddHttpClient<OpenAiCompatibleLlmProvider>(
client => client.Timeout = TimeSpan.FromSeconds(llmSettings.TimeoutSeconds));
services.AddSingleton<ILlmProvider>(
provider => provider.GetRequiredService<OpenAiCompatibleLlmProvider>());
}
else
{
services.AddSingleton<ILlmProvider, NullLlmProvider>();
}
var settings = configuration.GetSection("SurrealDb").Get<SurrealDbSettings>()
?? new SurrealDbSettings();
@@ -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;
}
}
}
@@ -11,15 +11,21 @@ public sealed class MatchmakingService : IMatchmakingService
private readonly ISurrealDbClient _client;
private readonly IEmbeddingProvider _embeddingProvider;
private readonly ISubmissionService _submissionService;
private readonly ILlmProvider _llmProvider;
private readonly ILlmRequestLimiter _llmRequestLimiter;
public MatchmakingService(
ISurrealDbClient client,
IEmbeddingProvider embeddingProvider,
ISubmissionService submissionService)
ISubmissionService submissionService,
ILlmProvider llmProvider,
ILlmRequestLimiter llmRequestLimiter)
{
_client = client;
_embeddingProvider = embeddingProvider;
_submissionService = submissionService;
_llmProvider = llmProvider;
_llmRequestLimiter = llmRequestLimiter;
}
public async Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken)
@@ -130,10 +136,7 @@ public sealed class MatchmakingService : IMatchmakingService
}
var maxResults = Math.Clamp(request.MaxResults ?? 5, 1, 20);
var queryText = EmbeddingTextBuilder.Build(
submission.Title,
null,
submission.Description);
var queryText = EmbeddingTextBuilder.Build(submission.Title, null, submission.Description);
if (submission.Keywords.Count > 0)
{
@@ -179,17 +182,37 @@ public sealed class MatchmakingService : IMatchmakingService
.Where(row => row.Embedding is { Count: > 0 })
.Select(row => (Item: row, Vector: (IReadOnlyList<float>)row.Embedding!));
var ranked = SemanticRanker.Rank(queryVector, candidates, maxResults, minScore: 0.05);
var ranked = SemanticRanker
.Rank(queryVector, candidates, maxResults * 3, minScore: 0.05)
.Select(item =>
{
var challengeMatch = string.Equals(
NormalizeRecordId(item.Item.Challenge),
submission.ChallengeId,
StringComparison.Ordinal);
return ranked
.Select(item => new SolutionItem(
return (item.Item, ChallengeMatch: challengeMatch, Score: MatchScoring.Combine(item.Score, challengeMatch, false, true));
})
.OrderByDescending(item => item.Score)
.Take(maxResults)
.ToList();
var solutions = new List<SolutionItem>(ranked.Count);
foreach (var item in ranked)
{
var rationale = await BuildRationaleAsync(submission, item.Item, item.Score, item.ChallengeMatch, cancellationToken);
solutions.Add(new SolutionItem(
item.Item.Id,
item.Item.Title,
item.Item.Stage,
NormalizeRecordId(item.Item.Challenge) ?? string.Empty,
Math.Round(item.Score, 3),
BuildRationale(item.Item.Challenge, submission.ChallengeId)))
.ToList();
rationale));
}
return solutions;
}
private async Task<List<SimilarCaseItem>> RankSimilarCasesAsync(
@@ -216,8 +239,8 @@ public sealed class MatchmakingService : IMatchmakingService
}
var response = await _client.RawQuery(
"SELECT type::string(id) AS id, title, type::string(challenge) AS challenge, embedding " +
"FROM problem WHERE " + string.Join(" AND ", clauses) + ";",
"SELECT type::string(id) AS id, title, type::string(challenge) AS challenge, " +
"type::string(location) AS location, embedding FROM problem WHERE " + string.Join(" AND ", clauses) + ";",
parameters,
cancellationToken);
response.EnsureAllOks();
@@ -228,23 +251,54 @@ public sealed class MatchmakingService : IMatchmakingService
.Where(row => row.Embedding is { Count: > 0 })
.Select(row => (Item: row, Vector: (IReadOnlyList<float>)row.Embedding!));
var ranked = SemanticRanker.Rank(queryVector, candidates, maxResults, minScore: 0.05);
return SemanticRanker
.Rank(queryVector, candidates, maxResults * 3, minScore: 0.05)
.Select(item =>
{
var challengeMatch = string.Equals(
NormalizeRecordId(item.Item.Challenge),
submission.ChallengeId,
StringComparison.Ordinal);
var locationMatch = string.Equals(
NormalizeRecordId(item.Item.Location),
submission.LocationId,
StringComparison.Ordinal);
return ranked
.Select(item => new SimilarCaseItem(
item.Item.Id,
item.Item.Title,
Math.Round(item.Score, 3)))
return (item.Item, Score: MatchScoring.Combine(item.Score, challengeMatch, locationMatch, true));
})
.OrderByDescending(item => item.Score)
.Take(maxResults)
.Select(item => new SimilarCaseItem(item.Item.Id, item.Item.Title, Math.Round(item.Score, 3)))
.ToList();
}
private static string BuildRationale(string challenge, string queryChallengeId)
private async Task<string> BuildRationaleAsync(
SubmissionResponse submission,
InnovationRow innovation,
double score,
bool challengeMatch,
CancellationToken cancellationToken)
{
var normalized = NormalizeRecordId(challenge);
if (_llmProvider.IsConfigured && _llmRequestLimiter.TryAcquire())
{
var prompt = MatchPromptBuilder.BuildExplanation(
submission.Title,
submission.Description,
submission.ChallengeId,
innovation.Title,
innovation.Summary,
innovation.Stage,
score);
return string.Equals(normalized, queryChallengeId, StringComparison.Ordinal)
? "Pasuje obszarowo do zgłoszenia."
: "Wysoka zbieżność semantyczna opisu.";
var completion = await _llmProvider.CompleteAsync(prompt.System, prompt.User, cancellationToken);
if (!string.IsNullOrWhiteSpace(completion))
{
return completion.Trim();
}
}
return RationaleBuilder.Build(challengeMatch, locationMatch: false, score);
}
private static string? NormalizeRecordId(string? value) =>