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.
307 lines
11 KiB
C#
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;
|
|
}
|