diff --git a/backend/Application/Matchmaking/ILlmProvider.cs b/backend/Application/Matchmaking/ILlmProvider.cs
new file mode 100644
index 0000000..e4d0491
--- /dev/null
+++ b/backend/Application/Matchmaking/ILlmProvider.cs
@@ -0,0 +1,13 @@
+namespace TarninaHub.Application.Matchmaking;
+
+public interface ILlmProvider
+{
+ /// True when a real provider is configured (API key etc.).
+ bool IsConfigured { get; }
+
+ /// Returns the completion text or null when unavailable.
+ Task CompleteAsync(
+ string systemPrompt,
+ string userPrompt,
+ CancellationToken cancellationToken);
+}
diff --git a/backend/Application/Matchmaking/ILlmRequestLimiter.cs b/backend/Application/Matchmaking/ILlmRequestLimiter.cs
new file mode 100644
index 0000000..c1f199d
--- /dev/null
+++ b/backend/Application/Matchmaking/ILlmRequestLimiter.cs
@@ -0,0 +1,10 @@
+namespace TarninaHub.Application.Matchmaking;
+
+public interface ILlmRequestLimiter
+{
+ ///
+ /// Tries to reserve one LLM request within the current window.
+ /// Returns false when the limit is exceeded (caller should fall back).
+ ///
+ bool TryAcquire();
+}
diff --git a/backend/Application/Matchmaking/MatchPromptBuilder.cs b/backend/Application/Matchmaking/MatchPromptBuilder.cs
new file mode 100644
index 0000000..9303ce5
--- /dev/null
+++ b/backend/Application/Matchmaking/MatchPromptBuilder.cs
@@ -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);
+ }
+}
diff --git a/backend/Application/Matchmaking/MatchScoring.cs b/backend/Application/Matchmaking/MatchScoring.cs
new file mode 100644
index 0000000..0712922
--- /dev/null
+++ b/backend/Application/Matchmaking/MatchScoring.cs
@@ -0,0 +1,37 @@
+namespace TarninaHub.Application.Matchmaking;
+
+///
+/// Combines semantic similarity with context signals (area, location) and
+/// availability into a final 0..1 match score.
+///
+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);
+ }
+}
diff --git a/backend/Application/Matchmaking/NullLlmProvider.cs b/backend/Application/Matchmaking/NullLlmProvider.cs
new file mode 100644
index 0000000..fe06622
--- /dev/null
+++ b/backend/Application/Matchmaking/NullLlmProvider.cs
@@ -0,0 +1,15 @@
+namespace TarninaHub.Application.Matchmaking;
+
+///
+/// Default provider used when no external LLM is configured. Keeps the system
+/// fully offline/deterministic; matchmaking falls back to template rationales.
+///
+public sealed class NullLlmProvider : ILlmProvider
+{
+ public bool IsConfigured => false;
+
+ public Task CompleteAsync(
+ string systemPrompt,
+ string userPrompt,
+ CancellationToken cancellationToken) => Task.FromResult(null);
+}
diff --git a/backend/Application/Matchmaking/RationaleBuilder.cs b/backend/Application/Matchmaking/RationaleBuilder.cs
new file mode 100644
index 0000000..79fc80c
--- /dev/null
+++ b/backend/Application/Matchmaking/RationaleBuilder.cs
@@ -0,0 +1,29 @@
+namespace TarninaHub.Application.Matchmaking;
+
+///
+/// Deterministic, offline explanation of "why this fits". Used as the default
+/// and as a fallback when the LLM is unavailable or rate-limited.
+///
+public static class RationaleBuilder
+{
+ public static string Build(bool challengeMatch, bool locationMatch, double score)
+ {
+ var reasons = new List();
+
+ 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) + ".";
+ }
+}
diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs
index 3f1011c..5f6924b 100644
--- a/backend/Infrastructure/DependencyInjection.cs
+++ b/backend/Infrastructure/DependencyInjection.cs
@@ -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(configuration.GetSection("SurrealDb"));
services.Configure(configuration.GetSection("Auth"));
+ var llmSettings = configuration.GetSection("Llm").Get() ?? new LlmSettings();
+ services.Configure(configuration.GetSection("Llm"));
+ services.AddSingleton(
+ new InMemoryLlmRequestLimiter(llmSettings.MaxRequestsPerMinute));
+
+ if (llmSettings.IsConfigured)
+ {
+ services.AddHttpClient(
+ client => client.Timeout = TimeSpan.FromSeconds(llmSettings.TimeoutSeconds));
+ services.AddSingleton(
+ provider => provider.GetRequiredService());
+ }
+ else
+ {
+ services.AddSingleton();
+ }
+
var settings = configuration.GetSection("SurrealDb").Get()
?? new SurrealDbSettings();
diff --git a/backend/Infrastructure/Llm/InMemoryLlmRequestLimiter.cs b/backend/Infrastructure/Llm/InMemoryLlmRequestLimiter.cs
new file mode 100644
index 0000000..39b4878
--- /dev/null
+++ b/backend/Infrastructure/Llm/InMemoryLlmRequestLimiter.cs
@@ -0,0 +1,42 @@
+using TarninaHub.Application.Matchmaking;
+
+namespace TarninaHub.Infrastructure.Llm;
+
+///
+/// In-memory sliding-window request limiter (per instance) for LLM cost control.
+///
+public sealed class InMemoryLlmRequestLimiter : ILlmRequestLimiter
+{
+ private readonly int _maxPerWindow;
+ private readonly TimeSpan _window;
+ private readonly Queue _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;
+ }
+ }
+}
diff --git a/backend/Infrastructure/Llm/LlmSettings.cs b/backend/Infrastructure/Llm/LlmSettings.cs
new file mode 100644
index 0000000..218baab
--- /dev/null
+++ b/backend/Infrastructure/Llm/LlmSettings.cs
@@ -0,0 +1,23 @@
+namespace TarninaHub.Infrastructure.Llm;
+
+public sealed class LlmSettings
+{
+ public bool Enabled { get; set; }
+
+ /// Provider identifier, e.g. "openai" (OpenAI-compatible chat API).
+ 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);
+}
diff --git a/backend/Infrastructure/Llm/OpenAiCompatibleLlmProvider.cs b/backend/Infrastructure/Llm/OpenAiCompatibleLlmProvider.cs
new file mode 100644
index 0000000..f1df90e
--- /dev/null
+++ b/backend/Infrastructure/Llm/OpenAiCompatibleLlmProvider.cs
@@ -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;
+
+///
+/// OpenAI-compatible chat completion provider. Interchangeable behind
+/// (ADR-0005). The API key is read from
+/// configuration/secrets and never logged.
+///
+public sealed class OpenAiCompatibleLlmProvider : ILlmProvider
+{
+ private readonly HttpClient _httpClient;
+ private readonly LlmSettings _settings;
+ private readonly ILogger _logger;
+
+ public OpenAiCompatibleLlmProvider(
+ HttpClient httpClient,
+ IOptions settings,
+ ILogger logger)
+ {
+ _httpClient = httpClient;
+ _settings = settings.Value;
+ _logger = logger;
+ }
+
+ public bool IsConfigured => _settings.IsConfigured;
+
+ public async Task 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(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;
+ }
+ }
+}
diff --git a/backend/Infrastructure/Persistence/MatchmakingService.cs b/backend/Infrastructure/Persistence/MatchmakingService.cs
index c96b84f..9786643 100644
--- a/backend/Infrastructure/Persistence/MatchmakingService.cs
+++ b/backend/Infrastructure/Persistence/MatchmakingService.cs
@@ -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)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(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> 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)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 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) =>