@@ -0,0 +1,73 @@
|
||||
using Api.Requests;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/cv")]
|
||||
public sealed class CvController : ControllerBase
|
||||
{
|
||||
private readonly ICvMatcherService _service;
|
||||
private readonly ILogger<CvController> _logger;
|
||||
|
||||
public CvController(ICvMatcherService service, ILogger<CvController> logger)
|
||||
{
|
||||
_service = service;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<IActionResult> Upload([FromForm(Name = "cv")] IFormFile? cv, [FromForm] bool gdprConsent, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (cv is null) return BadRequest(new { error = "Missing CV PDF." });
|
||||
_logger.LogInformation("CV upload received. FileName={FileName}, Size={SizeBytes}, GdprConsent={GdprConsent}", cv.FileName, cv.Length, gdprConsent);
|
||||
var result = await _service.UploadCvAsync(cv, gdprConsent, ct);
|
||||
_logger.LogInformation("CV upload processed. CvDocumentId={CvDocumentId}, Cached={Cached}", result.DocumentId, result.Cached);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid CV upload request.");
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("find-jobs")]
|
||||
public async Task<IActionResult> FindJobs([FromBody] FindJobsRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Find jobs request received. CvDocumentId={CvDocumentId}, TopK={TopK}", request.CvDocumentId, request.TopK);
|
||||
var result = await _service.FindJobsAsync(request, ct);
|
||||
_logger.LogInformation("Find jobs completed. CvDocumentId={CvDocumentId}, ResultCount={ResultCount}", request.CvDocumentId, result.Jobs.Count);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid find jobs request.");
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("match-job")]
|
||||
public async Task<IActionResult> MatchJob([FromBody] MatchJobRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Match job request received. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}, EmailRequested={EmailRequested}",
|
||||
request.CvDocumentId, !string.IsNullOrWhiteSpace(request.JobUrl), !string.IsNullOrWhiteSpace(request.JobDescription), !string.IsNullOrWhiteSpace(request.Email));
|
||||
var result = await _service.MatchJobAsync(request, ct);
|
||||
_logger.LogInformation("Match job completed. CvDocumentId={CvDocumentId}, Score={Score}, Cached={Cached}", request.CvDocumentId, result.Score, result.Cached);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid match job request.");
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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
|
||||
@@ -0,0 +1,15 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY ["cv-matcher-api.csproj", "./"]
|
||||
RUN dotnet restore "cv-matcher-api.csproj"
|
||||
COPY . .
|
||||
RUN dotnet publish "cv-matcher-api.csproj" -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
ENTRYPOINT ["dotnet", "cv-matcher-api.dll"]
|
||||
@@ -0,0 +1,283 @@
|
||||
using Azure.Identity;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using Api.Settings;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Serilog;
|
||||
using System.Reflection;
|
||||
|
||||
DotNetEnv.Env.Load();
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
var appVersion = Assembly.GetExecutingAssembly()
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?
|
||||
.InformationalVersion
|
||||
?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
|
||||
builder.Host.UseSerilog((context, services, configuration) =>
|
||||
{
|
||||
configuration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithMachineName()
|
||||
.Enrich.WithEnvironmentName()
|
||||
.Enrich.WithProperty("Service", "cv-matcher-api")
|
||||
.Enrich.WithProperty("AppVersion", appVersion)
|
||||
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
|
||||
});
|
||||
|
||||
Log.Information("Starting {Service} version {AppVersion}", "cv-matcher-api", appVersion);
|
||||
|
||||
// --------------------
|
||||
// Azure Key Vault Configuration
|
||||
// --------------------
|
||||
var keyVaultUri = builder.Configuration["KeyVault:VaultUri"];
|
||||
var keyVaultEnabled = builder.Configuration.GetValue<bool>("KeyVault:Enabled");
|
||||
|
||||
if (keyVaultEnabled && !string.IsNullOrWhiteSpace(keyVaultUri))
|
||||
{
|
||||
Log.Information("Loading configuration from Azure Key Vault: {VaultUri}", keyVaultUri);
|
||||
|
||||
try
|
||||
{
|
||||
builder.Configuration.AddAzureKeyVault(
|
||||
new Uri(keyVaultUri),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
Log.Information("Azure Key Vault configuration loaded successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to load Azure Key Vault configuration. Continuing with other configuration sources.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Information("Azure Key Vault is disabled or not configured");
|
||||
}
|
||||
|
||||
builder.Services.Configure<RagApiSettings>(builder.Configuration.GetSection("RagApi"));
|
||||
builder.Services.Configure<InternalApiSettings>(builder.Configuration.GetSection("InternalApi"));
|
||||
builder.Services.Configure<AiSettings>(builder.Configuration.GetSection("Ai"));
|
||||
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
|
||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||
|
||||
builder.Services.AddHttpClient<IRagApiClient, RagApiClient>();
|
||||
builder.Services.AddHttpClient<IMatcherAiClient, MatcherAiClient>();
|
||||
builder.Services.AddHttpClient<IJobTextExtractor, JobTextExtractor>();
|
||||
builder.Services.AddSingleton<IMatcherRepository, SqlMatcherRepository>();
|
||||
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
|
||||
builder.Services.AddSingleton<IEmailService, EmailService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogInformation("API starting up...");
|
||||
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);
|
||||
if (logEnvironmentOnStartup)
|
||||
{
|
||||
LogEnvironmentSettings(logger, app.Configuration, app.Environment);
|
||||
}
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IMatcherRepository>();
|
||||
await repository.InitializeAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging(options =>
|
||||
{
|
||||
options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms";
|
||||
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
|
||||
{
|
||||
diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value);
|
||||
diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme);
|
||||
diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress?.ToString());
|
||||
diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString());
|
||||
};
|
||||
});
|
||||
|
||||
app.UseExceptionHandler(errorApp =>
|
||||
{
|
||||
errorApp.Run(async context =>
|
||||
{
|
||||
var feature = context.Features.Get<IExceptionHandlerFeature>();
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||
if (feature?.Error is not null)
|
||||
{
|
||||
logger.LogError(feature.Error, "Unhandled exception in {Service}", "cv-matcher-api");
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error." });
|
||||
});
|
||||
});
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
var settings = context.RequestServices.GetRequiredService<Microsoft.Extensions.Options.IOptions<InternalApiSettings>>().Value;
|
||||
if (settings.RequireApiKey)
|
||||
{
|
||||
var header = context.Request.Headers["X-Internal-Api-Key"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(settings.ApiKey) || header != settings.ApiKey)
|
||||
{
|
||||
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
|
||||
logger.LogWarning("Rejected unauthorized internal API call. Path={Path}, RemoteIP={RemoteIP}", context.Request.Path, context.Connection.RemoteIpAddress?.ToString());
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Unauthorized internal API call." });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
|
||||
// Swagger (typically only in Development)
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.DocumentTitle = "cv-matcher-api";
|
||||
options.SwaggerEndpoint("/swagger/v1/swagger.json", "cv-matcher-api v1");
|
||||
options.RoutePrefix = "swagger";
|
||||
});
|
||||
}
|
||||
|
||||
app.MapControllers();
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "cv-matcher-api", version = appVersion, timeUtc = DateTimeOffset.UtcNow }));
|
||||
|
||||
Log.Information("{Service} startup complete", "cv-matcher-api");
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "cv-matcher-api terminated unexpectedly");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.Information("Shutting down cv-matcher-api");
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs all environment variables and configuration settings at startup for diagnostics.
|
||||
/// </summary>
|
||||
static void LogEnvironmentSettings(Microsoft.Extensions.Logging.ILogger logger, IConfiguration configuration, IWebHostEnvironment environment)
|
||||
{
|
||||
logger.LogInformation("==================== ENVIRONMENT SETTINGS ====================");
|
||||
|
||||
// Environment Information
|
||||
logger.LogInformation("Application Name: {ApplicationName}", environment.ApplicationName);
|
||||
logger.LogInformation("Environment Name: {EnvironmentName}", environment.EnvironmentName);
|
||||
logger.LogInformation("Content Root Path: {ContentRootPath}", environment.ContentRootPath);
|
||||
logger.LogInformation("Web Root Path: {WebRootPath}", environment.WebRootPath);
|
||||
|
||||
// Environment Variables
|
||||
logger.LogInformation("-------------- Environment Variables --------------");
|
||||
var envVars = Environment.GetEnvironmentVariables();
|
||||
var sortedEnvVars = new SortedDictionary<string, string?>();
|
||||
|
||||
foreach (System.Collections.DictionaryEntry entry in envVars)
|
||||
{
|
||||
var key = entry.Key?.ToString() ?? string.Empty;
|
||||
var value = entry.Value?.ToString() ?? string.Empty;
|
||||
|
||||
// Mask sensitive values (passwords, secrets, tokens, keys) but show last 4 characters
|
||||
if (IsSensitiveKey(key))
|
||||
{
|
||||
value = MaskValueWithLastChars(value);
|
||||
}
|
||||
|
||||
sortedEnvVars[key] = value;
|
||||
}
|
||||
|
||||
foreach (var kvp in sortedEnvVars)
|
||||
{
|
||||
logger.LogInformation(" {Key} = {Value}", kvp.Key, kvp.Value);
|
||||
}
|
||||
|
||||
// Configuration Settings
|
||||
logger.LogInformation("-------------- Configuration Settings --------------");
|
||||
LogConfigurationRecursive(logger, configuration.GetChildren(), "");
|
||||
|
||||
logger.LogInformation("===========================================================");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively logs configuration settings with hierarchy.
|
||||
/// </summary>
|
||||
static void LogConfigurationRecursive(Microsoft.Extensions.Logging.ILogger logger, IEnumerable<IConfigurationSection> sections, string prefix)
|
||||
{
|
||||
foreach (var section in sections)
|
||||
{
|
||||
var key = string.IsNullOrEmpty(prefix) ? section.Key : $"{prefix}:{section.Key}";
|
||||
|
||||
if (section.Value != null)
|
||||
{
|
||||
var value = section.Value;
|
||||
|
||||
// Mask sensitive configuration values but show last 4 characters
|
||||
if (IsSensitiveKey(key))
|
||||
{
|
||||
value = MaskValueWithLastChars(value);
|
||||
}
|
||||
|
||||
logger.LogInformation(" {Key} = {Value}", key, value);
|
||||
}
|
||||
|
||||
// Recurse into child sections
|
||||
if (section.GetChildren().Any())
|
||||
{
|
||||
LogConfigurationRecursive(logger, section.GetChildren(), key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a configuration key contains sensitive information.
|
||||
/// </summary>
|
||||
static bool IsSensitiveKey(string key)
|
||||
{
|
||||
return key.Contains("Password", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("Secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("Token", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("Key", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("ConnectionString", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Masks a sensitive value but shows the last 4 characters for verification.
|
||||
/// </summary>
|
||||
/// <param name="value">The value to mask.</param>
|
||||
/// <returns>Masked value showing last 4 characters (e.g., "***MASKED***...abcd")</returns>
|
||||
static string MaskValueWithLastChars(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return "***NOT SET***";
|
||||
}
|
||||
|
||||
// If value is too short, just mask it completely
|
||||
if (value.Length <= 4)
|
||||
{
|
||||
return "***MASKED***";
|
||||
}
|
||||
|
||||
// Show last 4 characters
|
||||
var lastChars = value.Substring(value.Length - 4);
|
||||
return $"***MASKED***...{lastChars}";
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"cv-matcher-api": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:58423;http://localhost:58425"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Api.Requests
|
||||
{
|
||||
public sealed class FindJobsRequest
|
||||
{
|
||||
public required string CvDocumentId { get; init; }
|
||||
public int? TopK { get; init; }
|
||||
public string? Email { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Api.Requests
|
||||
{
|
||||
public sealed class MatchJobRequest
|
||||
{
|
||||
public string? CvDocumentId { get; set; }
|
||||
public string? JobUrl { get; set; }
|
||||
public string? JobDescription { get; set; }
|
||||
public bool GdprConsent { get; set; }
|
||||
public string? Email { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Api.Requests
|
||||
{
|
||||
public sealed class RagSearchRequest
|
||||
{
|
||||
public required string QueryText { get; init; }
|
||||
public IReadOnlyList<string>? TargetDocumentTypes { get; init; }
|
||||
public int? TopK { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Api.Responses
|
||||
{
|
||||
public sealed class CvUploadResponse
|
||||
{
|
||||
public required string DocumentId { get; init; }
|
||||
public required string TextHash { get; init; }
|
||||
public required string DocumentType { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public int Chunks { get; init; }
|
||||
public int Characters { get; init; }
|
||||
public bool Cached { get; init; }
|
||||
public string Summary { get; init; } = "CV indexed successfully.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Api.Responses
|
||||
{
|
||||
public sealed class FindJobsResponse
|
||||
{
|
||||
public required string CvDocumentId { get; init; }
|
||||
public IReadOnlyList<JobMatchResponse> Jobs { get; init; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Api.Responses
|
||||
{
|
||||
public sealed class JobMatchResponse
|
||||
{
|
||||
public int Score { get; set; }
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
public List<string> Strengths { get; set; } = [];
|
||||
public List<string> Gaps { get; set; } = [];
|
||||
public List<string> Recommendations { get; set; } = [];
|
||||
public List<string> Evidence { get; set; } = [];
|
||||
public bool Cached { get; set; }
|
||||
public string? JobDocumentId { get; set; }
|
||||
public string? JobUrl { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Api.Responses
|
||||
{
|
||||
public sealed class RagIndexResponse
|
||||
{
|
||||
public required string DocumentId { get; init; }
|
||||
public required string TextHash { get; init; }
|
||||
public required string DocumentType { get; init; }
|
||||
public double DocumentTypeConfidence { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public int Chunks { get; init; }
|
||||
public int Characters { get; init; }
|
||||
public bool Cached { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace Api.Responses
|
||||
{
|
||||
public sealed class RagSearchResponse
|
||||
{
|
||||
public IReadOnlyList<RagSearchDocumentResult> Results { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RagDocumentDetails
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string DocumentType { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string? SourceUrl { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public required string TextHash { get; init; }
|
||||
}
|
||||
public sealed class RagSearchDocumentResult
|
||||
{
|
||||
public required string DocumentId { get; init; }
|
||||
public required string DocumentType { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public string? SourceUrl { get; init; }
|
||||
public double Score { get; init; }
|
||||
public IReadOnlyList<RagSearchChunkResult> MatchedChunks { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RagSearchChunkResult
|
||||
{
|
||||
public required string ChunkId { get; init; }
|
||||
public int ChunkIndex { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public double Score { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)}
|
||||
""";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace Api.Settings;
|
||||
|
||||
public sealed class RagApiSettings
|
||||
{
|
||||
public string BaseUrl { get; set; } = "http://localhost:8081";
|
||||
public string InternalApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class InternalApiSettings
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public bool RequireApiKey { get; set; } = false;
|
||||
}
|
||||
|
||||
public sealed class AiSettings
|
||||
{
|
||||
public string Provider { get; set; } = "OpenAI";
|
||||
public OpenAiSettings OpenAI { get; set; } = new();
|
||||
public OllamaSettings Ollama { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class OpenAiSettings
|
||||
{
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
public string ChatModel { get; set; } = "gpt-4o-mini";
|
||||
public int TimeoutSeconds { get; set; } = 90;
|
||||
}
|
||||
|
||||
public sealed class OllamaSettings
|
||||
{
|
||||
public string BaseUrl { get; set; } = "http://localhost:11434";
|
||||
public string ChatModel { get; set; } = "llama3.1:8b";
|
||||
public int TimeoutSeconds { get; set; } = 180;
|
||||
}
|
||||
|
||||
public sealed class MatcherSettings
|
||||
{
|
||||
public int TopK { get; set; } = 10;
|
||||
public int DeepScoreTopN { get; set; } = 5;
|
||||
public int MaxJobTextChars { get; set; } = 60000;
|
||||
}
|
||||
|
||||
public sealed class SmtpSettings
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public int Port { get; set; } = 587;
|
||||
public string Username { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
public bool UseStartTls { get; set; } = true;
|
||||
public string FromEmail { get; set; } = "noreply@myai.ro";
|
||||
public string ToEmail { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.Hosting": "Information",
|
||||
"Microsoft.AspNetCore.Routing": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning",
|
||||
"Api": "Information"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "logs/api-.log",
|
||||
"rollingInterval": "Day",
|
||||
"retainedFileCountLimit": 30,
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[mihes.ro API] Error Alert",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||
"batchPostingLimit": 10,
|
||||
"period": "0.00:05:00"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithEnvironmentName"
|
||||
]
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.AspNetCore.Hosting": "Information",
|
||||
"Microsoft.AspNetCore.Routing": "Warning",
|
||||
"System.Net.Http.HttpClient": "Warning",
|
||||
"Api": "Information"
|
||||
},
|
||||
"LogEnvironmentOnStartup": true
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"KeyVault": {
|
||||
"VaultUri": "",
|
||||
"Enabled": false
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"CvMatcherDb": "Server=localhost,1433;Database=MyAiCvMatcher;User Id=sa;Password=Your_strong_password123;TrustServerCertificate=True"
|
||||
},
|
||||
"InternalApi": {
|
||||
"ApiKey": "",
|
||||
"RequireApiKey": false
|
||||
},
|
||||
"RagApi": {
|
||||
"BaseUrl": "http://localhost:8081",
|
||||
"InternalApiKey": ""
|
||||
},
|
||||
"Ai": {
|
||||
"Provider": "OpenAI",
|
||||
"OpenAI": {
|
||||
"ApiKey": "",
|
||||
"ChatModel": "gpt-4o-mini",
|
||||
"TimeoutSeconds": 90
|
||||
},
|
||||
"Ollama": {
|
||||
"BaseUrl": "http://localhost:11434",
|
||||
"ChatModel": "llama3.1:8b",
|
||||
"TimeoutSeconds": 180
|
||||
}
|
||||
},
|
||||
"Matcher": {
|
||||
"TopK": 10,
|
||||
"DeepScoreTopN": 5,
|
||||
"MaxJobTextChars": 60000
|
||||
},
|
||||
"Smtp": {
|
||||
"Host": "",
|
||||
"Port": 587,
|
||||
"Username": "",
|
||||
"Password": "",
|
||||
"UseStartTls": true,
|
||||
"FromEmail": "noreply@myai.ro",
|
||||
"ToEmail": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<RootNamespace>Api</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<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="MailKit" Version="4.16.0" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.3" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="Database/schema.sql">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user