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.
This commit is contained in:
Rafał Tyński
2026-10-04 02:52:45 +02:00
parent 182c26d9f8
commit 03e2d66ca4
11 changed files with 385 additions and 23 deletions
@@ -11,15 +11,21 @@ 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)
ISubmissionService submissionService,
ILlmProvider llmProvider,
ILlmRequestLimiter llmRequestLimiter)
{
_client = client;
_embeddingProvider = embeddingProvider;
_submissionService = submissionService;
_llmProvider = llmProvider;
_llmRequestLimiter = llmRequestLimiter;
}
public async Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken)
@@ -130,10 +136,7 @@ public sealed class MatchmakingService : IMatchmakingService
}
var maxResults = Math.Clamp(request.MaxResults ?? 5, 1, 20);
var queryText = EmbeddingTextBuilder.Build(
submission.Title,
null,
submission.Description);
var queryText = EmbeddingTextBuilder.Build(submission.Title, null, submission.Description);
if (submission.Keywords.Count > 0)
{
@@ -179,17 +182,37 @@ public sealed class MatchmakingService : IMatchmakingService
.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);
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 ranked
.Select(item => new SolutionItem(
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),
BuildRationale(item.Item.Challenge, submission.ChallengeId)))
.ToList();
rationale));
}
return solutions;
}
private async Task<List<SimilarCaseItem>> RankSimilarCasesAsync(
@@ -216,8 +239,8 @@ public sealed class MatchmakingService : IMatchmakingService
}
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) + ";",
"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();
@@ -228,23 +251,54 @@ public sealed class MatchmakingService : IMatchmakingService
.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 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 ranked
.Select(item => new SimilarCaseItem(
item.Item.Id,
item.Item.Title,
Math.Round(item.Score, 3)))
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 static string BuildRationale(string challenge, string queryChallengeId)
private async Task<string> BuildRationaleAsync(
SubmissionResponse submission,
InnovationRow innovation,
double score,
bool challengeMatch,
CancellationToken cancellationToken)
{
var normalized = NormalizeRecordId(challenge);
if (_llmProvider.IsConfigured && _llmRequestLimiter.TryAcquire())
{
var prompt = MatchPromptBuilder.BuildExplanation(
submission.Title,
submission.Description,
submission.ChallengeId,
innovation.Title,
innovation.Summary,
innovation.Stage,
score);
return string.Equals(normalized, queryChallengeId, StringComparison.Ordinal)
? "Pasuje obszarowo do zgłoszenia."
: "Wysoka zbieżność semantyczna opisu.";
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) =>