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) =>