diff --git a/backend/Application/Abstractions/IMatchmakingService.cs b/backend/Application/Abstractions/IMatchmakingService.cs new file mode 100644 index 0000000..8d845ad --- /dev/null +++ b/backend/Application/Abstractions/IMatchmakingService.cs @@ -0,0 +1,19 @@ +using TarninaHub.Application.Dtos; + +namespace TarninaHub.Application.Abstractions; + +public interface IMatchmakingService +{ + Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken); + + Task IndexProblemAsync(string problemId, CancellationToken cancellationToken); + + Task IndexSubmissionAsync(string submissionId, CancellationToken cancellationToken); + + Task ReindexAsync(CancellationToken cancellationToken); + + Task MatchAsync( + string submissionId, + MatchRequest request, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Dtos/MatchmakingDtos.cs b/backend/Application/Dtos/MatchmakingDtos.cs new file mode 100644 index 0000000..0e602c2 --- /dev/null +++ b/backend/Application/Dtos/MatchmakingDtos.cs @@ -0,0 +1,23 @@ +namespace TarninaHub.Application.Dtos; + +public sealed record MatchRequest( + int? MaxResults, + string? ChallengeId); + +public sealed record SimilarCaseItem( + string Id, + string Title, + double Score); + +public sealed record SolutionItem( + string InnovationId, + string Title, + string Stage, + string ChallengeId, + double Score, + string Rationale); + +public sealed record MatchResponse( + IReadOnlyList SimilarCases, + IReadOnlyList Solutions, + bool Fallback); diff --git a/backend/Application/Matchmaking/EmbeddingConstants.cs b/backend/Application/Matchmaking/EmbeddingConstants.cs new file mode 100644 index 0000000..0285e99 --- /dev/null +++ b/backend/Application/Matchmaking/EmbeddingConstants.cs @@ -0,0 +1,7 @@ +namespace TarninaHub.Application.Matchmaking; + +public static class EmbeddingConstants +{ + /// Embedding dimension; must match the HNSW index definition. + public const int Dimension = 1536; +} diff --git a/backend/Application/Matchmaking/HashingEmbeddingProvider.cs b/backend/Application/Matchmaking/HashingEmbeddingProvider.cs new file mode 100644 index 0000000..82e6767 --- /dev/null +++ b/backend/Application/Matchmaking/HashingEmbeddingProvider.cs @@ -0,0 +1,103 @@ +using System.Text; + +namespace TarninaHub.Application.Matchmaking; + +/// +/// Deterministic, offline embedding provider (feature hashing). Produces +/// L2-normalized vectors so cosine similarity reflects shared vocabulary. +/// Interchangeable with a real LLM provider behind +/// (see ADR-0005). Used as the default until an external provider is wired. +/// +public sealed class HashingEmbeddingProvider : IEmbeddingProvider +{ + public int Dimension => EmbeddingConstants.Dimension; + + public Task EmbedAsync(string text, CancellationToken cancellationToken) + { + return Task.FromResult(Embed(text)); + } + + public Task> EmbedBatchAsync( + IReadOnlyList texts, + CancellationToken cancellationToken) + { + IReadOnlyList result = texts.Select(Embed).ToList(); + + return Task.FromResult(result); + } + + public float[] Embed(string text) + { + var vector = new float[Dimension]; + + if (string.IsNullOrWhiteSpace(text)) + { + return vector; + } + + foreach (var token in Tokenize(text)) + { + var index = (int)(Fnv1a(token) % Dimension); + vector[index] += 1f; + } + + Normalize(vector); + + return vector; + } + + private static IEnumerable Tokenize(string text) + { + var buffer = new StringBuilder(); + + foreach (var character in text) + { + if (char.IsLetterOrDigit(character)) + { + buffer.Append(char.ToLowerInvariant(character)); + } + else if (buffer.Length > 0) + { + yield return buffer.ToString(); + buffer.Clear(); + } + } + + if (buffer.Length > 0) + { + yield return buffer.ToString(); + } + } + + private static void Normalize(float[] vector) + { + double sumOfSquares = vector.Sum(value => (double)value * value); + var magnitude = Math.Sqrt(sumOfSquares); + + if (magnitude <= 0) + { + return; + } + + for (var i = 0; i < vector.Length; i++) + { + vector[i] = (float)(vector[i] / magnitude); + } + } + + private static uint Fnv1a(string token) + { + const uint offset = 2166136261; + const uint prime = 16777619; + + var hash = offset; + + foreach (var character in token) + { + hash ^= character; + hash *= prime; + } + + return hash; + } +} diff --git a/backend/Application/Matchmaking/IEmbeddingProvider.cs b/backend/Application/Matchmaking/IEmbeddingProvider.cs new file mode 100644 index 0000000..d55b726 --- /dev/null +++ b/backend/Application/Matchmaking/IEmbeddingProvider.cs @@ -0,0 +1,12 @@ +namespace TarninaHub.Application.Matchmaking; + +public interface IEmbeddingProvider +{ + int Dimension { get; } + + Task EmbedAsync(string text, CancellationToken cancellationToken); + + Task> EmbedBatchAsync( + IReadOnlyList texts, + CancellationToken cancellationToken); +} diff --git a/backend/Application/Matchmaking/SemanticRanker.cs b/backend/Application/Matchmaking/SemanticRanker.cs new file mode 100644 index 0000000..d2278e8 --- /dev/null +++ b/backend/Application/Matchmaking/SemanticRanker.cs @@ -0,0 +1,20 @@ +namespace TarninaHub.Application.Matchmaking; + +public sealed record RankedItem(T Item, double Score); + +public static class SemanticRanker +{ + public static IReadOnlyList> Rank( + IReadOnlyList queryVector, + IEnumerable<(T Item, IReadOnlyList Vector)> candidates, + int topK, + double minScore = 0) + { + return candidates + .Select(candidate => new RankedItem(candidate.Item, VectorMath.CosineSimilarity(queryVector, candidate.Vector))) + .Where(ranked => ranked.Score >= minScore) + .OrderByDescending(ranked => ranked.Score) + .Take(topK) + .ToList(); + } +} diff --git a/backend/Application/Matchmaking/VectorMath.cs b/backend/Application/Matchmaking/VectorMath.cs new file mode 100644 index 0000000..5a6a995 --- /dev/null +++ b/backend/Application/Matchmaking/VectorMath.cs @@ -0,0 +1,30 @@ +namespace TarninaHub.Application.Matchmaking; + +public static class VectorMath +{ + public static double CosineSimilarity(IReadOnlyList a, IReadOnlyList b) + { + if (a.Count == 0 || a.Count != b.Count) + { + return 0; + } + + double dot = 0; + double magnitudeA = 0; + double magnitudeB = 0; + + for (var i = 0; i < a.Count; i++) + { + dot += (double)a[i] * b[i]; + magnitudeA += (double)a[i] * a[i]; + magnitudeB += (double)b[i] * b[i]; + } + + if (magnitudeA <= 0 || magnitudeB <= 0) + { + return 0; + } + + return dot / (Math.Sqrt(magnitudeA) * Math.Sqrt(magnitudeB)); + } +} diff --git a/backend/Controllers/InnovationsController.cs b/backend/Controllers/InnovationsController.cs index 1526d95..41c7b6b 100644 --- a/backend/Controllers/InnovationsController.cs +++ b/backend/Controllers/InnovationsController.cs @@ -15,10 +15,12 @@ namespace TarninaHub.Controllers; public class InnovationsController : ControllerBase { private readonly IInnovationService _innovations; + private readonly IMatchmakingService _matchmakingService; - public InnovationsController(IInnovationService innovations) + public InnovationsController(IInnovationService innovations, IMatchmakingService matchmakingService) { _innovations = innovations; + _matchmakingService = matchmakingService; } [HttpGet] @@ -56,6 +58,8 @@ public class InnovationsController : ControllerBase var created = await _innovations.CreateAsync(request, cancellationToken); + await _matchmakingService.IndexInnovationAsync(created.Id, cancellationToken); + return CreatedAtAction(nameof(GetById), new { innovationId = created.Id }, created); } @@ -73,6 +77,11 @@ public class InnovationsController : ControllerBase var updated = await _innovations.UpdateAsync(innovationId, request, cancellationToken); + if (updated is not null) + { + await _matchmakingService.IndexInnovationAsync(updated.Id, cancellationToken); + } + return updated is null ? NotFound() : Ok(updated); } diff --git a/backend/Controllers/MatchmakingController.cs b/backend/Controllers/MatchmakingController.cs new file mode 100644 index 0000000..358699d --- /dev/null +++ b/backend/Controllers/MatchmakingController.cs @@ -0,0 +1,32 @@ +using Asp.Versioning; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Security; + +namespace TarninaHub.Controllers; + +[ApiController] +[ApiVersion("1.0")] +[Route("api/matchmaking")] +[Authorize] +public class MatchmakingController : ControllerBase +{ + private readonly IMatchmakingService _matchmakingService; + + public MatchmakingController(IMatchmakingService matchmakingService) + { + _matchmakingService = matchmakingService; + } + + [HttpPost("reindex")] + [Authorize(Policy = Policies.AdminOnly)] + public async Task> Reindex(CancellationToken cancellationToken) + { + var indexed = await _matchmakingService.ReindexAsync(cancellationToken); + + return Ok(new MatchmakingReindexResponse(indexed)); + } +} + +public sealed record MatchmakingReindexResponse(int Indexed); diff --git a/backend/Controllers/SubmissionsController.cs b/backend/Controllers/SubmissionsController.cs index 509b54d..a0063b4 100644 --- a/backend/Controllers/SubmissionsController.cs +++ b/backend/Controllers/SubmissionsController.cs @@ -16,10 +16,14 @@ namespace TarninaHub.Controllers; public class SubmissionsController : ControllerBase { private readonly ISubmissionService _submissionService; + private readonly IMatchmakingService _matchmakingService; - public SubmissionsController(ISubmissionService submissionService) + public SubmissionsController( + ISubmissionService submissionService, + IMatchmakingService matchmakingService) { _submissionService = submissionService; + _matchmakingService = matchmakingService; } [HttpPost] @@ -36,6 +40,8 @@ public class SubmissionsController : ControllerBase var created = await _submissionService.CreateAsync(userId, request, cancellationToken); + await _matchmakingService.IndexSubmissionAsync(created.Id, cancellationToken); + return CreatedAtAction(nameof(GetById), new { submissionId = created.Id }, created); } @@ -132,6 +138,75 @@ public class SubmissionsController : ControllerBase return updated is null ? NotFound() : Ok(updated); } + [HttpPost("{submissionId}/match")] + public async Task> Match( + string submissionId, + [FromBody] MatchRequest? request, + CancellationToken cancellationToken) + { + var access = await AuthorizeSubmissionAccessAsync(submissionId, cancellationToken); + + if (access is not null) + { + return access; + } + + var result = await _matchmakingService.MatchAsync( + submissionId, + request ?? new MatchRequest(null, null), + cancellationToken); + + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{submissionId}/matches")] + public async Task> Matches( + string submissionId, + [FromQuery] string? challengeId, + [FromQuery] int? maxResults, + CancellationToken cancellationToken) + { + var access = await AuthorizeSubmissionAccessAsync(submissionId, cancellationToken); + + if (access is not null) + { + return access; + } + + var result = await _matchmakingService.MatchAsync( + submissionId, + new MatchRequest(maxResults, challengeId), + cancellationToken); + + return result is null ? NotFound() : Ok(result); + } + + private async Task AuthorizeSubmissionAccessAsync( + string submissionId, + CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + + if (string.IsNullOrWhiteSpace(userId)) + { + return Unauthorized(); + } + + var submission = await _submissionService.GetAsync(submissionId, cancellationToken); + + if (submission is null) + { + return NotFound(); + } + + if (!IsStaff() && !string.Equals(submission.AuthorId, userId, StringComparison.Ordinal)) + { + return Forbid(); + } + + return null; + } + private bool IsStaff() => User.IsInRole(RoleNames.Operator) || User.IsInRole(RoleNames.Manager) || User.IsInRole(RoleNames.Admin); } diff --git a/backend/Infrastructure/DependencyInjection.cs b/backend/Infrastructure/DependencyInjection.cs index 3ea658e..3f1011c 100644 --- a/backend/Infrastructure/DependencyInjection.cs +++ b/backend/Infrastructure/DependencyInjection.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using SurrealDb.Net; using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Matchmaking; using TarninaHub.Infrastructure.Auth; using TarninaHub.Infrastructure.Events; using TarninaHub.Infrastructure.Persistence; @@ -59,6 +60,8 @@ public static class DependencyInjection services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/backend/Infrastructure/Persistence/MatchmakingService.cs b/backend/Infrastructure/Persistence/MatchmakingService.cs new file mode 100644 index 0000000..c96b84f --- /dev/null +++ b/backend/Infrastructure/Persistence/MatchmakingService.cs @@ -0,0 +1,252 @@ +using SurrealDb.Net; +using TarninaHub.Application.Abstractions; +using TarninaHub.Application.Dtos; +using TarninaHub.Application.Matchmaking; +using TarninaHub.Application.Text; + +namespace TarninaHub.Infrastructure.Persistence; + +public sealed class MatchmakingService : IMatchmakingService +{ + private readonly ISurrealDbClient _client; + private readonly IEmbeddingProvider _embeddingProvider; + private readonly ISubmissionService _submissionService; + + public MatchmakingService( + ISurrealDbClient client, + IEmbeddingProvider embeddingProvider, + ISubmissionService submissionService) + { + _client = client; + _embeddingProvider = embeddingProvider; + _submissionService = submissionService; + } + + public async Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(innovationId); + + var response = await _client.RawQuery( + "SELECT type::string(id) AS id, title, summary, description, type::string(challenge) AS challenge " + + "FROM type::record($table, $id) LIMIT 1;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + if (rows.Count == 0) + { + return; + } + + var text = EmbeddingTextBuilder.Build(rows[0].Title, rows[0].Summary, rows[0].Description); + var embedding = await _embeddingProvider.EmbedAsync(text, cancellationToken); + + var update = await _client.RawQuery( + "UPDATE type::record($table, $id) SET embedding = $embedding;", + new Dictionary { ["table"] = table, ["id"] = id, ["embedding"] = embedding }, + cancellationToken); + update.EnsureAllOks(); + } + + public async Task IndexProblemAsync(string problemId, CancellationToken cancellationToken) + { + var (table, id) = SurrealRecordId.Split(problemId); + + var response = await _client.RawQuery( + "SELECT type::string(id) AS id, title, description, type::string(challenge) AS challenge " + + "FROM type::record($table, $id) LIMIT 1;", + new Dictionary { ["table"] = table, ["id"] = id }, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + if (rows.Count == 0) + { + return; + } + + var text = EmbeddingTextBuilder.Build(rows[0].Title, null, rows[0].Description); + var embedding = await _embeddingProvider.EmbedAsync(text, cancellationToken); + + var update = await _client.RawQuery( + "UPDATE type::record($table, $id) SET embedding = $embedding;", + new Dictionary { ["table"] = table, ["id"] = id, ["embedding"] = embedding }, + cancellationToken); + update.EnsureAllOks(); + } + + public async Task IndexSubmissionAsync(string submissionId, CancellationToken cancellationToken) + { + var submission = await _submissionService.GetAsync(submissionId, cancellationToken); + + if (submission is not null) + { + await IndexProblemAsync(submission.ProblemId, cancellationToken); + } + } + + public async Task ReindexAsync(CancellationToken cancellationToken) + { + var count = 0; + + var innovations = await _client.RawQuery( + "SELECT type::string(id) AS id FROM innovation;", + cancellationToken: cancellationToken); + innovations.EnsureAllOks(); + + foreach (var row in innovations.GetValue>(0) ?? []) + { + await IndexInnovationAsync(row.Id, cancellationToken); + count++; + } + + var problems = await _client.RawQuery( + "SELECT type::string(id) AS id FROM problem;", + cancellationToken: cancellationToken); + problems.EnsureAllOks(); + + foreach (var row in problems.GetValue>(0) ?? []) + { + await IndexProblemAsync(row.Id, cancellationToken); + count++; + } + + return count; + } + + public async Task MatchAsync( + string submissionId, + MatchRequest request, + CancellationToken cancellationToken) + { + var submission = await _submissionService.GetAsync(submissionId, cancellationToken); + + if (submission is null) + { + return null; + } + + var maxResults = Math.Clamp(request.MaxResults ?? 5, 1, 20); + var queryText = EmbeddingTextBuilder.Build( + submission.Title, + null, + submission.Description); + + if (submission.Keywords.Count > 0) + { + queryText += "\n" + string.Join(" ", submission.Keywords); + } + + var queryVector = await _embeddingProvider.EmbedAsync(queryText, cancellationToken); + + var solutions = await RankSolutionsAsync(submission, request.ChallengeId, queryVector, maxResults, cancellationToken); + var similarCases = await RankSimilarCasesAsync(submission, request.ChallengeId, queryVector, maxResults, cancellationToken); + + return new MatchResponse(similarCases, solutions, Fallback: solutions.Count == 0); + } + + private async Task> RankSolutionsAsync( + SubmissionResponse submission, + string? challengeFilter, + float[] queryVector, + int maxResults, + CancellationToken cancellationToken) + { + var clauses = new List { "published = true", "embedding != NONE" }; + var parameters = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(challengeFilter)) + { + var (table, id) = SurrealRecordId.Split(challengeFilter); + clauses.Add("challenge = type::record($challengeTable, $challengeId)"); + parameters["challengeTable"] = table; + parameters["challengeId"] = id; + } + + var response = await _client.RawQuery( + "SELECT type::string(id) AS id, title, summary, type::string(challenge) AS challenge, stage, embedding " + + "FROM innovation WHERE " + string.Join(" AND ", clauses) + ";", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + var candidates = rows + .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 ranked + .Select(item => 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(); + } + + private async Task> RankSimilarCasesAsync( + SubmissionResponse submission, + string? challengeFilter, + float[] queryVector, + int maxResults, + CancellationToken cancellationToken) + { + var (problemTable, problemId) = SurrealRecordId.Split(submission.ProblemId); + var clauses = new List { "embedding != NONE", "id != type::record($problemTable, $problemId)" }; + var parameters = new Dictionary + { + ["problemTable"] = problemTable, + ["problemId"] = problemId, + }; + + if (!string.IsNullOrWhiteSpace(challengeFilter)) + { + var (table, id) = SurrealRecordId.Split(challengeFilter); + clauses.Add("challenge = type::record($challengeTable, $challengeId)"); + parameters["challengeTable"] = table; + parameters["challengeId"] = id; + } + + 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) + ";", + parameters, + cancellationToken); + response.EnsureAllOks(); + + var rows = response.GetValue>(0) ?? []; + + var candidates = rows + .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 ranked + .Select(item => new SimilarCaseItem( + item.Item.Id, + item.Item.Title, + Math.Round(item.Score, 3))) + .ToList(); + } + + private static string BuildRationale(string challenge, string queryChallengeId) + { + var normalized = NormalizeRecordId(challenge); + + return string.Equals(normalized, queryChallengeId, StringComparison.Ordinal) + ? "Pasuje obszarowo do zgłoszenia." + : "Wysoka zbieżność semantyczna opisu."; + } + + private static string? NormalizeRecordId(string? value) => + string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value; +} diff --git a/backend/Infrastructure/Persistence/SurrealRows.cs b/backend/Infrastructure/Persistence/SurrealRows.cs index 1c72c27..a8f975b 100644 --- a/backend/Infrastructure/Persistence/SurrealRows.cs +++ b/backend/Infrastructure/Persistence/SurrealRows.cs @@ -118,6 +118,25 @@ internal sealed class InnovationRow [CborProperty("published")] public bool Published { get; set; } [CborProperty("updated_at")] public DateTimeOffset? UpdatedAt { get; set; } + + [CborProperty("embedding")] public List? Embedding { get; set; } +} + +internal sealed class ProblemRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; + + [CborProperty("title")] public string Title { get; set; } = string.Empty; + + [CborProperty("description")] public string Description { get; set; } = string.Empty; + + [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; + + [CborProperty("location")] public string Location { get; set; } = string.Empty; + + [CborProperty("author")] public string Author { get; set; } = string.Empty; + + [CborProperty("embedding")] public List? Embedding { get; set; } } internal sealed class TrendRow @@ -131,3 +150,8 @@ internal sealed class TrendSubmissionRow { [CborProperty("challenge")] public string Challenge { get; set; } = string.Empty; } + +internal sealed class IdRow +{ + [CborProperty("id")] public string Id { get; set; } = string.Empty; +}