@@ -1,4 +1,5 @@
|
||||
using Api.Models;
|
||||
using Api.Services.Contracts.Models;
|
||||
using Api.Requests;
|
||||
using Api.Services.Contracts;
|
||||
using Api.Settings;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
@@ -118,7 +119,7 @@ namespace Api.Controllers
|
||||
/// <param name="token">Client-provided reCAPTCHA token.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Tuple containing the verification verdict and user IP.</returns>
|
||||
private async Task<(CaptchaVerdict Verdict, string? UserIp)> ValidateCaptcha(string token, CancellationToken ct)
|
||||
private async Task<(CaptchaVerdictModel Verdict, string? UserIp)> ValidateCaptcha(string token, CancellationToken ct)
|
||||
{
|
||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
var verdict = await _captcha.VerifyAsync(token, userIp, ct);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using api.Services.Contracts.Rag;
|
||||
using Api.Models.Rag;
|
||||
using Api.Services.Rag;
|
||||
using Api.Requests;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
@@ -11,52 +12,135 @@ namespace Api.Controllers;
|
||||
[EnableRateLimiting("rag")]
|
||||
public sealed class RagController : ControllerBase
|
||||
{
|
||||
private readonly ICvRagService _cvRagService;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly ILogger<RagController> _logger;
|
||||
|
||||
public RagController(ICvRagService cvRagService, ILogger<RagController> logger)
|
||||
public RagController(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IConfiguration configuration,
|
||||
ILogger<RagController> logger)
|
||||
{
|
||||
_cvRagService = cvRagService;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("cv")]
|
||||
[RequestSizeLimit(8 * 1024 * 1024)]
|
||||
public async Task<IActionResult> UploadCv([FromForm(Name = "cv")] IFormFile? cv, [FromForm] bool gdprConsent, CancellationToken ct)
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
||||
public async Task<IActionResult> UploadCv(
|
||||
[FromForm(Name = "cv")] IFormFile? cv,
|
||||
[FromForm] bool gdprConsent,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (cv is null)
|
||||
{
|
||||
return BadRequest(new { error = "Missing CV PDF." });
|
||||
}
|
||||
|
||||
var baseUrl = GetCvMatcherBaseUrl();
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogError("CvMatcherApi:BaseUrl is not configured. The public API cannot proxy CV upload requests.");
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API is not configured." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (cv is null) return BadRequest(new { error = "Missing CV PDF." });
|
||||
var result = await _cvRagService.IngestCvAsync(cv, gdprConsent, ct);
|
||||
return Ok(result);
|
||||
_logger.LogInformation("Proxying CV upload to cv-matcher-api. FileName={FileName}, Size={SizeBytes}, GdprConsent={GdprConsent}",
|
||||
cv.FileName, cv.Length, gdprConsent);
|
||||
|
||||
using var client = CreateCvMatcherClient(baseUrl);
|
||||
using var form = new MultipartFormDataContent();
|
||||
await using var stream = cv.OpenReadStream();
|
||||
using var fileContent = new StreamContent(stream);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
|
||||
form.Add(fileContent, "cv", cv.FileName);
|
||||
form.Add(new StringContent(gdprConsent.ToString().ToLowerInvariant()), "gdprConsent");
|
||||
|
||||
using var response = await client.PostAsync("api/cv/upload", form, ct);
|
||||
return await ProxyResponseAsync(response, ct);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
_logger.LogWarning("CV upload proxy request was cancelled by the client.");
|
||||
return StatusCode(499, new { error = "Request cancelled." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CV ingestion failed");
|
||||
return StatusCode(500, new { error = "CV ingestion failed." });
|
||||
_logger.LogError(ex, "CV upload proxy request failed.");
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API request failed." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("match-job")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status502BadGateway)]
|
||||
public async Task<IActionResult> MatchJob([FromBody] JobMatchRequest request, CancellationToken ct)
|
||||
{
|
||||
var baseUrl = GetCvMatcherBaseUrl();
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
_logger.LogError("CvMatcherApi:BaseUrl is not configured. The public API cannot proxy job matching requests.");
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API is not configured." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _cvRagService.MatchJobAsync(request, ct);
|
||||
return Ok(result);
|
||||
_logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}",
|
||||
request.CvDocumentId,
|
||||
!string.IsNullOrWhiteSpace(request.JobUrl),
|
||||
!string.IsNullOrWhiteSpace(request.JobDescription));
|
||||
|
||||
using var client = CreateCvMatcherClient(baseUrl);
|
||||
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
using var response = await client.PostAsync(
|
||||
"api/cv/match-job",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"),
|
||||
ct);
|
||||
|
||||
return await ProxyResponseAsync(response, ct);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
_logger.LogWarning("Job match proxy request was cancelled by the client.");
|
||||
return StatusCode(499, new { error = "Request cancelled." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Job matching failed");
|
||||
return StatusCode(500, new { error = "Job matching failed." });
|
||||
_logger.LogError(ex, "Job match proxy request failed.");
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "CV matcher API request failed." });
|
||||
}
|
||||
}
|
||||
|
||||
private string GetCvMatcherBaseUrl() => _configuration["CvMatcherApi:BaseUrl"] ?? string.Empty;
|
||||
|
||||
private HttpClient CreateCvMatcherClient(string baseUrl)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient("CvMatcherApi");
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
|
||||
var key = _configuration["CvMatcherApi:InternalApiKey"];
|
||||
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
|
||||
{
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<ContentResult> ProxyResponseAsync(HttpResponseMessage response, CancellationToken ct)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
return new ContentResult
|
||||
{
|
||||
StatusCode = (int)response.StatusCode,
|
||||
Content = body,
|
||||
ContentType = response.Content.Headers.ContentType?.ToString() ?? "application/json"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
namespace Api.Models.Rag;
|
||||
|
||||
public sealed record CvIngestResponse(
|
||||
string DocumentId,
|
||||
int Chunks,
|
||||
int CharactersExtracted,
|
||||
string Summary
|
||||
);
|
||||
|
||||
public sealed class JobMatchRequest
|
||||
{
|
||||
public string? CvDocumentId { get; set; }
|
||||
public string? JobUrl { get; set; }
|
||||
public string? JobDescription { get; set; }
|
||||
public bool GdprConsent { get; set; }
|
||||
}
|
||||
|
||||
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 sealed class StoredCvChunk
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string DocumentId { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public required float[] Embedding { get; init; }
|
||||
public required int ChunkIndex { get; init; }
|
||||
public DateTimeOffset ExpiresAt { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RetrievedCvChunk
|
||||
{
|
||||
public required string Text { get; init; }
|
||||
public required int ChunkIndex { get; init; }
|
||||
public double Score { get; init; }
|
||||
}
|
||||
+1
-11
@@ -1,8 +1,5 @@
|
||||
using api.Services.Contracts.Rag;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using Api.Services.Contracts.Rag;
|
||||
using Api.Services.Rag;
|
||||
using Api.Settings;
|
||||
using Azure.Identity;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
@@ -78,19 +75,12 @@ try
|
||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
|
||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||
builder.Services.Configure<RagSettings>(builder.Configuration.GetSection("Rag"));
|
||||
builder.Services.Configure<OpenAiSettings>(builder.Configuration.GetSection("OpenAI"));
|
||||
|
||||
// Services
|
||||
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
||||
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
||||
builder.Services.AddSingleton<IPdfTextExtractor, PdfTextExtractor>();
|
||||
builder.Services.AddSingleton<ITextChunker, TextChunker>();
|
||||
builder.Services.AddSingleton<ICvVectorStore, InMemoryCvVectorStore>();
|
||||
builder.Services.AddScoped<ICvRagService, CvRagService>();
|
||||
builder.Services.AddHttpClient<IAiRagClient, OpenAiRagClient>();
|
||||
builder.Services.AddHttpClient<IJobTextExtractor, JobTextExtractor>();
|
||||
builder.Services.AddHttpClient("CvMatcherApi");
|
||||
|
||||
// Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Api.Models
|
||||
namespace Api.Requests
|
||||
{
|
||||
public sealed class ContactRequest
|
||||
{
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Api.Requests;
|
||||
|
||||
public sealed class JobMatchRequest
|
||||
{
|
||||
public string? CvDocumentId { get; set; }
|
||||
public string? JobUrl { get; set; }
|
||||
public string? JobDescription { get; set; }
|
||||
public bool GdprConsent { get; set; }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Api.Models
|
||||
namespace Api.Requests
|
||||
{
|
||||
public sealed class SubscribeRequest
|
||||
{
|
||||
@@ -1,9 +1,9 @@
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
public sealed record CaptchaVerdict(bool Success, string? Error, double? Score);
|
||||
using Api.Services.Contracts.Models;
|
||||
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
public interface ICaptchaVerifier
|
||||
{
|
||||
Task<CaptchaVerdict> VerifyAsync(string token, string? userIp, CancellationToken ct);
|
||||
Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Api.Models;
|
||||
using Api.Requests;
|
||||
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace Api.Services.Contracts.Models
|
||||
{
|
||||
public sealed record CaptchaVerdictModel(bool Success, string? Error, double? Score);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Api.Services.Contracts.Rag;
|
||||
|
||||
public interface IAiRagClient
|
||||
{
|
||||
Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct);
|
||||
Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, CancellationToken ct);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
using Api.Models.Rag;
|
||||
|
||||
namespace api.Services.Contracts.Rag;
|
||||
|
||||
public interface ICvRagService
|
||||
{
|
||||
Task<CvIngestResponse> IngestCvAsync(IFormFile file, bool gdprConsent, CancellationToken ct);
|
||||
Task<JobMatchResponse> MatchJobAsync(JobMatchRequest request, CancellationToken ct);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Api.Services.Contracts.Rag;
|
||||
|
||||
public interface IJobTextExtractor
|
||||
{
|
||||
Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Api.Services.Contracts.Rag;
|
||||
|
||||
public interface IPdfTextExtractor
|
||||
{
|
||||
string ExtractText(Stream pdfStream);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace Api.Services.Contracts.Rag;
|
||||
|
||||
public interface ITextChunker
|
||||
{
|
||||
IReadOnlyList<string> Chunk(string text, int chunkSize, int overlap);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
using api.Services.Contracts.Rag;
|
||||
using Api.Models.Rag;
|
||||
using Api.Services.Contracts.Rag;
|
||||
using Api.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Api.Services.Rag;
|
||||
|
||||
public sealed class CvRagService : ICvRagService
|
||||
{
|
||||
private readonly IPdfTextExtractor _pdfTextExtractor;
|
||||
private readonly ITextChunker _textChunker;
|
||||
private readonly IAiRagClient _openAi;
|
||||
private readonly ICvVectorStore _store;
|
||||
private readonly IJobTextExtractor _jobTextExtractor;
|
||||
private readonly RagSettings _settings;
|
||||
private readonly ILogger<CvRagService> _logger;
|
||||
|
||||
public CvRagService(
|
||||
IPdfTextExtractor pdfTextExtractor,
|
||||
ITextChunker textChunker,
|
||||
IAiRagClient openAi,
|
||||
ICvVectorStore store,
|
||||
IJobTextExtractor jobTextExtractor,
|
||||
IOptions<RagSettings> options,
|
||||
ILogger<CvRagService> logger)
|
||||
{
|
||||
_pdfTextExtractor = pdfTextExtractor;
|
||||
_textChunker = textChunker;
|
||||
_openAi = openAi;
|
||||
_store = store;
|
||||
_jobTextExtractor = jobTextExtractor;
|
||||
_settings = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<CvIngestResponse> IngestCvAsync(IFormFile file, bool gdprConsent, CancellationToken ct)
|
||||
{
|
||||
if (!gdprConsent) throw new InvalidOperationException("GDPR consent is required.");
|
||||
if (file.Length == 0) throw new InvalidOperationException("CV PDF is empty.");
|
||||
if (file.Length > _settings.MaxPdfSizeMb * 1024L * 1024L) throw new InvalidOperationException($"PDF is too large. Max size is {_settings.MaxPdfSizeMb} MB.");
|
||||
if (!string.Equals(Path.GetExtension(file.FileName), ".pdf", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Only PDF files are accepted.");
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var text = _pdfTextExtractor.ExtractText(stream);
|
||||
if (text.Length < 80) throw new InvalidOperationException("Could not extract enough text from this PDF.");
|
||||
|
||||
var documentId = $"cv_{Guid.NewGuid():N}";
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(Math.Max(10, _settings.CvTtlMinutes));
|
||||
var chunks = _textChunker.Chunk(text, _settings.ChunkSize, _settings.ChunkOverlap);
|
||||
|
||||
var stored = new List<StoredCvChunk>();
|
||||
for (var i = 0; i < chunks.Count; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
stored.Add(new StoredCvChunk
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
DocumentId = documentId,
|
||||
Text = chunks[i],
|
||||
Embedding = await _openAi.CreateEmbeddingAsync(chunks[i], ct),
|
||||
ChunkIndex = i,
|
||||
ExpiresAt = expiresAt
|
||||
});
|
||||
}
|
||||
|
||||
_store.Save(documentId, stored);
|
||||
var summary = await SummarizeCvAsync(text, ct);
|
||||
return new CvIngestResponse(documentId, stored.Count, text.Length, summary);
|
||||
}
|
||||
|
||||
public async Task<JobMatchResponse> MatchJobAsync(JobMatchRequest 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 cvChunks = _store.Get(request.CvDocumentId);
|
||||
if (cvChunks.Count == 0) throw new InvalidOperationException("CV context was not found or has expired. Upload the CV again.");
|
||||
|
||||
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 jobEmbedding = await _openAi.CreateEmbeddingAsync(jobText, ct);
|
||||
var retrieved = _store.Search(request.CvDocumentId, jobEmbedding, _settings.TopK);
|
||||
var cvContext = string.Join("\n\n", retrieved.Select(x => $"CV chunk {x.ChunkIndex} | similarity {x.Score:0.000}:\n{x.Text}"));
|
||||
|
||||
var systemPrompt = "You are a strict senior technical recruiter and AI CV matcher. Return only valid JSON. Do not invent candidate experience. Use only the supplied CV context and job text.";
|
||||
var userPrompt = $$"""
|
||||
Compare the candidate CV context with the job description.
|
||||
Return this JSON shape exactly:
|
||||
{
|
||||
"score": 0,
|
||||
"summary": "short direct assessment",
|
||||
"strengths": ["strength 1"],
|
||||
"gaps": ["gap 1"],
|
||||
"recommendations": ["action 1"],
|
||||
"evidence": ["short CV evidence quote or paraphrase"]
|
||||
}
|
||||
Score must be 0-100.
|
||||
|
||||
CV CONTEXT:
|
||||
{{cvContext}}
|
||||
|
||||
JOB DESCRIPTION:
|
||||
{{jobText}}
|
||||
""";
|
||||
|
||||
var content = await _openAi.CreateChatCompletionAsync(systemPrompt, userPrompt, ct);
|
||||
var response = ParseMatchResponse(content);
|
||||
if (response.Evidence.Count == 0)
|
||||
{
|
||||
response.Evidence = retrieved.Select(x => x.Text.Length > 280 ? x.Text[..280] + "..." : x.Text).ToList();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<string> SummarizeCvAsync(string cvText, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var shortened = cvText.Length > 8000 ? cvText[..8000] : cvText;
|
||||
var content = await _openAi.CreateChatCompletionAsync(
|
||||
"Return only valid JSON.",
|
||||
$$"""
|
||||
Summarize this CV in one concise sentence. Return JSON: { "summary": "..." }
|
||||
|
||||
CV:
|
||||
{{shortened}}
|
||||
""",
|
||||
ct);
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
return doc.RootElement.TryGetProperty("summary", out var summary) ? summary.GetString() ?? "CV indexed." : "CV indexed.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "CV summary failed");
|
||||
return "CV indexed.";
|
||||
}
|
||||
}
|
||||
|
||||
private static JobMatchResponse ParseMatchResponse(string content)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = JsonSerializer.Deserialize<JobMatchResponse>(content, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new JobMatchResponse();
|
||||
response.Score = Math.Clamp(response.Score, 0, 100);
|
||||
response.Strengths ??= [];
|
||||
response.Gaps ??= [];
|
||||
response.Recommendations ??= [];
|
||||
response.Evidence ??= [];
|
||||
return response;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new JobMatchResponse
|
||||
{
|
||||
Score = 0,
|
||||
Summary = "The AI response could not be parsed. Check logs and prompt output.",
|
||||
Gaps = ["Invalid JSON returned by the model."],
|
||||
Evidence = []
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
using Api.Models.Rag;
|
||||
|
||||
namespace Api.Services.Rag;
|
||||
|
||||
public interface ICvVectorStore
|
||||
{
|
||||
void Save(string documentId, IEnumerable<StoredCvChunk> chunks);
|
||||
IReadOnlyList<StoredCvChunk> Get(string documentId);
|
||||
IReadOnlyList<RetrievedCvChunk> Search(string documentId, float[] queryEmbedding, int topK);
|
||||
}
|
||||
|
||||
public sealed class InMemoryCvVectorStore : ICvVectorStore
|
||||
{
|
||||
private readonly object _lock = new();
|
||||
private readonly Dictionary<string, List<StoredCvChunk>> _store = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public void Save(string documentId, IEnumerable<StoredCvChunk> chunks)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
CleanupExpiredUnsafe();
|
||||
_store[documentId] = chunks.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<StoredCvChunk> Get(string documentId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
CleanupExpiredUnsafe();
|
||||
return _store.TryGetValue(documentId, out var chunks) ? chunks.ToList() : [];
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<RetrievedCvChunk> Search(string documentId, float[] queryEmbedding, int topK)
|
||||
{
|
||||
var chunks = Get(documentId);
|
||||
if (chunks.Count == 0) return [];
|
||||
|
||||
return chunks
|
||||
.Select(chunk => new RetrievedCvChunk
|
||||
{
|
||||
Text = chunk.Text,
|
||||
ChunkIndex = chunk.ChunkIndex,
|
||||
Score = CosineSimilarity(queryEmbedding, chunk.Embedding)
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.Take(Math.Clamp(topK, 1, 12))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void CleanupExpiredUnsafe()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
foreach (var key in _store.Where(x => x.Value.All(c => c.ExpiresAt <= now)).Select(x => x.Key).ToList())
|
||||
{
|
||||
_store.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static double CosineSimilarity(float[] a, float[] b)
|
||||
{
|
||||
if (a.Length != b.Length || a.Length == 0) return 0;
|
||||
|
||||
double dot = 0;
|
||||
double magA = 0;
|
||||
double magB = 0;
|
||||
|
||||
for (var i = 0; i < a.Length; i++)
|
||||
{
|
||||
dot += a[i] * b[i];
|
||||
magA += a[i] * a[i];
|
||||
magB += b[i] * b[i];
|
||||
}
|
||||
|
||||
if (magA == 0 || magB == 0) return 0;
|
||||
return dot / (Math.Sqrt(magA) * Math.Sqrt(magB));
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.Services.Contracts.Rag;
|
||||
using Api.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.Services.Rag;
|
||||
public sealed class JobTextExtractor : IJobTextExtractor
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly RagSettings _settings;
|
||||
|
||||
public JobTextExtractor(HttpClient httpClient, IOptions<RagSettings> options)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_settings = options.Value;
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(20);
|
||||
_httpClient.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 != "http" && uri.Scheme != "https"))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid job URL.");
|
||||
}
|
||||
|
||||
var html = await _httpClient.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, "<[^>]+>", " ");
|
||||
var text = WebUtility.HtmlDecode(html);
|
||||
return Limit(Normalize(text));
|
||||
}
|
||||
|
||||
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;
|
||||
var parts = value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Join(' ', parts).Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Api.Services.Contracts.Rag;
|
||||
using Api.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.Services.Rag;
|
||||
|
||||
public sealed class OpenAiRagClient : IAiRagClient
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly OpenAiSettings _settings;
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
};
|
||||
|
||||
public OpenAiRagClient(HttpClient httpClient, IOptions<OpenAiSettings> options)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_settings = options.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_settings.ApiKey))
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _settings.ApiKey);
|
||||
}
|
||||
|
||||
_httpClient.Timeout = TimeSpan.FromSeconds(Math.Max(15, _settings.TimeoutSeconds));
|
||||
_httpClient.BaseAddress = new Uri("https://api.openai.com/v1/");
|
||||
}
|
||||
|
||||
public async Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct)
|
||||
{
|
||||
EnsureConfigured();
|
||||
var payload = new { model = _settings.EmbeddingModel, input };
|
||||
using var response = await _httpClient.PostAsync("embeddings", ToJson(payload), ct);
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"OpenAI embeddings request failed: {(int)response.StatusCode} {json}");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
var embedding = document.RootElement.GetProperty("data")[0].GetProperty("embedding");
|
||||
var result = new float[embedding.GetArrayLength()];
|
||||
var i = 0;
|
||||
foreach (var value in embedding.EnumerateArray())
|
||||
{
|
||||
result[i++] = value.GetSingle();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, CancellationToken ct)
|
||||
{
|
||||
EnsureConfigured();
|
||||
var payload = new
|
||||
{
|
||||
model = _settings.ChatModel,
|
||||
temperature = 0.2,
|
||||
response_format = new { type = "json_object" },
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "system", content = systemPrompt },
|
||||
new { role = "user", content = userPrompt }
|
||||
}
|
||||
};
|
||||
|
||||
using var response = await _httpClient.PostAsync("chat/completions", ToJson(payload), ct);
|
||||
var json = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new InvalidOperationException($"OpenAI chat request failed: {(int)response.StatusCode} {json}");
|
||||
}
|
||||
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return document.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString() ?? "{}";
|
||||
}
|
||||
|
||||
private void EnsureConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.ApiKey))
|
||||
{
|
||||
throw new InvalidOperationException("OpenAI API key is not configured. Set OpenAI__ApiKey.");
|
||||
}
|
||||
}
|
||||
|
||||
private static StringContent ToJson<T>(T payload) => new(
|
||||
JsonSerializer.Serialize(payload, JsonOptions),
|
||||
Encoding.UTF8,
|
||||
"application/json"
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using Api.Services.Contracts.Rag;
|
||||
using System.Text;
|
||||
using UglyToad.PdfPig;
|
||||
|
||||
namespace Api.Services.Rag;
|
||||
|
||||
public sealed class PdfTextExtractor : IPdfTextExtractor
|
||||
{
|
||||
public string ExtractText(Stream pdfStream)
|
||||
{
|
||||
using var document = PdfDocument.Open(pdfStream);
|
||||
var builder = new StringBuilder();
|
||||
|
||||
foreach (var page in document.GetPages())
|
||||
{
|
||||
builder.AppendLine(page.Text);
|
||||
builder.AppendLine();
|
||||
}
|
||||
|
||||
return NormalizeWhitespace(builder.ToString());
|
||||
}
|
||||
|
||||
private static string NormalizeWhitespace(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
var parts = value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
return string.Join(' ', parts).Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using Api.Services.Contracts.Rag;
|
||||
|
||||
namespace Api.Services.Rag;
|
||||
|
||||
public sealed class TextChunker : ITextChunker
|
||||
{
|
||||
public IReadOnlyList<string> Chunk(string text, int chunkSize, int overlap)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return [];
|
||||
chunkSize = Math.Clamp(chunkSize, 300, 3000);
|
||||
overlap = Math.Clamp(overlap, 0, chunkSize / 2);
|
||||
|
||||
var chunks = new List<string>();
|
||||
var start = 0;
|
||||
while (start < text.Length)
|
||||
{
|
||||
var length = Math.Min(chunkSize, text.Length - start);
|
||||
chunks.Add(text.Substring(start, length).Trim());
|
||||
start += chunkSize - overlap;
|
||||
}
|
||||
|
||||
return chunks.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Api.Services.Contracts.Models;
|
||||
using Api.Services.Contracts;
|
||||
using Api.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -17,14 +18,14 @@ namespace Api.Services
|
||||
_log = log;
|
||||
}
|
||||
|
||||
public async Task<CaptchaVerdict> VerifyAsync(string token, string? userIp, CancellationToken ct)
|
||||
public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, CancellationToken ct)
|
||||
{
|
||||
_log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(_opt.SecretKey))
|
||||
{
|
||||
_log.LogWarning("Captcha verification attempted but SecretKey is not configured");
|
||||
return new CaptchaVerdict(false, "Captcha not configured", null);
|
||||
return new CaptchaVerdictModel(false, "Captcha not configured", null);
|
||||
}
|
||||
|
||||
var form = new Dictionary<string, string>
|
||||
@@ -45,21 +46,21 @@ namespace Api.Services
|
||||
{
|
||||
_log.LogWarning("Captcha HTTP request failed with status {StatusCode} for IP {Ip}",
|
||||
(int)resp.StatusCode, userIp ?? "unknown");
|
||||
return new CaptchaVerdict(false, $"Captcha HTTP {(int)resp.StatusCode}", null);
|
||||
return new CaptchaVerdictModel(false, $"Captcha HTTP {(int)resp.StatusCode}", null);
|
||||
}
|
||||
|
||||
var data = await resp.Content.ReadFromJsonAsync<RecaptchaResponse>(cancellationToken: ct);
|
||||
if (data is null)
|
||||
{
|
||||
_log.LogError("Failed to parse captcha response for IP {Ip}", userIp ?? "unknown");
|
||||
return new CaptchaVerdict(false, "Captcha parse error", null);
|
||||
return new CaptchaVerdictModel(false, "Captcha parse error", null);
|
||||
}
|
||||
|
||||
if (!data.success)
|
||||
{
|
||||
_log.LogWarning("Captcha verification failed for IP {Ip}. Score={Score}",
|
||||
userIp ?? "unknown", data.score);
|
||||
return new CaptchaVerdict(false, "Captcha failed", data.score);
|
||||
return new CaptchaVerdictModel(false, "Captcha failed", data.score);
|
||||
}
|
||||
|
||||
// v3 score check (score is typically null for v2)
|
||||
@@ -67,7 +68,7 @@ namespace Api.Services
|
||||
{
|
||||
_log.LogWarning("Captcha score {Score} below minimum {MinScore} for IP {Ip}",
|
||||
score, _opt.MinimumScore, userIp ?? "unknown");
|
||||
return new CaptchaVerdict(false, "Captcha score too low", score);
|
||||
return new CaptchaVerdictModel(false, "Captcha score too low", score);
|
||||
}
|
||||
|
||||
// Optional strictness (usually v3): action/hostname checks
|
||||
@@ -76,7 +77,7 @@ namespace Api.Services
|
||||
{
|
||||
_log.LogWarning("Captcha action mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}",
|
||||
_opt.ExpectedAction, data.action, userIp ?? "unknown");
|
||||
return new CaptchaVerdict(false, "Captcha action mismatch", data.score);
|
||||
return new CaptchaVerdictModel(false, "Captcha action mismatch", data.score);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_opt.ExpectedHostname) &&
|
||||
@@ -84,12 +85,12 @@ namespace Api.Services
|
||||
{
|
||||
_log.LogWarning("Captcha hostname mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}",
|
||||
_opt.ExpectedHostname, data.hostname, userIp ?? "unknown");
|
||||
return new CaptchaVerdict(false, "Captcha hostname mismatch", data.score);
|
||||
return new CaptchaVerdictModel(false, "Captcha hostname mismatch", data.score);
|
||||
}
|
||||
|
||||
_log.LogInformation("Captcha verified successfully for IP {Ip}. Score={Score}",
|
||||
userIp ?? "unknown", data.score);
|
||||
return new CaptchaVerdict(true, null, data.score);
|
||||
return new CaptchaVerdictModel(true, null, data.score);
|
||||
}
|
||||
|
||||
private sealed class RecaptchaResponse
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using Api.Services.Contracts;
|
||||
using Api.Models;
|
||||
using Api.Requests;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -10,6 +10,7 @@
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<DisableStaticWebAssets>true</DisableStaticWebAssets>
|
||||
<RootNamespace>Api</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -18,7 +19,6 @@
|
||||
<PackageReference Include="DotNetEnv" Version="3.2.0" />
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||
<PackageReference Include="PdfPig" Version="0.1.14" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
|
||||
|
||||
+4
-14
@@ -106,18 +106,8 @@
|
||||
"FromEmail": "",
|
||||
"SubjectPrefix": "[File Download]"
|
||||
},
|
||||
"OpenAI": {
|
||||
"ApiKey": "",
|
||||
"ChatModel": "gpt-4o-mini",
|
||||
"EmbeddingModel": "text-embedding-3-small",
|
||||
"TimeoutSeconds": 60
|
||||
},
|
||||
"Rag": {
|
||||
"MaxPdfSizeMb": 5,
|
||||
"ChunkSize": 900,
|
||||
"ChunkOverlap": 150,
|
||||
"CvTtlMinutes": 60,
|
||||
"MaxJobTextChars": 20000,
|
||||
"TopK": 6
|
||||
"CvMatcherApi": {
|
||||
"BaseUrl": "",
|
||||
"InternalApiKey": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user