This commit is contained in:
2026-05-06 12:19:31 +03:00
parent 17266730fc
commit 89cb5a10da
34 changed files with 198 additions and 226 deletions
@@ -1,4 +1,4 @@
namespace Api.Services.Contracts;
namespace Api.Clients.Ai.Contracts;
public interface IMatcherAiClient
{
@@ -1,7 +1,7 @@
using System.Security.Cryptography;
using System.Text;
namespace Api.Services;
namespace Api.Clients.Ai;
public static class HashHelper
{
@@ -2,11 +2,12 @@ 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 Api.Clients.Ai.Contracts;
using Api.Data.Repositories.Contracts;
using Api.Models.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services;
namespace Api.Clients.Ai;
public sealed class MatcherAiClient : IMatcherAiClient
{
@@ -1,7 +1,7 @@
using Api.Requests;
using Api.Responses;
using Api.Models.Requests;
using Api.Models.Responses;
namespace Api.Services.Contracts;
namespace Api.Clients.Api.Contracts;
public interface IRagApiClient
{
@@ -0,0 +1,30 @@
using Refit;
using Api.Models.Responses;
using Api.Models.Requests;
namespace Api.Clients.Api.Contracts;
[Headers("Accept: application/json")]
public interface IRefitRagApi
{
[Multipart]
[Post("/api/rag/documents")]
Task<RagIndexResponse> IndexDocumentAsync([AliasAs("file")] StreamPart file,
[AliasAs("documentType")] string documentType,
[AliasAs("title")] string title,
CancellationToken ct = default);
[Multipart]
[Post("/api/rag/documents")]
Task<RagIndexResponse> IndexDocumentWithTextAsync([AliasAs("text")] string text,
[AliasAs("documentType")] string documentType,
[AliasAs("title")] string title,
[AliasAs("sourceUrl")] string? sourceUrl = null,
CancellationToken ct = default);
[Get("/api/rag/documents/{documentId}")]
Task<RagDocumentDetails> GetDocumentAsync(string documentId, CancellationToken ct = default);
[Post("/api/rag/search")]
Task<RagSearchResponse> SearchAsync([Body] RagSearchRequest request, CancellationToken ct = default);
}
@@ -0,0 +1,48 @@
using System.Net;
using Refit;
using Microsoft.Extensions.Options;
using Api.Clients.Api.Contracts;
using Api.Models.Responses;
using Api.Models.Settings;
using Api.Models.Requests;
namespace Api.Clients.Api;
public sealed class RagApiClient : IRagApiClient
{
private readonly IRefitRagApi _refit;
public RagApiClient(IRefitRagApi refit, IOptions<RagApiSettings> options)
{
_refit = refit;
}
public async Task<RagIndexResponse> IndexCvPdfAsync(IFormFile file, CancellationToken ct)
{
await using var stream = file.OpenReadStream();
var part = new StreamPart(stream, file.FileName, "application/pdf");
return await _refit.IndexDocumentAsync(part, "cv", file.FileName, ct);
}
public async Task<RagIndexResponse> IndexJobTextAsync(string text, string? url, string? title, CancellationToken ct)
{
return await _refit.IndexDocumentWithTextAsync(text, "job", title ?? "Job description", url, ct);
}
public async Task<RagDocumentDetails?> GetDocumentAsync(string documentId, CancellationToken ct)
{
try
{
return await _refit.GetDocumentAsync(documentId, ct);
}
catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
}
public async Task<RagSearchResponse> SearchAsync(RagSearchRequest request, CancellationToken ct)
{
return await _refit.SearchAsync(request, ct);
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
using Api.Requests;
using Api.Models.Requests;
using Api.Services.Contracts;
using Microsoft.AspNetCore.Mvc;
@@ -1,6 +1,6 @@
using Api.Responses;
using Api.Models.Responses;
namespace Api.Services.Contracts;
namespace Api.Data.Repositories.Contracts;
public interface IMatcherRepository
{
@@ -1,11 +1,11 @@
using System.Text.Json;
using Api.Data;
using Api.Data.Entities;
using Api.Responses;
using Api.Services.Contracts;
using Api.Data.Repositories.Contracts;
using Api.Models.Responses;
using Microsoft.EntityFrameworkCore;
namespace Api.Services;
namespace Api.Data.Repositories;
public sealed class EfMatcherRepository : IMatcherRepository
{
-25
View File
@@ -1,25 +0,0 @@
IF OBJECT_ID('dbo.CvMatchResults', 'U') IS NULL
BEGIN
CREATE TABLE dbo.CvMatchResults (
Id NVARCHAR(64) NOT NULL CONSTRAINT PK_CvMatchResults PRIMARY KEY,
CvDocumentId NVARCHAR(64) NOT NULL,
JobDocumentId NVARCHAR(64) NOT NULL,
ResultJson NVARCHAR(MAX) NOT NULL,
Score INT NOT NULL,
CreatedAt DATETIME2 NOT NULL CONSTRAINT DF_CvMatchResults_CreatedAt DEFAULT SYSUTCDATETIME()
);
CREATE UNIQUE INDEX UX_CvMatchResults_CvJob ON dbo.CvMatchResults(CvDocumentId, JobDocumentId);
END
GO
IF OBJECT_ID('dbo.CvMatcherChatCache', 'U') IS NULL
BEGIN
CREATE TABLE dbo.CvMatcherChatCache (
CacheKey NVARCHAR(64) NOT NULL CONSTRAINT PK_CvMatcherChatCache PRIMARY KEY,
Model NVARCHAR(120) NOT NULL,
Temperature DECIMAL(4,2) NOT NULL,
ResponseText NVARCHAR(MAX) NOT NULL,
CreatedAt DATETIME2 NOT NULL CONSTRAINT DF_CvMatcherChatCache_CreatedAt DEFAULT SYSUTCDATETIME()
);
END
GO
@@ -1,4 +1,4 @@
namespace Api.Requests
namespace Api.Models.Requests
{
public sealed class FindJobsRequest
{
@@ -1,4 +1,4 @@
namespace Api.Requests
namespace Api.Models.Requests
{
public sealed class MatchJobRequest
{
@@ -1,4 +1,4 @@
namespace Api.Requests
namespace Api.Models.Requests
{
public sealed class RagSearchRequest
{
@@ -1,4 +1,4 @@
namespace Api.Responses
namespace Api.Models.Responses
{
public sealed class CvUploadResponse
{
@@ -1,4 +1,4 @@
namespace Api.Responses
namespace Api.Models.Responses
{
public sealed class FindJobsResponse
{
@@ -1,4 +1,4 @@
namespace Api.Responses
namespace Api.Models.Responses
{
public sealed class JobMatchResponse
{
@@ -1,4 +1,4 @@
namespace Api.Responses
namespace Api.Models.Responses
{
public sealed class RagIndexResponse
{
@@ -1,4 +1,4 @@
namespace Api.Responses
namespace Api.Models.Responses
{
public sealed class RagSearchResponse
{
@@ -1,4 +1,4 @@
namespace Api.Settings;
namespace Api.Models.Settings;
public sealed class RagApiSettings
{
+23 -4
View File
@@ -2,11 +2,18 @@ using Azure.Identity;
using Api.Data;
using Api.Services;
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.AspNetCore.Diagnostics;
using Serilog;
using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Refit;
using Api.Data.Repositories;
using Api.Data.Repositories.Contracts;
using Api.Clients.Api;
using Api.Clients.Api.Contracts;
using Api.Clients.Ai;
using Api.Clients.Ai.Contracts;
using Api.Models.Settings;
DotNetEnv.Env.Load();
@@ -68,7 +75,19 @@ try
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
builder.Services.AddHttpClient<IRagApiClient, RagApiClient>();
// Register Refit client for the external RAG API and a thin wrapper that implements IRagApiClient
builder.Services.AddRefitClient<IRefitRagApi>()
.ConfigureHttpClient((sp, c) =>
{
var settings = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<RagApiSettings>>().Value;
c.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/");
if (!string.IsNullOrWhiteSpace(settings.InternalApiKey))
{
c.DefaultRequestHeaders.Add("X-Internal-Api-Key", settings.InternalApiKey);
}
});
builder.Services.AddScoped<IRagApiClient, RagApiClient>();
builder.Services.AddHttpClient<IMatcherAiClient, MatcherAiClient>();
builder.Services.AddHttpClient<IJobTextExtractor, JobTextExtractor>();
builder.Services.AddDbContext<CvMatcherDbContext>(options =>
@@ -89,8 +108,8 @@ try
logger.LogInformation("Environment: {Environment}", app.Environment.EnvironmentName);
// Log all environment variables and configuration settings at startup
// Can be controlled via appsettings: "Logging:LogEnvironmentOnStartup": true
var logEnvironmentOnStartup = app.Configuration.GetValue<bool>("Logging:LogEnvironmentOnStartup", defaultValue: true);
// Can be controlled via appsettings: "LogEnvironmentOnStartup": true
var logEnvironmentOnStartup = app.Configuration.GetValue<bool>("LogEnvironmentOnStartup", defaultValue: true);
if (logEnvironmentOnStartup)
{
LogEnvironmentSettings(logger, app.Configuration, app.Environment);
@@ -1,5 +1,5 @@
using Api.Requests;
using Api.Responses;
using Api.Models.Requests;
using Api.Models.Responses;
namespace Api.Services.Contracts;
+6 -3
View File
@@ -1,8 +1,11 @@
using System.Text.Json;
using Api.Requests;
using Api.Responses;
using Api.Clients.Ai.Contracts;
using Api.Clients.Api.Contracts;
using Api.Data.Repositories.Contracts;
using Api.Models.Requests;
using Api.Models.Responses;
using Api.Models.Settings;
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services;
+1 -1
View File
@@ -1,5 +1,5 @@
using Api.Models.Settings;
using Api.Services.Contracts;
using Api.Settings;
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Options;
+1 -1
View File
@@ -1,7 +1,7 @@
using System.Net;
using System.Text.RegularExpressions;
using Api.Models.Settings;
using Api.Services.Contracts;
using Api.Settings;
using Microsoft.Extensions.Options;
namespace Api.Services;
-80
View File
@@ -1,80 +0,0 @@
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.");
}
}
+1 -1
View File
@@ -58,7 +58,6 @@
]
},
"Logging": {
"LogEnvironmentOnStartup": true,
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
@@ -68,6 +67,7 @@
"Api": "Information"
}
},
"LogEnvironmentOnStartup": true,
"AllowedHosts": "*",
"KeyVault": {
"VaultUri": "",
+4 -8
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
@@ -59,8 +59,8 @@
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="DotNetEnv" Version="3.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -70,10 +70,6 @@
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
</ItemGroup>
<ItemGroup>
<None Update="Database/schema.sql">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
</ItemGroup>
</Project>