feat(backend): add semantic matchmaking with embedding pipeline
Add a pluggable embedding provider with an offline deterministic default, vector indexing for problems and innovations, cosine semantic ranking, similar cases and solution suggestions, area filtering and an admin reindex endpoint. Refresh the index when innovations or submissions change.
This commit is contained in:
@@ -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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<InnovationRow>>(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<string, object?> { ["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<string, object?> { ["table"] = table, ["id"] = id },
|
||||
cancellationToken);
|
||||
response.EnsureAllOks();
|
||||
|
||||
var rows = response.GetValue<List<ProblemRow>>(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<string, object?> { ["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<int> 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<List<IdRow>>(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<List<IdRow>>(0) ?? [])
|
||||
{
|
||||
await IndexProblemAsync(row.Id, cancellationToken);
|
||||
count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
public async Task<MatchResponse?> 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<List<SolutionItem>> RankSolutionsAsync(
|
||||
SubmissionResponse submission,
|
||||
string? challengeFilter,
|
||||
float[] queryVector,
|
||||
int maxResults,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var clauses = new List<string> { "published = true", "embedding != NONE" };
|
||||
var parameters = new Dictionary<string, object?>();
|
||||
|
||||
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<List<InnovationRow>>(0) ?? [];
|
||||
|
||||
var candidates = rows
|
||||
.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 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<List<SimilarCaseItem>> RankSimilarCasesAsync(
|
||||
SubmissionResponse submission,
|
||||
string? challengeFilter,
|
||||
float[] queryVector,
|
||||
int maxResults,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (problemTable, problemId) = SurrealRecordId.Split(submission.ProblemId);
|
||||
var clauses = new List<string> { "embedding != NONE", "id != type::record($problemTable, $problemId)" };
|
||||
var parameters = new Dictionary<string, object?>
|
||||
{
|
||||
["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<List<ProblemRow>>(0) ?? [];
|
||||
|
||||
var candidates = rows
|
||||
.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 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;
|
||||
}
|
||||
Reference in New Issue
Block a user