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:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user