Files
Rafał Tyński 03e2d66ca4 feat(backend): add LLM provider abstraction, contextual ranking and explanations
Add a pluggable LLM provider with an offline template fallback, prompt builder, contextual match scoring (area/location boosts, availability), per-solution explanations and in-memory request limiting for cost control.
2026-10-04 02:52:45 +02:00

307 lines
11 KiB
C#

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;
private readonly ILlmProvider _llmProvider;
private readonly ILlmRequestLimiter _llmRequestLimiter;
public MatchmakingService(
ISurrealDbClient client,
IEmbeddingProvider embeddingProvider,
ISubmissionService submissionService,
ILlmProvider llmProvider,
ILlmRequestLimiter llmRequestLimiter)
{
_client = client;
_embeddingProvider = embeddingProvider;
_submissionService = submissionService;
_llmProvider = llmProvider;
_llmRequestLimiter = llmRequestLimiter;
}
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 * 3, minScore: 0.05)
.Select(item =>
{
var challengeMatch = string.Equals(
NormalizeRecordId(item.Item.Challenge),
submission.ChallengeId,
StringComparison.Ordinal);
return (item.Item, ChallengeMatch: challengeMatch, Score: MatchScoring.Combine(item.Score, challengeMatch, false, true));
})
.OrderByDescending(item => item.Score)
.Take(maxResults)
.ToList();
var solutions = new List<SolutionItem>(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),
rationale));
}
return solutions;
}
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, " +
"type::string(location) AS location, 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!));
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 (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 async Task<string> BuildRationaleAsync(
SubmissionResponse submission,
InnovationRow innovation,
double score,
bool challengeMatch,
CancellationToken cancellationToken)
{
if (_llmProvider.IsConfigured && _llmRequestLimiter.TryAcquire())
{
var prompt = MatchPromptBuilder.BuildExplanation(
submission.Title,
submission.Description,
submission.ChallengeId,
innovation.Title,
innovation.Summary,
innovation.Stage,
score);
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) =>
string.IsNullOrWhiteSpace(value) || value is "NONE" or "NULL" ? null : value;
}