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:
@@ -0,0 +1,13 @@
|
|||||||
|
namespace TarninaHub.Application.Matchmaking;
|
||||||
|
|
||||||
|
public interface ILlmProvider
|
||||||
|
{
|
||||||
|
/// <summary>True when a real provider is configured (API key etc.).</summary>
|
||||||
|
bool IsConfigured { get; }
|
||||||
|
|
||||||
|
/// <summary>Returns the completion text or null when unavailable.</summary>
|
||||||
|
Task<string?> CompleteAsync(
|
||||||
|
string systemPrompt,
|
||||||
|
string userPrompt,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace TarninaHub.Application.Matchmaking;
|
||||||
|
|
||||||
|
public interface ILlmRequestLimiter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to reserve one LLM request within the current window.
|
||||||
|
/// Returns false when the limit is exceeded (caller should fall back).
|
||||||
|
/// </summary>
|
||||||
|
bool TryAcquire();
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
namespace TarninaHub.Application.Matchmaking;
|
||||||
|
|
||||||
|
public sealed record LlmPrompt(string System, string User);
|
||||||
|
|
||||||
|
public static class MatchPromptBuilder
|
||||||
|
{
|
||||||
|
private const string SystemPrompt =
|
||||||
|
"Jesteś asystentem Małopolskiego Hubu Innowacji Społecznych. " +
|
||||||
|
"Wyjaśnij po polsku, prostym językiem (maks. 2 zdania), dlaczego proponowana " +
|
||||||
|
"innowacja pasuje do zgłoszonej potrzeby. Nie wymyślaj faktów.";
|
||||||
|
|
||||||
|
public static LlmPrompt BuildExplanation(
|
||||||
|
string problemTitle,
|
||||||
|
string problemDescription,
|
||||||
|
string challengeId,
|
||||||
|
string innovationTitle,
|
||||||
|
string innovationSummary,
|
||||||
|
string stage,
|
||||||
|
double score)
|
||||||
|
{
|
||||||
|
var user =
|
||||||
|
$"Potrzeba: {problemTitle}\n" +
|
||||||
|
$"Opis: {problemDescription}\n" +
|
||||||
|
$"Obszar: {challengeId}\n" +
|
||||||
|
$"Proponowana innowacja: {innovationTitle}\n" +
|
||||||
|
$"Streszczenie: {innovationSummary}\n" +
|
||||||
|
$"Etap: {stage}\n" +
|
||||||
|
$"Ocena dopasowania: {score:0.00}\n" +
|
||||||
|
"Uzasadnij krótko dopasowanie.";
|
||||||
|
|
||||||
|
return new LlmPrompt(SystemPrompt, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace TarninaHub.Application.Matchmaking;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Combines semantic similarity with context signals (area, location) and
|
||||||
|
/// availability into a final 0..1 match score.
|
||||||
|
/// </summary>
|
||||||
|
public static class MatchScoring
|
||||||
|
{
|
||||||
|
private const double ChallengeBoost = 0.10;
|
||||||
|
private const double LocationBoost = 0.05;
|
||||||
|
|
||||||
|
public static double Combine(
|
||||||
|
double semanticScore,
|
||||||
|
bool challengeMatch,
|
||||||
|
bool locationMatch,
|
||||||
|
bool available)
|
||||||
|
{
|
||||||
|
if (!available)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var score = semanticScore;
|
||||||
|
|
||||||
|
if (challengeMatch)
|
||||||
|
{
|
||||||
|
score += ChallengeBoost;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationMatch)
|
||||||
|
{
|
||||||
|
score += LocationBoost;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.Clamp(score, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace TarninaHub.Application.Matchmaking;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default provider used when no external LLM is configured. Keeps the system
|
||||||
|
/// fully offline/deterministic; matchmaking falls back to template rationales.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NullLlmProvider : ILlmProvider
|
||||||
|
{
|
||||||
|
public bool IsConfigured => false;
|
||||||
|
|
||||||
|
public Task<string?> CompleteAsync(
|
||||||
|
string systemPrompt,
|
||||||
|
string userPrompt,
|
||||||
|
CancellationToken cancellationToken) => Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace TarninaHub.Application.Matchmaking;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic, offline explanation of "why this fits". Used as the default
|
||||||
|
/// and as a fallback when the LLM is unavailable or rate-limited.
|
||||||
|
/// </summary>
|
||||||
|
public static class RationaleBuilder
|
||||||
|
{
|
||||||
|
public static string Build(bool challengeMatch, bool locationMatch, double score)
|
||||||
|
{
|
||||||
|
var reasons = new List<string>();
|
||||||
|
|
||||||
|
if (challengeMatch)
|
||||||
|
{
|
||||||
|
reasons.Add("ten sam obszar problemowy");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locationMatch)
|
||||||
|
{
|
||||||
|
reasons.Add("zbliżona lokalizacja");
|
||||||
|
}
|
||||||
|
|
||||||
|
reasons.Add(score >= 0.3
|
||||||
|
? "wysoka zbieżność opisu potrzeby"
|
||||||
|
: "zbieżność semantyczna opisu");
|
||||||
|
|
||||||
|
return "Proponowane dopasowanie: " + string.Join(", ", reasons) + ".";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ using TarninaHub.Application.Abstractions;
|
|||||||
using TarninaHub.Application.Matchmaking;
|
using TarninaHub.Application.Matchmaking;
|
||||||
using TarninaHub.Infrastructure.Auth;
|
using TarninaHub.Infrastructure.Auth;
|
||||||
using TarninaHub.Infrastructure.Events;
|
using TarninaHub.Infrastructure.Events;
|
||||||
|
using TarninaHub.Infrastructure.Llm;
|
||||||
using TarninaHub.Infrastructure.Persistence;
|
using TarninaHub.Infrastructure.Persistence;
|
||||||
using TarninaHub.Infrastructure.Settings;
|
using TarninaHub.Infrastructure.Settings;
|
||||||
|
|
||||||
@@ -19,6 +20,23 @@ public static class DependencyInjection
|
|||||||
services.Configure<SurrealDbSettings>(configuration.GetSection("SurrealDb"));
|
services.Configure<SurrealDbSettings>(configuration.GetSection("SurrealDb"));
|
||||||
services.Configure<AuthSettings>(configuration.GetSection("Auth"));
|
services.Configure<AuthSettings>(configuration.GetSection("Auth"));
|
||||||
|
|
||||||
|
var llmSettings = configuration.GetSection("Llm").Get<LlmSettings>() ?? new LlmSettings();
|
||||||
|
services.Configure<LlmSettings>(configuration.GetSection("Llm"));
|
||||||
|
services.AddSingleton<ILlmRequestLimiter>(
|
||||||
|
new InMemoryLlmRequestLimiter(llmSettings.MaxRequestsPerMinute));
|
||||||
|
|
||||||
|
if (llmSettings.IsConfigured)
|
||||||
|
{
|
||||||
|
services.AddHttpClient<OpenAiCompatibleLlmProvider>(
|
||||||
|
client => client.Timeout = TimeSpan.FromSeconds(llmSettings.TimeoutSeconds));
|
||||||
|
services.AddSingleton<ILlmProvider>(
|
||||||
|
provider => provider.GetRequiredService<OpenAiCompatibleLlmProvider>());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
services.AddSingleton<ILlmProvider, NullLlmProvider>();
|
||||||
|
}
|
||||||
|
|
||||||
var settings = configuration.GetSection("SurrealDb").Get<SurrealDbSettings>()
|
var settings = configuration.GetSection("SurrealDb").Get<SurrealDbSettings>()
|
||||||
?? new SurrealDbSettings();
|
?? new SurrealDbSettings();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using TarninaHub.Application.Matchmaking;
|
||||||
|
|
||||||
|
namespace TarninaHub.Infrastructure.Llm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory sliding-window request limiter (per instance) for LLM cost control.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InMemoryLlmRequestLimiter : ILlmRequestLimiter
|
||||||
|
{
|
||||||
|
private readonly int _maxPerWindow;
|
||||||
|
private readonly TimeSpan _window;
|
||||||
|
private readonly Queue<DateTimeOffset> _timestamps = new();
|
||||||
|
private readonly Lock _gate = new();
|
||||||
|
|
||||||
|
public InMemoryLlmRequestLimiter(int maxRequestsPerMinute, TimeSpan? window = null)
|
||||||
|
{
|
||||||
|
_maxPerWindow = Math.Max(1, maxRequestsPerMinute);
|
||||||
|
_window = window ?? TimeSpan.FromMinutes(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool TryAcquire()
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
while (_timestamps.Count > 0 && now - _timestamps.Peek() > _window)
|
||||||
|
{
|
||||||
|
_timestamps.Dequeue();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_timestamps.Count >= _maxPerWindow)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_timestamps.Enqueue(now);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace TarninaHub.Infrastructure.Llm;
|
||||||
|
|
||||||
|
public sealed class LlmSettings
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Provider identifier, e.g. "openai" (OpenAI-compatible chat API).</summary>
|
||||||
|
public string Provider { get; set; } = "openai";
|
||||||
|
|
||||||
|
public string? ApiKey { get; set; }
|
||||||
|
|
||||||
|
public string BaseUrl { get; set; } = "https://api.openai.com/v1";
|
||||||
|
|
||||||
|
public string Model { get; set; } = "gpt-4o-mini";
|
||||||
|
|
||||||
|
public int MaxTokens { get; set; } = 256;
|
||||||
|
|
||||||
|
public int MaxRequestsPerMinute { get; set; } = 30;
|
||||||
|
|
||||||
|
public int TimeoutSeconds { get; set; } = 20;
|
||||||
|
|
||||||
|
public bool IsConfigured => Enabled && !string.IsNullOrWhiteSpace(ApiKey);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using TarninaHub.Application.Matchmaking;
|
||||||
|
|
||||||
|
namespace TarninaHub.Infrastructure.Llm;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OpenAI-compatible chat completion provider. Interchangeable behind
|
||||||
|
/// <see cref="ILlmProvider"/> (ADR-0005). The API key is read from
|
||||||
|
/// configuration/secrets and never logged.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OpenAiCompatibleLlmProvider : ILlmProvider
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly LlmSettings _settings;
|
||||||
|
private readonly ILogger<OpenAiCompatibleLlmProvider> _logger;
|
||||||
|
|
||||||
|
public OpenAiCompatibleLlmProvider(
|
||||||
|
HttpClient httpClient,
|
||||||
|
IOptions<LlmSettings> settings,
|
||||||
|
ILogger<OpenAiCompatibleLlmProvider> logger)
|
||||||
|
{
|
||||||
|
_httpClient = httpClient;
|
||||||
|
_settings = settings.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsConfigured => _settings.IsConfigured;
|
||||||
|
|
||||||
|
public async Task<string?> CompleteAsync(
|
||||||
|
string systemPrompt,
|
||||||
|
string userPrompt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!IsConfigured)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var endpoint = _settings.BaseUrl.TrimEnd('/') + "/chat/completions";
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
model = _settings.Model,
|
||||||
|
max_tokens = _settings.MaxTokens,
|
||||||
|
messages = new object[]
|
||||||
|
{
|
||||||
|
new { role = "system", content = systemPrompt },
|
||||||
|
new { role = "user", content = userPrompt },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
|
||||||
|
{
|
||||||
|
Content = JsonContent.Create(payload),
|
||||||
|
};
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue(
|
||||||
|
"Bearer",
|
||||||
|
_settings.ApiKey);
|
||||||
|
|
||||||
|
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("LLM provider returned status {Status}.", (int)response.StatusCode);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var document = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken);
|
||||||
|
|
||||||
|
return document
|
||||||
|
.GetProperty("choices")[0]
|
||||||
|
.GetProperty("message")
|
||||||
|
.GetProperty("content")
|
||||||
|
.GetString();
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(exception, "LLM completion failed; falling back to template rationale.");
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,15 +11,21 @@ public sealed class MatchmakingService : IMatchmakingService
|
|||||||
private readonly ISurrealDbClient _client;
|
private readonly ISurrealDbClient _client;
|
||||||
private readonly IEmbeddingProvider _embeddingProvider;
|
private readonly IEmbeddingProvider _embeddingProvider;
|
||||||
private readonly ISubmissionService _submissionService;
|
private readonly ISubmissionService _submissionService;
|
||||||
|
private readonly ILlmProvider _llmProvider;
|
||||||
|
private readonly ILlmRequestLimiter _llmRequestLimiter;
|
||||||
|
|
||||||
public MatchmakingService(
|
public MatchmakingService(
|
||||||
ISurrealDbClient client,
|
ISurrealDbClient client,
|
||||||
IEmbeddingProvider embeddingProvider,
|
IEmbeddingProvider embeddingProvider,
|
||||||
ISubmissionService submissionService)
|
ISubmissionService submissionService,
|
||||||
|
ILlmProvider llmProvider,
|
||||||
|
ILlmRequestLimiter llmRequestLimiter)
|
||||||
{
|
{
|
||||||
_client = client;
|
_client = client;
|
||||||
_embeddingProvider = embeddingProvider;
|
_embeddingProvider = embeddingProvider;
|
||||||
_submissionService = submissionService;
|
_submissionService = submissionService;
|
||||||
|
_llmProvider = llmProvider;
|
||||||
|
_llmRequestLimiter = llmRequestLimiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task IndexInnovationAsync(string innovationId, CancellationToken cancellationToken)
|
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 maxResults = Math.Clamp(request.MaxResults ?? 5, 1, 20);
|
||||||
var queryText = EmbeddingTextBuilder.Build(
|
var queryText = EmbeddingTextBuilder.Build(submission.Title, null, submission.Description);
|
||||||
submission.Title,
|
|
||||||
null,
|
|
||||||
submission.Description);
|
|
||||||
|
|
||||||
if (submission.Keywords.Count > 0)
|
if (submission.Keywords.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -179,17 +182,37 @@ public sealed class MatchmakingService : IMatchmakingService
|
|||||||
.Where(row => row.Embedding is { Count: > 0 })
|
.Where(row => row.Embedding is { Count: > 0 })
|
||||||
.Select(row => (Item: row, Vector: (IReadOnlyList<float>)row.Embedding!));
|
.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
|
return (item.Item, ChallengeMatch: challengeMatch, Score: MatchScoring.Combine(item.Score, challengeMatch, false, true));
|
||||||
.Select(item => new SolutionItem(
|
})
|
||||||
|
.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.Id,
|
||||||
item.Item.Title,
|
item.Item.Title,
|
||||||
item.Item.Stage,
|
item.Item.Stage,
|
||||||
NormalizeRecordId(item.Item.Challenge) ?? string.Empty,
|
NormalizeRecordId(item.Item.Challenge) ?? string.Empty,
|
||||||
Math.Round(item.Score, 3),
|
Math.Round(item.Score, 3),
|
||||||
BuildRationale(item.Item.Challenge, submission.ChallengeId)))
|
rationale));
|
||||||
.ToList();
|
}
|
||||||
|
|
||||||
|
return solutions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<List<SimilarCaseItem>> RankSimilarCasesAsync(
|
private async Task<List<SimilarCaseItem>> RankSimilarCasesAsync(
|
||||||
@@ -216,8 +239,8 @@ public sealed class MatchmakingService : IMatchmakingService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response = await _client.RawQuery(
|
var response = await _client.RawQuery(
|
||||||
"SELECT type::string(id) AS id, title, type::string(challenge) AS challenge, embedding " +
|
"SELECT type::string(id) AS id, title, type::string(challenge) AS challenge, " +
|
||||||
"FROM problem WHERE " + string.Join(" AND ", clauses) + ";",
|
"type::string(location) AS location, embedding FROM problem WHERE " + string.Join(" AND ", clauses) + ";",
|
||||||
parameters,
|
parameters,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
response.EnsureAllOks();
|
response.EnsureAllOks();
|
||||||
@@ -228,23 +251,54 @@ public sealed class MatchmakingService : IMatchmakingService
|
|||||||
.Where(row => row.Embedding is { Count: > 0 })
|
.Where(row => row.Embedding is { Count: > 0 })
|
||||||
.Select(row => (Item: row, Vector: (IReadOnlyList<float>)row.Embedding!));
|
.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
|
return (item.Item, Score: MatchScoring.Combine(item.Score, challengeMatch, locationMatch, true));
|
||||||
.Select(item => new SimilarCaseItem(
|
})
|
||||||
item.Item.Id,
|
.OrderByDescending(item => item.Score)
|
||||||
item.Item.Title,
|
.Take(maxResults)
|
||||||
Math.Round(item.Score, 3)))
|
.Select(item => new SimilarCaseItem(item.Item.Id, item.Item.Title, Math.Round(item.Score, 3)))
|
||||||
.ToList();
|
.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)
|
var completion = await _llmProvider.CompleteAsync(prompt.System, prompt.User, cancellationToken);
|
||||||
? "Pasuje obszarowo do zgłoszenia."
|
|
||||||
: "Wysoka zbieżność semantyczna opisu.";
|
if (!string.IsNullOrWhiteSpace(completion))
|
||||||
|
{
|
||||||
|
return completion.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RationaleBuilder.Build(challengeMatch, locationMatch: false, score);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? NormalizeRecordId(string? value) =>
|
private static string? NormalizeRecordId(string? value) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user