Changes
Build and Push Docker Images / build (push) Successful in 37s

This commit is contained in:
2026-05-04 21:02:35 +03:00
parent 34625ae242
commit fa1ef23c02
87 changed files with 3151 additions and 522 deletions
@@ -0,0 +1,11 @@
using Api.Requests;
using Api.Responses;
namespace Api.Services.Contracts;
public interface ICvMatcherService
{
Task<CvUploadResponse> UploadCvAsync(IFormFile file, bool gdprConsent, CancellationToken ct);
Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct);
Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct);
}
@@ -0,0 +1,6 @@
namespace Api.Services.Contracts;
public interface IEmailService
{
Task SendMatchAsync(string? explicitTo, string subject, string body, CancellationToken ct);
}
@@ -0,0 +1,6 @@
namespace Api.Services.Contracts;
public interface IJobTextExtractor
{
Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct);
}
@@ -0,0 +1,6 @@
namespace Api.Services.Contracts;
public interface IMatcherAiClient
{
Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct);
}
@@ -0,0 +1,12 @@
using Api.Responses;
namespace Api.Services.Contracts;
public interface IMatcherRepository
{
Task InitializeAsync(CancellationToken ct);
Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct);
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct);
Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct);
Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct);
}
@@ -0,0 +1,12 @@
using Api.Requests;
using Api.Responses;
namespace Api.Services.Contracts;
public interface IRagApiClient
{
Task<RagIndexResponse> IndexCvPdfAsync(IFormFile file, CancellationToken ct);
Task<RagIndexResponse> IndexJobTextAsync(string text, string? url, string? title, CancellationToken ct);
Task<RagDocumentDetails?> GetDocumentAsync(string documentId, CancellationToken ct);
Task<RagSearchResponse> SearchAsync(RagSearchRequest request, CancellationToken ct);
}
+201
View File
@@ -0,0 +1,201 @@
using System.Text.Json;
using Api.Requests;
using Api.Responses;
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services;
public sealed class CvMatcherService : ICvMatcherService
{
private readonly IRagApiClient _rag;
private readonly IJobTextExtractor _jobTextExtractor;
private readonly IMatcherAiClient _ai;
private readonly IMatcherRepository _repository;
private readonly IEmailService _email;
private readonly MatcherSettings _settings;
public CvMatcherService(
IRagApiClient rag,
IJobTextExtractor jobTextExtractor,
IMatcherAiClient ai,
IMatcherRepository repository,
IEmailService email,
IOptions<MatcherSettings> options)
{
_rag = rag;
_jobTextExtractor = jobTextExtractor;
_ai = ai;
_repository = repository;
_email = email;
_settings = options.Value;
}
public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, bool gdprConsent, CancellationToken ct)
{
if (!gdprConsent) throw new InvalidOperationException("GDPR consent is required.");
var response = await _rag.IndexCvPdfAsync(file, ct);
return new CvUploadResponse
{
DocumentId = response.DocumentId,
TextHash = response.TextHash,
DocumentType = response.DocumentType,
Title = response.Title,
Chunks = response.Chunks,
Characters = response.Characters,
Cached = response.Cached,
Summary = response.Cached ? "CV already indexed. Cached data reused." : "CV indexed successfully."
};
}
public async Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct)
{
var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found.");
if (!string.Equals(cv.DocumentType, "cv", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("The provided document is not a CV.");
}
var search = await _rag.SearchAsync(new RagSearchRequest
{
QueryText = BuildCvSearchProfile(cv.Text),
TargetDocumentTypes = ["job"],
TopK = request.TopK ?? _settings.TopK
}, ct);
var deepScoreLimit = Math.Clamp(_settings.DeepScoreTopN, 1, 10);
var jobs = new List<JobMatchResponse>();
foreach (var result in search.Results.Take(deepScoreLimit))
{
var job = await _rag.GetDocumentAsync(result.DocumentId, ct);
if (job is null) continue;
jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, ct));
}
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
}
public async Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct)
{
if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required.");
if (string.IsNullOrWhiteSpace(request.CvDocumentId)) throw new InvalidOperationException("Missing CV document id.");
var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found.");
var jobText = await _jobTextExtractor.ExtractAsync(request.JobUrl, request.JobDescription, ct);
if (jobText.Length < 80) throw new InvalidOperationException("Could not extract enough job text. Paste the job description manually.");
var job = await _rag.IndexJobTextAsync(jobText, request.JobUrl, ExtractJobTitle(jobText), ct);
var jobDocument = await _rag.GetDocumentAsync(job.DocumentId, ct) ?? throw new InvalidOperationException("Indexed job document not found.");
var search = await _rag.SearchAsync(new RagSearchRequest
{
QueryText = BuildCvSearchProfile(cv.Text),
TargetDocumentTypes = ["job"],
TopK = Math.Max(5, _settings.TopK)
}, ct);
var matchedChunks = search.Results
.FirstOrDefault(x => x.DocumentId == job.DocumentId)?
.MatchedChunks.Select(x => x.Text).ToArray() ?? [];
return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, ct);
}
private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, CancellationToken ct)
{
var cached = await _repository.GetMatchAsync(cv.Id, job.Id, ct);
if (cached is not null) return cached;
var cvText = Limit(cv.Text, 18000);
var jobText = Limit(job.Text, 14000);
var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000);
const string systemPrompt = """
You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.
Penalize missing required skills. Do not invent experience. Use concise business language.
JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]}
""";
var userPrompt = $"""
CV:
{cvText}
JOB:
{jobText}
SEMANTICALLY MATCHED JOB EVIDENCE:
{evidence}
""";
var json = await _ai.CreateChatCompletionAsync(systemPrompt, userPrompt, 0.2m, ct);
var result = ParseResult(json);
result.JobDocumentId = job.Id;
result.JobUrl = job.SourceUrl;
result.Cached = false;
await _repository.SaveMatchAsync(cv.Id, job.Id, result, ct);
await _email.SendMatchAsync(
email,
$"MyAi.ro CV Match: {result.Score}% - {job.Title}",
BuildEmailBody(cv, job, result),
ct);
return result;
}
private static JobMatchResponse ParseResult(string json)
{
try
{
var parsed = JsonSerializer.Deserialize<JobMatchResponse>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (parsed is not null) return parsed;
}
catch
{
// Fall through to safe response.
}
return new JobMatchResponse
{
Score = 0,
Summary = "The AI response could not be parsed as structured JSON.",
Recommendations = ["Inspect the raw model output and tune the scoring prompt."]
};
}
private static string BuildCvSearchProfile(string cvText)
{
var text = Limit(cvText, 10000);
return $"Candidate profile, skills, technologies, seniority, industry experience, project experience: {text}";
}
private static string ExtractJobTitle(string jobText)
{
var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140);
return first ?? "Job description";
}
private static string Limit(string value, int max) => value.Length <= max ? value : value[..max];
private static string BuildEmailBody(RagDocumentDetails cv, RagDocumentDetails job, JobMatchResponse result) => $"""
CV Matcher result
CV: {cv.Title}
Job: {job.Title}
Job URL: {job.SourceUrl ?? "N/A"}
Score: {result.Score}%
Summary:
{result.Summary}
Strengths:
- {string.Join("\n- ", result.Strengths)}
Gaps:
- {string.Join("\n- ", result.Gaps)}
Recommendations:
- {string.Join("\n- ", result.Recommendations)}
""";
}
+46
View File
@@ -0,0 +1,46 @@
using Api.Services.Contracts;
using Api.Settings;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
using MimeKit;
namespace Api.Services;
public sealed class EmailService : IEmailService
{
private readonly SmtpSettings _settings;
private readonly ILogger<EmailService> _logger;
public EmailService(IOptions<SmtpSettings> options, ILogger<EmailService> logger)
{
_settings = options.Value;
_logger = logger;
}
public async Task SendMatchAsync(string? explicitTo, string subject, string body, CancellationToken ct)
{
var to = !string.IsNullOrWhiteSpace(explicitTo) ? explicitTo : _settings.ToEmail;
if (string.IsNullOrWhiteSpace(_settings.Host) || string.IsNullOrWhiteSpace(to))
{
_logger.LogInformation("SMTP is not configured. Skipping CV matcher email.");
return;
}
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(_settings.FromEmail));
message.To.Add(MailboxAddress.Parse(to));
message.Subject = subject;
message.Body = new TextPart("plain") { Text = body };
using var client = new SmtpClient();
var secureSocket = _settings.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
await client.ConnectAsync(_settings.Host, _settings.Port, secureSocket, ct);
if (!string.IsNullOrWhiteSpace(_settings.Username))
{
await client.AuthenticateAsync(_settings.Username, _settings.Password, ct);
}
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
}
+13
View File
@@ -0,0 +1,13 @@
using System.Security.Cryptography;
using System.Text;
namespace Api.Services;
public static class HashHelper
{
public static string Compute(string value)
{
using var sha = SHA256.Create();
return Convert.ToHexString(sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? string.Empty)));
}
}
@@ -0,0 +1,51 @@
using System.Net;
using System.Text.RegularExpressions;
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services;
public sealed class JobTextExtractor : IJobTextExtractor
{
private readonly HttpClient _http;
private readonly MatcherSettings _settings;
public JobTextExtractor(HttpClient http, IOptions<MatcherSettings> options)
{
_http = http;
_settings = options.Value;
_http.Timeout = TimeSpan.FromSeconds(25);
_http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0");
}
public async Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct)
{
var pasted = Normalize(jobDescription ?? string.Empty);
if (!string.IsNullOrWhiteSpace(pasted)) return Limit(pasted);
if (string.IsNullOrWhiteSpace(jobUrl)) return string.Empty;
if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
{
throw new InvalidOperationException("Invalid job URL.");
}
var html = await _http.GetStringAsync(uri, ct);
html = Regex.Replace(html, "<script[\\s\\S]*?</script>", " ", RegexOptions.IgnoreCase);
html = Regex.Replace(html, "<style[\\s\\S]*?</style>", " ", RegexOptions.IgnoreCase);
html = Regex.Replace(html, "<[^>]+>", " ");
return Limit(Normalize(WebUtility.HtmlDecode(html)));
}
private string Limit(string value)
{
var max = Math.Max(4000, _settings.MaxJobTextChars);
return value.Length <= max ? value : value[..max];
}
private static string Normalize(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
return string.Join(' ', value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Trim();
}
}
@@ -0,0 +1,95 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services;
public sealed class MatcherAiClient : IMatcherAiClient
{
private readonly HttpClient _http;
private readonly IMatcherRepository _repository;
private readonly AiSettings _settings;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public MatcherAiClient(HttpClient http, IMatcherRepository repository, IOptions<AiSettings> options)
{
_http = http;
_repository = repository;
_settings = options.Value;
}
public async Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct)
{
var model = GetModel();
var cacheKey = HashHelper.Compute($"chat:{_settings.Provider}:{model}:{temperature:0.00}:{systemPrompt}:{userPrompt}");
var cached = await _repository.GetChatCompletionAsync(cacheKey, ct);
if (cached is not null) return cached;
var response = IsOllama()
? await CreateOllamaChatCompletionAsync(systemPrompt, userPrompt, temperature, ct)
: await CreateOpenAiChatCompletionAsync(systemPrompt, userPrompt, temperature, ct);
await _repository.SaveChatCompletionAsync(cacheKey, model, temperature, response, ct);
return response;
}
private bool IsOllama() => string.Equals(_settings.Provider, "Ollama", StringComparison.OrdinalIgnoreCase);
private string GetModel() => IsOllama() ? _settings.Ollama.ChatModel : _settings.OpenAI.ChatModel;
private async Task<string> CreateOpenAiChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_settings.OpenAI.ApiKey)) throw new InvalidOperationException("OpenAI API key is missing.");
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _settings.OpenAI.ApiKey);
request.Content = ToJson(new
{
model = _settings.OpenAI.ChatModel,
temperature,
response_format = new { type = "json_object" },
messages = new[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
}
});
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(15, _settings.OpenAI.TimeoutSeconds)));
using var response = await _http.SendAsync(request, cts.Token);
var json = await response.Content.ReadAsStringAsync(cts.Token);
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"OpenAI chat failed: {(int)response.StatusCode} {json}");
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString() ?? "{}";
}
private async Task<string> CreateOllamaChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct)
{
var baseUrl = _settings.Ollama.BaseUrl.TrimEnd('/');
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(Math.Max(30, _settings.Ollama.TimeoutSeconds)));
using var response = await _http.PostAsync($"{baseUrl}/api/chat", ToJson(new
{
model = _settings.Ollama.ChatModel,
stream = false,
format = "json",
messages = new[]
{
new { role = "system", content = systemPrompt },
new { role = "user", content = userPrompt }
},
options = new { temperature = (float)temperature }
}), cts.Token);
var json = await response.Content.ReadAsStringAsync(cts.Token);
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Ollama chat failed: {(int)response.StatusCode} {json}");
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("message").GetProperty("content").GetString() ?? "{}";
}
private static StringContent ToJson<T>(T payload) => new(JsonSerializer.Serialize(payload, JsonOptions), Encoding.UTF8, "application/json");
}
+80
View File
@@ -0,0 +1,80 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Api.Requests;
using Api.Responses;
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services;
public sealed class RagApiClient : IRagApiClient
{
private readonly HttpClient _http;
private readonly RagApiSettings _settings;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public RagApiClient(HttpClient http, IOptions<RagApiSettings> options)
{
_http = http;
_settings = options.Value;
_http.BaseAddress = new Uri(_settings.BaseUrl.TrimEnd('/') + "/");
if (!string.IsNullOrWhiteSpace(_settings.InternalApiKey))
{
_http.DefaultRequestHeaders.Add("X-Internal-Api-Key", _settings.InternalApiKey);
}
}
public async Task<RagIndexResponse> IndexCvPdfAsync(IFormFile file, CancellationToken ct)
{
using var content = new MultipartFormDataContent();
await using var stream = file.OpenReadStream();
using var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", file.FileName);
content.Add(new StringContent("cv"), "documentType");
content.Add(new StringContent(file.FileName), "title");
using var response = await _http.PostAsync("api/rag/documents", content, ct);
return await ReadJsonAsync<RagIndexResponse>(response, ct);
}
public async Task<RagIndexResponse> IndexJobTextAsync(string text, string? url, string? title, CancellationToken ct)
{
using var content = new MultipartFormDataContent
{
{ new StringContent(text), "text" },
{ new StringContent("job"), "documentType" },
{ new StringContent(title ?? "Job description"), "title" }
};
if (!string.IsNullOrWhiteSpace(url)) content.Add(new StringContent(url), "sourceUrl");
using var response = await _http.PostAsync("api/rag/documents", content, ct);
return await ReadJsonAsync<RagIndexResponse>(response, ct);
}
public async Task<RagDocumentDetails?> GetDocumentAsync(string documentId, CancellationToken ct)
{
using var response = await _http.GetAsync($"api/rag/documents/{Uri.EscapeDataString(documentId)}", ct);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
return await ReadJsonAsync<RagDocumentDetails>(response, ct);
}
public async Task<RagSearchResponse> SearchAsync(RagSearchRequest request, CancellationToken ct)
{
using var response = await _http.PostAsync(
"api/rag/search",
new StringContent(JsonSerializer.Serialize(request, JsonOptions), Encoding.UTF8, "application/json"),
ct);
return await ReadJsonAsync<RagSearchResponse>(response, ct);
}
private static async Task<T> ReadJsonAsync<T>(HttpResponseMessage response, CancellationToken ct)
{
var json = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"RAG API failed: {(int)response.StatusCode} {json}");
}
return JsonSerializer.Deserialize<T>(json, JsonOptions) ?? throw new InvalidOperationException("RAG API returned invalid JSON.");
}
}
@@ -0,0 +1,105 @@
using System.Text.Json;
using Api.Responses;
using Api.Services.Contracts;
using Microsoft.Data.SqlClient;
namespace Api.Services;
public sealed class SqlMatcherRepository : IMatcherRepository
{
private readonly string _connectionString;
public SqlMatcherRepository(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("CvMatcherDb")
?? throw new InvalidOperationException("Connection string 'CvMatcherDb' is missing.");
}
public async Task InitializeAsync(CancellationToken ct)
{
await EnsureDatabaseExistsAsync(ct);
var sql = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Database", "schema.sql"), ct);
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(ct);
foreach (var commandText in sql.Split("GO", StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
await using var command = new SqlCommand(commandText, connection);
await command.ExecuteNonQueryAsync(ct);
}
}
public async Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct)
{
const string sql = "SELECT ResultJson FROM CvMatchResults WHERE CvDocumentId = @CvDocumentId AND JobDocumentId = @JobDocumentId";
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(ct);
await using var command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@CvDocumentId", cvDocumentId);
command.Parameters.AddWithValue("@JobDocumentId", jobDocumentId);
var json = await command.ExecuteScalarAsync(ct) as string;
if (string.IsNullOrWhiteSpace(json)) return null;
var result = JsonSerializer.Deserialize<JobMatchResponse>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web));
if (result is not null) result.Cached = true;
return result;
}
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct)
{
const string sql = """
IF NOT EXISTS (SELECT 1 FROM CvMatchResults WHERE CvDocumentId = @CvDocumentId AND JobDocumentId = @JobDocumentId)
INSERT INTO CvMatchResults (Id, CvDocumentId, JobDocumentId, ResultJson, Score, CreatedAt)
VALUES (@Id, @CvDocumentId, @JobDocumentId, @ResultJson, @Score, SYSUTCDATETIME())
""";
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(ct);
await using var command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@Id", Guid.NewGuid().ToString("N"));
command.Parameters.AddWithValue("@CvDocumentId", cvDocumentId);
command.Parameters.AddWithValue("@JobDocumentId", jobDocumentId);
command.Parameters.AddWithValue("@ResultJson", JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)));
command.Parameters.AddWithValue("@Score", response.Score);
await command.ExecuteNonQueryAsync(ct);
}
public async Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct)
{
const string sql = "SELECT ResponseText FROM CvMatcherChatCache WHERE CacheKey = @CacheKey";
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(ct);
await using var command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@CacheKey", cacheKey);
return await command.ExecuteScalarAsync(ct) as string;
}
public async Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct)
{
const string sql = """
IF NOT EXISTS (SELECT 1 FROM CvMatcherChatCache WHERE CacheKey = @CacheKey)
INSERT INTO CvMatcherChatCache (CacheKey, Model, Temperature, ResponseText, CreatedAt)
VALUES (@CacheKey, @Model, @Temperature, @ResponseText, SYSUTCDATETIME())
""";
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync(ct);
await using var command = new SqlCommand(sql, connection);
command.Parameters.AddWithValue("@CacheKey", cacheKey);
command.Parameters.AddWithValue("@Model", model);
command.Parameters.AddWithValue("@Temperature", temperature);
command.Parameters.AddWithValue("@ResponseText", responseText);
await command.ExecuteNonQueryAsync(ct);
}
private async Task EnsureDatabaseExistsAsync(CancellationToken ct)
{
var builder = new SqlConnectionStringBuilder(_connectionString);
var databaseName = builder.InitialCatalog;
if (string.IsNullOrWhiteSpace(databaseName)) return;
builder.InitialCatalog = "master";
await using var connection = new SqlConnection(builder.ConnectionString);
await connection.OpenAsync(ct);
var safeName = databaseName.Replace("]", "]]" );
await using var command = new SqlCommand($"IF DB_ID(@DatabaseName) IS NULL EXEC('CREATE DATABASE [{safeName}]')", connection);
command.Parameters.AddWithValue("@DatabaseName", databaseName);
await command.ExecuteNonQueryAsync(ct);
}
}