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) + ".";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user