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; }