This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using CommonHelpers;
|
||||
|
||||
namespace Api.Clients.Ai;
|
||||
|
||||
public sealed class CachedMatcherAiClient : IMatcherAiClient
|
||||
{
|
||||
private readonly MatcherAiClient _client;
|
||||
private readonly IMatcherRepository _repository;
|
||||
private readonly AiSettings _settings;
|
||||
|
||||
public CachedMatcherAiClient(MatcherAiClient client, IMatcherRepository repository, IOptions<AiSettings> options)
|
||||
{
|
||||
_client = client;
|
||||
_repository = repository;
|
||||
_settings = options.Value;
|
||||
}
|
||||
|
||||
public async Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct)
|
||||
{
|
||||
var model = GetChatModel();
|
||||
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 = await _client.CreateChatCompletionAsync(systemPrompt, userPrompt, temperature, ct);
|
||||
await _repository.SaveChatCompletionAsync(cacheKey, model, temperature, response, ct);
|
||||
return response;
|
||||
}
|
||||
|
||||
private string GetChatModel() => string.Equals(_settings.Provider, "Ollama", StringComparison.OrdinalIgnoreCase)
|
||||
? _settings.Ollama.ChatModel
|
||||
: _settings.OpenAI.ChatModel;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Api.Clients.Ai.Contracts;
|
||||
|
||||
public interface IMatcherAiClient
|
||||
{
|
||||
Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using CommonHelpers;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.Clients.Ai;
|
||||
|
||||
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,12 @@
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
|
||||
namespace Api.Clients.Api.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,30 @@
|
||||
using Refit;
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvMatcher.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 CvMatcher.Models.Responses;
|
||||
using CvMatcher.Models.Settings;
|
||||
using CvMatcher.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using CvMatcher.Models.Requests;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Shared.Models.Requests;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using Shared.Models.Responses;
|
||||
|
||||
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)]
|
||||
[SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it for matching.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "CV uploaded and indexed successfully")]
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid upload request")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<CvUploadResponse>> Upload([FromForm] UploadFileRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (request.File is null) return BadRequest(new ErrorResponse { Error = "Missing CV PDF.", Code = "cv_file_missing" });
|
||||
_logger.LogInformation("CV upload received. FileName={FileName}, Size={SizeBytes}", request.File.FileName, request.File.Length);
|
||||
var result = await _service.UploadCvAsync(request.File, 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 ErrorResponse { Error = ex.Message, Code = "invalid_request" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("find-jobs")]
|
||||
[SwaggerOperation(Summary = "Find matching jobs", Description = "Finds top matching jobs for a previously uploaded CV document.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned")]
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid find jobs request")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<FindJobsResponse>> 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 result;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid find jobs request.");
|
||||
return BadRequest(new ErrorResponse { Error = ex.Message, Code = "invalid_request" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("match-job")]
|
||||
[SwaggerOperation(Summary = "Match CV to one job", Description = "Computes detailed match analysis between a CV and a single job description or URL.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully")]
|
||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid match job request")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<JobMatchResponse>> 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 result;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Invalid match job request.");
|
||||
return BadRequest(new ErrorResponse { Error = ex.Message, Code = "invalid_request" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// Controller that exposes simple health and readiness endpoints for the API.
|
||||
/// Routes are prefixed with "api/health".
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public sealed class HealthController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Liveness probe.
|
||||
/// Indicates whether the process is running. Used by orchestration systems to confirm the process is alive.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// 200 OK with JSON payload: { "status": "alive" } when the process is running.
|
||||
/// </returns>
|
||||
// GET api/health/live
|
||||
[HttpGet("live")]
|
||||
[SwaggerOperation(Summary = "Liveness probe", Description = "Returns whether the API process is alive.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Service is alive")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult Live() => Ok(new { status = "alive" });
|
||||
|
||||
/// <summary>
|
||||
/// Basic health check endpoint.
|
||||
/// Returns overall status and the current server time in UTC.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// 200 OK with JSON payload: { "status": "ok", "time": <UTC time> }.
|
||||
/// </returns>
|
||||
// GET api/health
|
||||
[HttpGet]
|
||||
[SwaggerOperation(Summary = "Health check", Description = "Returns overall health status and current UTC time.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Health check succeeded")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult Health() => Ok(new { status = "ok", time = DateTimeOffset.UtcNow });
|
||||
|
||||
/// <summary>
|
||||
/// Echo endpoint.
|
||||
/// Returns the received JSON payload unchanged. Useful for testing request/response plumbing.
|
||||
/// </summary>
|
||||
/// <param name="payload">Arbitrary JSON from the request body. The endpoint returns the same object.</param>
|
||||
/// <returns>200 OK with the same JSON payload provided in the request body.</returns>
|
||||
// POST api/health/echo
|
||||
[HttpPost("echo")]
|
||||
[SwaggerOperation(Summary = "Echo payload", Description = "Returns the same JSON payload received in the request body.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Payload echoed successfully")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public IActionResult Echo(object payload) => Ok(payload);
|
||||
|
||||
/// <summary>
|
||||
/// Readiness probe.
|
||||
/// Indicates whether the service is ready to accept traffic. Typically checks downstream dependencies.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// 200 OK with JSON { "status": "ready" } when ready;
|
||||
/// 503 Service Unavailable with JSON { "status": "not_ready" } when not ready.
|
||||
/// </returns>
|
||||
// GET api/health/ready
|
||||
[HttpGet("ready")]
|
||||
[SwaggerOperation(Summary = "Readiness probe", Description = "Returns whether the service is ready to accept traffic.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Service is ready")]
|
||||
[SwaggerResponse(StatusCodes.Status503ServiceUnavailable, "Service is not ready")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public IActionResult Ready()
|
||||
{
|
||||
var ready = true;
|
||||
|
||||
return ready
|
||||
? Ok(new { status = "ready" })
|
||||
: StatusCode(503, new { status = "not_ready" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using Api.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.Data;
|
||||
|
||||
|
||||
public sealed class CvMatcherDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "cvMatcher";
|
||||
public const string MigrationTableName = "_Migrations";
|
||||
|
||||
public CvMatcherDbContext(DbContextOptions<CvMatcherDbContext> options) : base(options)
|
||||
{
|
||||
}
|
||||
|
||||
public DbSet<CvMatchResultEntity> CvMatchResults => Set<CvMatchResultEntity>();
|
||||
public DbSet<CvMatcherChatCacheEntity> CvMatcherChatCache => Set<CvMatcherChatCacheEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<CvMatchResultEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("Results");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).HasMaxLength(64);
|
||||
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.ResultJson).IsRequired();
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<CvMatcherChatCacheEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("ChatCache");
|
||||
entity.HasKey(x => x.CacheKey);
|
||||
entity.Property(x => x.CacheKey).HasMaxLength(64);
|
||||
entity.Property(x => x.Model).HasMaxLength(120).IsRequired();
|
||||
entity.Property(x => x.Temperature).HasColumnType("decimal(4,2)");
|
||||
entity.Property(x => x.ResponseText).IsRequired();
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Api.Data.Entities;
|
||||
|
||||
public sealed class CvMatchResultEntity
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
public string CvDocumentId { get; set; } = string.Empty;
|
||||
public string JobDocumentId { get; set; } = string.Empty;
|
||||
public string ResultJson { get; set; } = string.Empty;
|
||||
public int Score { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Api.Data.Entities;
|
||||
|
||||
public sealed class CvMatcherChatCacheEntity
|
||||
{
|
||||
public string CacheKey { get; set; } = string.Empty;
|
||||
public string Model { get; set; } = string.Empty;
|
||||
public decimal Temperature { get; set; }
|
||||
public string ResponseText { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using CvMatcher.Models.Responses;
|
||||
|
||||
namespace Api.Data.Repositories.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,88 @@
|
||||
using System.Text.Json;
|
||||
using Api.Data;
|
||||
using Api.Data.Entities;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Api.Data.Repositories;
|
||||
|
||||
public sealed class EfMatcherRepository : IMatcherRepository
|
||||
{
|
||||
private readonly CvMatcherDbContext _db;
|
||||
private readonly ILogger<EfMatcherRepository> _logger;
|
||||
|
||||
public EfMatcherRepository(CvMatcherDbContext db, ILogger<EfMatcherRepository> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
_logger.LogInformation("Ensuring CV matcher database schema exists using EF Core");
|
||||
//await _db.Database.EnsureCreatedAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct)
|
||||
{
|
||||
var json = await _db.CvMatchResults
|
||||
.AsNoTracking()
|
||||
.Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId)
|
||||
.Select(x => x.ResultJson)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
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)
|
||||
{
|
||||
var exists = await _db.CvMatchResults.AnyAsync(
|
||||
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId,
|
||||
ct);
|
||||
|
||||
if (exists) return;
|
||||
|
||||
_db.CvMatchResults.Add(new CvMatchResultEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
CvDocumentId = cvDocumentId,
|
||||
JobDocumentId = jobDocumentId,
|
||||
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
||||
Score = response.Score,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct)
|
||||
{
|
||||
return await _db.CvMatcherChatCache
|
||||
.AsNoTracking()
|
||||
.Where(x => x.CacheKey == cacheKey)
|
||||
.Select(x => x.ResponseText)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
public async Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct)
|
||||
{
|
||||
var exists = await _db.CvMatcherChatCache.AnyAsync(x => x.CacheKey == cacheKey, ct);
|
||||
if (exists) return;
|
||||
|
||||
_db.CvMatcherChatCache.Add(new CvMatcherChatCacheEntity
|
||||
{
|
||||
CacheKey = cacheKey,
|
||||
Model = model,
|
||||
Temperature = temperature,
|
||||
ResponseText = responseText,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvMatcherDbContext))]
|
||||
[Migration("20260507140442_InitialCvMatcherSchema")]
|
||||
partial class InitialCvMatcherSchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("cvMatcher")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("JobDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Results", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("ResponseText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Temperature")
|
||||
.HasColumnType("decimal(4,2)");
|
||||
|
||||
b.HasKey("CacheKey");
|
||||
|
||||
b.ToTable("ChatCache", "cvMatcher");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCvMatcherSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "cvMatcher");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ChatCache",
|
||||
schema: "cvMatcher",
|
||||
columns: table => new
|
||||
{
|
||||
CacheKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Model = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
|
||||
Temperature = table.Column<decimal>(type: "decimal(4,2)", nullable: false),
|
||||
ResponseText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ChatCache", x => x.CacheKey);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Results",
|
||||
schema: "cvMatcher",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
JobDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Score = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Results", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Results_CvDocumentId_JobDocumentId",
|
||||
schema: "cvMatcher",
|
||||
table: "Results",
|
||||
columns: new[] { "CvDocumentId", "JobDocumentId" },
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "ChatCache",
|
||||
schema: "cvMatcher");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Results",
|
||||
schema: "cvMatcher");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvMatcherDbContext))]
|
||||
partial class CvMatcherDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("cvMatcher")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("JobDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Results", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("ResponseText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Temperature")
|
||||
.HasColumnType("decimal(4,2)");
|
||||
|
||||
b.HasKey("CacheKey");
|
||||
|
||||
b.ToTable("ChatCache", "cvMatcher");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Api.Clients.Ai;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using Api.Clients.Api;
|
||||
using Api.Clients.Api.Contracts;
|
||||
using Api.Data;
|
||||
using Api.Data.Repositories;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using Shared.Models.Settings;
|
||||
using StartupHelpers;
|
||||
using System.Reflection;
|
||||
|
||||
StartupExtensions.LoadDotEnvFile();
|
||||
|
||||
const string ServiceName = "cv-matcher-api";
|
||||
var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly());
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.ConfigureJsonSerilog(ServiceName, appVersion);
|
||||
Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion);
|
||||
|
||||
builder.AddAzureKeyVaultIfConfigured();
|
||||
|
||||
builder.Services.Configure<DatabaseSettings>(builder.Configuration.GetSection("Database"));
|
||||
builder.Services.Configure<RagApiSettings>(builder.Configuration.GetSection("RagApi"));
|
||||
builder.Services.Configure<InternalApiSettings>(builder.Configuration.GetSection("InternalApi"));
|
||||
builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
|
||||
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
|
||||
|
||||
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 =>
|
||||
{
|
||||
var configuration = builder.Configuration;
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(configuration);
|
||||
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsHistoryTable(CvMatcherDbContext.MigrationTableName, CvMatcherDbContext.SchemaName);
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>();
|
||||
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.LogStartupDiagnostics(ServiceName);
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var repository = scope.ServiceProvider.GetRequiredService<IMatcherRepository>();
|
||||
await repository.InitializeAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
app.UseDefaultSerilogRequestLogging();
|
||||
app.UseJsonExceptionHandler(ServiceName);
|
||||
app.UseInternalApiKeyProtection();
|
||||
app.UseSwaggerInDevelopment(ServiceName, ServiceName);
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
Log.Information("Running EF Core migrations if any");
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<CvMatcherDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete", ServiceName);
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.Information("Shutting down {Service}", ServiceName);
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
@@ -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,11 @@
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
public interface ICvMatcherService
|
||||
{
|
||||
Task<CvUploadResponse> UploadCvAsync(IFormFile file, 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 IJobTextExtractor
|
||||
{
|
||||
Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using System.Text.Json;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using Api.Clients.Api.Contracts;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Api.Services.Contracts;
|
||||
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 MatcherSettings _settings;
|
||||
|
||||
public CvMatcherService(
|
||||
IRagApiClient rag,
|
||||
IJobTextExtractor jobTextExtractor,
|
||||
IMatcherAiClient ai,
|
||||
IMatcherRepository repository,
|
||||
IOptions<MatcherSettings> options)
|
||||
{
|
||||
_rag = rag;
|
||||
_jobTextExtractor = jobTextExtractor;
|
||||
_ai = ai;
|
||||
_repository = repository;
|
||||
_settings = options.Value;
|
||||
}
|
||||
|
||||
public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
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,51 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Api.Services.Contracts;
|
||||
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,110 @@
|
||||
{
|
||||
"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
|
||||
},
|
||||
"Database": {
|
||||
"Host": "localhost",
|
||||
"Port": 1433,
|
||||
"Name": "MyAiCvMatcher",
|
||||
"User": "sa",
|
||||
"Password": "",
|
||||
"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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user