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,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user