Changes
Build and Push Docker Images / build (push) Successful in 4m35s

This commit is contained in:
2026-05-14 14:12:29 +03:00
parent 92278ae375
commit 75bc9509c5
137 changed files with 0 additions and 371 deletions
@@ -0,0 +1,9 @@
namespace Models.Requests
{
public class CaptchaVerifyRequest
{
public string? Token { get; set; }
public string? ExpectedAction { get; set; }
}
}
@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
namespace Models.Requests
{
public sealed class ContactRequest
{
[Required, StringLength(100)]
public string Name { get; set; } = "";
[Required, EmailAddress, StringLength(200)]
public string Email { get; set; } = "";
[Required, StringLength(200)]
public string Subject { get; set; } = "";
[Required, StringLength(5000)]
public string Message { get; set; } = "";
// Token returned by the captcha widget
[Required]
public string CaptchaToken { get; set; } = "";
}
}
@@ -0,0 +1,11 @@
namespace Models.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; }
public string? Email { get; set; }
public string? CaptchaToken { get; set; }
}
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Models.Requests
{
public sealed class SubscribeRequest
{
[Required, EmailAddress, StringLength(200)]
public string Email { get; set; } = "";
// Token returned by the captcha widget
[Required]
public string CaptchaToken { get; set; } = "";
}
}
@@ -0,0 +1,11 @@
using Shared.Models.Requests;
namespace Models.Requests
{
public sealed class UploadCvRequest : UploadFileRequest
{
public bool GdprConsent { get; set; }
public string? CaptchaToken { get; set; }
}
}
@@ -0,0 +1,17 @@
namespace Models.Settings
{
public sealed class CaptchaSettings
{
// "Recaptcha" for now (easy to extend later)
public string Provider { get; set; } = "Recaptcha";
public string SecretKey { get; set; } = "";
public string PublicKey { get; set; } = "";
// Only relevant for reCAPTCHA v3 (score-based)
public double MinimumScore { get; set; } = 0.5;
// Optional but recommended for v3: enforce expected action and/or hostname
public string? ExpectedAction { get; set; }
public string? ExpectedHostname { get; set; }
}
}
@@ -0,0 +1,8 @@
namespace Models.Settings
{
public sealed class ContactSettings
{
public string ToEmail { get; set; } = "";
public string SubjectPrefix { get; set; } = "[Contact]";
}
}
@@ -0,0 +1,10 @@
namespace Models.Settings
{
public sealed class FileStorageSettings
{
public string Path { get; set; } = "Files";
public string DefaultFileName { get; set; } = "";
public string ToEmail { get; set; } = "";
public string SubjectPrefix { get; set; } = "[File Download]";
}
}
@@ -0,0 +1,8 @@
namespace Models.Settings
{
public sealed class GoogleSettings
{
public string TagManagerId { get; set; } = "";
public string MapKey { get; set; } = "";
}
}
@@ -0,0 +1,8 @@
namespace Models.Settings
{
public sealed class KeyVaultSettings
{
public string VaultUri { get; set; } = "";
public bool Enabled { get; set; } = false;
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Models.Settings
{
public class SmtpSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool UseStartTls { get; set; } = true;
}
}
@@ -0,0 +1,8 @@
namespace Models.Settings
{
public sealed class SubscribeSettings
{
public string ToEmail { get; set; } = "";
public string SubjectPrefix { get; set; } = "[Subscribe]";
}
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\shared-models\shared-models.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,15 @@
using CvMatcher.Models.Responses;
using Models.Requests;
using Refit;
namespace Api.Clients.Api.Contracts;
public interface ICvMatcherApi
{
[Multipart]
[Post("/api/cv/upload")]
Task<CvUploadResponse> Upload([AliasAs("file")] StreamPart file, CancellationToken ct);
[Post("/api/cv/match-job")]
Task<JobMatchResponse> MatchJob([Body] JobMatchRequest request, CancellationToken ct);
}
+71
View File
@@ -0,0 +1,71 @@
using Api.Services.Contracts;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Models.Settings;
using Swashbuckle.AspNetCore.Annotations;
using Models.Requests;
using Shared.Models.Responses;
namespace Api.Controllers
{
/// <summary>
/// Endpoints that expose captcha configuration and verification.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public sealed class CaptchaController : ControllerBase
{
private readonly CaptchaSettings _captchaSettings;
private readonly ICaptchaVerifier _captcha;
private readonly ILogger<CaptchaController> _log;
public CaptchaController(IOptions<CaptchaSettings> options, ICaptchaVerifier captcha, ILogger<CaptchaController> log)
{
_captchaSettings = options.Value;
_captcha = captcha;
_log = log;
}
/// <summary>
/// Returns the public reCAPTCHA site key used by the client to render the widget.
/// </summary>
[HttpGet]
[SwaggerOperation(Summary = "Get captcha site key")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetSiteKey()
{
return Ok(_captchaSettings.PublicKey);
}
/// <summary>
/// Verify a captcha token and return the verification verdict.
/// </summary>
[HttpPost("verify")]
[SwaggerOperation(Summary = "Verify captcha token")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Captcha verification failed or token missing", typeof(ErrorResponse))]
public async Task<IActionResult> Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct)
{
if (req is null || string.IsNullOrWhiteSpace(req.Token))
{
return BadRequest(new ErrorResponse { Error = "Missing token", Code = "captcha_token_missing" });
}
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var verdict = await _captcha.VerifyAsync(req.Token, userIp, req.ExpectedAction, ct);
if (!verdict.Success)
{
_log.LogWarning("Captcha failed. ip={Ip} score={Score} err={Err}", userIp, verdict.Score, verdict.Error);
return BadRequest(new ErrorResponse
{
Error = "Captcha verification failed.",
Code = "captcha_verification_failed",
Score = verdict.Score
});
}
return Ok(verdict);
}
}
}
+123
View File
@@ -0,0 +1,123 @@
using Api.Services.Contracts.Models;
using Api.Services.Contracts;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Models.Settings;
using Models.Requests;
using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses;
namespace Api.Controllers
{
/// <summary>
/// Exposes endpoints used by the frontend to send contact messages and to
/// subscribe to newsletters. All endpoints are protected by reCAPTCHA
/// verification and rate limiting.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[EnableCors("FrontendOnly")]
public sealed class ContactController : ControllerBase
{
private readonly ICaptchaVerifier _captcha;
private readonly IEmailSender _email;
private readonly ILogger<ContactController> _log;
public ContactController(ICaptchaVerifier captcha, IEmailSender email, ILogger<ContactController> log)
{
_captcha = captcha;
_email = email;
_log = log;
}
/// <summary>
/// Validates the provided reCAPTCHA token and sends a contact message
/// via the configured email sender.
/// </summary>
/// <param name="req">Contact request containing name, email, subject,
/// and message. The <c>CaptchaToken</c> field is required for verification.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK when the message was queued/sent; 400 Bad Request when
/// captcha verification fails; 500 on internal errors.
/// </returns>
[HttpPost]
[EnableRateLimiting("contact")]
[SwaggerOperation(Summary = "Send contact message", Description = "Validates captcha and sends a contact message using the configured email sender.")]
[SwaggerResponse(StatusCodes.Status200OK, "Contact message sent")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request or captcha verification failed")]
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Contact message could not be sent due to server error")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Send([FromBody] ContactRequest req, CancellationToken ct)
{
if (!ModelState.IsValid)
return ValidationProblem(ModelState);
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var verdict = await _captcha.VerifyAsync(req.CaptchaToken, userIp, "contact", ct);
if (!verdict.Success)
{
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
}
try
{
await _email.SendContactAsync(req, ct);
return Ok(new { ok = true });
}
catch (Exception ex)
{
_log.LogError(ex, "Contact send failed. ip={Ip} from={From}", userIp, req.Email);
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Could not send message.", Code = "contact_send_failed" });
}
}
/// <summary>
/// Validates the provided reCAPTCHA token and subscribes the given
/// email address to the newsletter or mailing list.
/// </summary>
/// <param name="req">Subscription request containing the email and
/// the <c>CaptchaToken</c>.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK when subscription succeeded; 400 when captcha verification
/// fails; 500 on internal errors.
/// </returns>
[HttpPost("subscribe")]
[EnableRateLimiting("contact")]
[SwaggerOperation(Summary = "Subscribe email", Description = "Validates captcha and subscribes an email address to the mailing list.")]
[SwaggerResponse(StatusCodes.Status200OK, "Subscription succeeded")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request or captcha verification failed")]
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Subscription failed due to server error")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest req, CancellationToken ct)
{
if (!ModelState.IsValid)
return ValidationProblem(ModelState);
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var verdict = await _captcha.VerifyAsync(req.CaptchaToken, userIp, "subscribe", ct);
if (!verdict.Success)
{
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
}
try
{
await _email.SendSubscribeAsync(req, ct);
return Ok(new { ok = true });
}
catch (Exception ex)
{
_log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email);
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed.", Code = "subscription_failed" });
}
}
}
}
+214
View File
@@ -0,0 +1,214 @@
using Api.Clients.Api.Contracts;
using Models.Requests;
using Models.Settings;
using Api.Services.Contracts;
using Api.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses;
namespace Api.Controllers;
/// <summary>
/// Proxy endpoints for the CV matcher API. These endpoints forward requests to the internal cv-matcher-api.
/// </summary>
[ApiController]
[Route("api/cv-matcher")]
[EnableRateLimiting("cvMatcher")]
public sealed class CvMatcherController : ControllerBase
{
private readonly ICvMatcherApi _cvApi;
private readonly ICaptchaVerifier _captcha;
private readonly FileStorageSettings _fileStorageSettings;
private readonly IEmailSender _emailSender;
private readonly ILogger<CvMatcherController> _logger;
public CvMatcherController(
ICvMatcherApi cvApi,
ICaptchaVerifier captcha,
IOptions<FileStorageSettings> fileStorageSettings,
IEmailSender emailSender,
ILogger<CvMatcherController> logger)
{
_cvApi = cvApi;
_captcha = captcha;
_fileStorageSettings = fileStorageSettings.Value;
_emailSender = emailSender;
_logger = logger;
}
/// <summary>
/// Upload a CV PDF to the cv-matcher-api.
/// </summary>
/// <param name="request">The uploaded CV request.</param>
/// <param name="ct">Cancellation token.</param>
[HttpPost("upload")]
[RequestSizeLimit(8 * 1024 * 1024)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status502BadGateway)]
[ProducesResponseType(typeof(ErrorResponse), 499)]
[SwaggerOperation(Summary = "Upload CV", Description = "Proxy upload of a CV PDF to the internal cv-matcher-api.")]
[SwaggerResponse(StatusCodes.Status200OK, "Upload succeeded")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Missing or invalid input")]
[SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")]
public async Task<IActionResult> UploadCv(
[FromForm] UploadCvRequest request,
CancellationToken ct)
{
if (request.File is null)
{
return BadRequest(new ErrorResponse { Error = "Missing CV PDF.", Code = "cv_file_missing" });
}
var cv = request.File;
var gdprConsent = request.GdprConsent;
try
{
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var verdict = await _captcha.VerifyAsync(request.CaptchaToken ?? string.Empty, userIp, "cv_upload", ct);
if (!verdict.Success)
{
_logger.LogWarning("Captcha verification failed for CV upload. IP={IP}", userIp);
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
}
if (!gdprConsent) throw new InvalidOperationException("GDPR consent is required.");
_logger.LogInformation("Proxying CV upload to cv-matcher-api. FileName={FileName}, Size={SizeBytes}, GdprConsent={GdprConsent}",
cv.FileName, cv.Length, gdprConsent);
var stream = cv.OpenReadStream();
var part = new Refit.StreamPart(stream, cv.FileName, "application/pdf");
var res = await _cvApi.Upload(part, ct);
await CacheUploadedCvAsync(cv, res.DocumentId, ct);
return Ok(res);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogWarning("CV upload proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" });
}
catch (Exception ex)
{
_logger.LogError(ex, "CV upload proxy request failed.");
return StatusCode(StatusCodes.Status502BadGateway, new ErrorResponse { Error = "CV matcher API request failed.", Code = "upstream_request_failed" });
}
}
/// <summary>
/// Proxy a job matching request to the cv-matcher-api.
/// </summary>
/// <param name="request">Job match request payload containing CV document id or job description/url.</param>
/// <param name="ct">Cancellation token.</param>
[HttpPost("match-job")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status502BadGateway)]
[ProducesResponseType(typeof(ErrorResponse), 499)]
[SwaggerOperation(Summary = "Match job", Description = "Proxy job matching request to the internal cv-matcher-api.")]
[SwaggerResponse(StatusCodes.Status200OK, "Match succeeded")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid request")]
[SwaggerResponse(StatusCodes.Status502BadGateway, "Upstream cv-matcher-api error")]
public async Task<IActionResult> MatchJob([FromBody] JobMatchRequest request, CancellationToken ct)
{
try
{
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var verdict = await _captcha.VerifyAsync(request.CaptchaToken ?? string.Empty, userIp, "match_job", ct);
if (!verdict.Success)
{
_logger.LogWarning("Captcha verification failed for job match. IP={IP}", userIp);
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
}
_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));
var res = await _cvApi.MatchJob(request, ct);
var attachmentPath = TryGetCachedCvPath(request.CvDocumentId);
var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl)
? request.JobUrl
: "Manual job description";
await _emailSender.SendMatchAsync(
request.Email,
SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel),
SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel),
attachmentPath,
ct);
return Ok(res);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
_logger.LogWarning("Job match proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Job match proxy request failed.");
return StatusCode(StatusCodes.Status502BadGateway, new ErrorResponse { Error = "CV matcher API request failed.", Code = "upstream_request_failed" });
}
}
private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
{
try
{
var storagePath = GetFileStoragePath();
Directory.CreateDirectory(storagePath);
var targetPath = BuildCvPath(documentId);
await using var fileStream = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true);
await file.CopyToAsync(fileStream, ct);
_logger.LogInformation("Cached uploaded CV for email attachment. DocumentId={DocumentId}", documentId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not cache uploaded CV for attachment. DocumentId={DocumentId}", documentId);
}
}
private string? TryGetCachedCvPath(string? cvDocumentId)
{
if (string.IsNullOrWhiteSpace(cvDocumentId))
{
return null;
}
var path = BuildCvPath(cvDocumentId);
return System.IO.File.Exists(path) ? path : null;
}
private string BuildCvPath(string documentId)
{
var safeId = string.Concat(documentId.Where(char.IsLetterOrDigit));
if (string.IsNullOrWhiteSpace(safeId))
{
safeId = "cv";
}
return Path.Combine(GetFileStoragePath(), $"{safeId}.pdf");
}
private string GetFileStoragePath()
{
var fileStoragePath = _fileStorageSettings.Path;
if (!Path.IsPathRooted(fileStoragePath))
{
var solutionRoot = Directory.GetCurrentDirectory();
if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase))
{
solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot;
}
fileStoragePath = Path.Combine(solutionRoot, fileStoragePath);
}
return fileStoragePath;
}
}
@@ -0,0 +1,255 @@
using Models.Settings;
using Api.Services.Contracts;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses;
namespace Api.Controllers
{
/// <summary>
/// Controller for handling file downloads with support for resume and chunked transfers.
/// Routes are prefixed with "api/filedownload".
/// </summary>
[ApiController]
[Route("api/[controller]")]
[EnableCors("FrontendOnly")]
public sealed class FileDownloadController : ControllerBase
{
private readonly ILogger<FileDownloadController> _logger;
private readonly FileStorageSettings _fileStorageSettings;
private readonly IContentTypeProvider _contentTypeProvider;
private readonly IEmailSender _emailSender;
private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance
public FileDownloadController(
ILogger<FileDownloadController> logger,
IOptions<FileStorageSettings> fileStorageSettings,
IContentTypeProvider contentTypeProvider,
IEmailSender emailSender)
{
_logger = logger;
_fileStorageSettings = fileStorageSettings.Value;
_contentTypeProvider = contentTypeProvider;
_emailSender = emailSender;
}
/// <summary>
/// Downloads a file with support for resume (range requests) and chunked transfer.
/// Supports HTTP 206 Partial Content for efficient downloads and resume capability.
/// Sends email notification when download starts.
/// </summary>
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided)</param>
/// <returns>File stream with appropriate headers for resumable downloads</returns>
/// <response code="200">Full file content</response>
/// <response code="206">Partial file content (range request)</response>
/// <response code="404">File not found</response>
/// <response code="416">Requested range not satisfiable</response>
[HttpGet("{fileName?}")]
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")]
[SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")]
[SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "No file name provided and no default configured")]
[SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")]
[SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")]
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status206PartialContent)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> DownloadFile(string? fileName = null)
{
try
{
// Use default file name from settings if not provided
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = _fileStorageSettings.DefaultFileName;
if (string.IsNullOrWhiteSpace(fileName))
{
_logger.LogWarning("No file name provided and no default file name configured");
return BadRequest(new ErrorResponse { Error = "File name is required", Code = "file_name_required" });
}
_logger.LogInformation("Using default file name from settings: {FileName}", fileName);
}
// Get the file storage path (relative to solution folder)
var fileStoragePath = _fileStorageSettings.Path;
// If path is not absolute, make it relative to the solution root
if (!Path.IsPathRooted(fileStoragePath))
{
var solutionRoot = Directory.GetCurrentDirectory();
// Go up from api folder to solution root if needed
if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase))
{
solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot;
}
fileStoragePath = Path.Combine(solutionRoot, fileStoragePath);
}
// Sanitize fileName to prevent directory traversal attacks
var sanitizedFileName = Path.GetFileName(fileName);
var filePath = Path.Combine(fileStoragePath, sanitizedFileName);
// Verify file exists
if (!System.IO.File.Exists(filePath))
{
_logger.LogWarning("File not found: {FilePath}", filePath);
return NotFound(new ErrorResponse { Error = "File not found", Code = "file_not_found" });
}
var fileInfo = new FileInfo(filePath);
var fileLength = fileInfo.Length;
// Determine content type
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
{
contentType = "application/octet-stream";
}
// Send email notification asynchronously (fire and forget with error handling)
// This is done before streaming to ensure notification is sent for both full and range downloads
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
_ = Task.Run(async () =>
{
try
{
await _emailSender.SendFileDownloadNotificationAsync(sanitizedFileName, userIp, CancellationToken.None);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send file download notification for {FileName}", sanitizedFileName);
}
});
// Check if this is a range request
var rangeHeader = Request.Headers[HeaderNames.Range].ToString();
if (!string.IsNullOrEmpty(rangeHeader))
{
return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName);
}
// Full file download
_logger.LogInformation("Starting full file download: {FileName} ({FileSize} bytes)", sanitizedFileName, fileLength);
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString());
return File(stream, contentType, sanitizedFileName, enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error downloading file: {FileName}", fileName);
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "An error occurred while downloading the file", Code = "download_failed" });
}
}
/// <summary>
/// Handles HTTP range requests for partial content downloads and resume support.
/// </summary>
private async Task<IActionResult> HandleRangeRequest(
string filePath,
long fileLength,
string contentType,
string rangeHeader,
string fileName)
{
try
{
// Parse range header (format: "bytes=start-end")
var range = rangeHeader.Replace("bytes=", "").Split('-');
long startByte = 0;
long endByte = fileLength - 1;
if (!string.IsNullOrEmpty(range[0]))
{
startByte = long.Parse(range[0]);
}
if (range.Length > 1 && !string.IsNullOrEmpty(range[1]))
{
endByte = long.Parse(range[1]);
}
// Validate range
if (startByte > endByte || startByte >= fileLength)
{
_logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength);
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
}
// Adjust end byte if it exceeds file length
if (endByte >= fileLength)
{
endByte = fileLength - 1;
}
var contentLength = endByte - startByte + 1;
_logger.LogInformation(
"Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)",
fileName, startByte, endByte, fileLength, contentLength);
// Open file stream and seek to start position
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
stream.Seek(startByte, SeekOrigin.Begin);
// Set response headers for partial content
Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}");
Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString());
Response.ContentType = contentType;
// Stream the requested range
await StreamRangeAsync(stream, Response.Body, contentLength);
await stream.DisposeAsync();
return new EmptyResult();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing range request for file: {FileName}", fileName);
return StatusCode(StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Efficiently streams a specific byte range from source to destination.
/// </summary>
private static async Task StreamRangeAsync(Stream source, Stream destination, long bytesToRead)
{
var buffer = new byte[BufferSize];
long totalBytesRead = 0;
while (totalBytesRead < bytesToRead)
{
var bytesToReadThisIteration = (int)Math.Min(BufferSize, bytesToRead - totalBytesRead);
var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration));
if (bytesRead == 0)
{
break; // End of stream
}
await destination.WriteAsync(buffer.AsMemory(0, bytesRead));
totalBytesRead += bytesRead;
}
await destination.FlushAsync();
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using Models.Settings;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.Annotations;
namespace Api.Controllers
{
/// <summary>
/// Provides simple endpoints to expose Google related public keys used by
/// the frontend (for example Google Analytics tag id and Maps API key).
/// These endpoints return only public values safe to be exposed to clients.
/// </summary>
[ApiController]
[Route("api/[controller]")]
[EnableCors("FrontendOnly")]
public sealed class GoogleController : ControllerBase
{
private readonly GoogleSettings _googleSettings;
private readonly ILogger<GoogleController> _log;
public GoogleController(IOptions<GoogleSettings> options, ILogger<GoogleController> log)
{
_googleSettings = options.Value;
_log = log;
}
/// <summary>
/// Returns the Google Tag Manager ID used by the frontend for analytics and tracking.
/// </summary>
/// <returns>200 OK with the Tag Manager ID as a string.</returns>
[HttpGet("tagmanager")]
[SwaggerOperation(Summary = "Get Google Tag Manager ID", Description = "Returns the Google Tag Manager ID configured for frontend analytics.")]
[SwaggerResponse(StatusCodes.Status200OK, "Tag Manager ID returned")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> GetTagManagerId(CancellationToken ct)
{
return Ok(_googleSettings.TagManagerId);
}
/// <summary>
/// Returns the Google Maps API key used by the frontend to load the
/// Maps JavaScript API. This key is expected to be restricted and
/// safe to expose for client-side usage.
/// </summary>
/// <returns>200 OK with the maps API key as a string.</returns>
[HttpGet("maps")]
[SwaggerOperation(Summary = "Get Google Maps key", Description = "Returns the Google Maps API key configured for frontend map features.")]
[SwaggerResponse(StatusCodes.Status200OK, "Maps API key returned")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> GetMapKey(CancellationToken ct)
{
return Ok(_googleSettings.MapKey);
}
}
}
+82
View File
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Cors;
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]")]
// Enables only the "FrontendOnly" CORS policy so browser requests from the frontend are allowed.
[EnableCors("FrontendOnly")]
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": &lt;UTC time&gt; }.
/// </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" });
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using System.Reflection;
using Api.Services;
using Api.Services.Contracts;
using Models.Settings;
using Refit;
using Serilog;
using StartupHelpers;
StartupExtensions.LoadDotEnvFile();
const string ServiceName = "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.AddControllers();
builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google"));
builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact"));
builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe"));
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.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
.ConfigureHttpClient((sp, client) =>
{
var config = sp.GetRequiredService<IConfiguration>();
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(baseUrl))
{
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
}
var key = config["CvMatcherApi:InternalApiKey"];
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
{
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
}
});
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "API");
builder.Services.ConfigureCaddyForwardedHeaders();
builder.Services.AddFrontendCorsFromConfiguration(builder.Configuration);
builder.Services.AddPublicApiRateLimiting(builder.Configuration);
var app = builder.Build();
app.LogStartupDiagnostics(ServiceName);
app.UseForwardedHeaders();
app.UseDefaultSerilogRequestLogging(includeProxyHeaders: true);
app.UseSwaggerInDevelopment("API", "API");
app.UseHttpsRedirection();
app.UseAuthorization();
app.UseRouting();
app.UseCors("FrontendOnly");
app.UseRateLimiter();
app.MapControllers();
Log.Information("{Service} startup complete. Listening for requests...", ServiceName);
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName);
}
finally
{
Log.Information("Shutting down {Service}", ServiceName);
Log.CloseAndFlush();
}
+38
View File
@@ -0,0 +1,38 @@
{
"profiles": {
"api": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:55119;http://localhost:55121"
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Container (Dockerfile)": {
"commandName": "Docker",
"launchBrowser": true,
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTPS_PORTS": "8081",
"ASPNETCORE_HTTP_PORTS": "8080"
},
"publishAllPorts": true,
"useSSL": true
}
},
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:62186/",
"sslPort": 44392
}
}
}
@@ -0,0 +1,9 @@
using Api.Services.Contracts.Models;
namespace Api.Services.Contracts
{
public interface ICaptchaVerifier
{
Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct);
}
}
@@ -0,0 +1,12 @@
using Models.Requests;
namespace Api.Services.Contracts
{
public interface IEmailSender
{
Task SendContactAsync(ContactRequest req, CancellationToken ct);
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct);
Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct);
}
}
@@ -0,0 +1,4 @@
namespace Api.Services.Contracts.Models
{
public sealed record CaptchaVerdictModel(bool Success, string? Error, double? Score);
}
+106
View File
@@ -0,0 +1,106 @@
using Api.Services.Contracts.Models;
using Api.Services.Contracts;
using Microsoft.Extensions.Options;
using Models.Settings;
namespace Api.Services
{
public sealed class RecaptchaVerifier : ICaptchaVerifier
{
private readonly HttpClient _http;
private readonly CaptchaSettings _opt;
private readonly ILogger<RecaptchaVerifier> _log;
public RecaptchaVerifier(HttpClient http, IOptions<CaptchaSettings> options, ILogger<RecaptchaVerifier> log)
{
_http = http;
_opt = options.Value;
_log = log;
}
public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, 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 CaptchaVerdictModel(false, "Captcha not configured", null);
}
var form = new Dictionary<string, string>
{
["secret"] = _opt.SecretKey,
["response"] = token
};
if (!string.IsNullOrWhiteSpace(userIp))
form["remoteip"] = userIp;
using var resp = await _http.PostAsync(
"https://www.google.com/recaptcha/api/siteverify",
new FormUrlEncodedContent(form),
ct
);
if (!resp.IsSuccessStatusCode)
{
_log.LogWarning("Captcha HTTP request failed with status {StatusCode} for IP {Ip}",
(int)resp.StatusCode, userIp ?? "unknown");
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 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 CaptchaVerdictModel(false, "Captcha failed", data.score);
}
// v3 score check (score is typically null for v2)
if (data.score is double score && score < _opt.MinimumScore)
{
_log.LogWarning("Captcha score {Score} below minimum {MinScore} for IP {Ip}",
score, _opt.MinimumScore, userIp ?? "unknown");
return new CaptchaVerdictModel(false, "Captcha score too low", score);
}
// Optional strictness (usually v3): action/hostname checks
var actionToCheck = !string.IsNullOrWhiteSpace(expectedAction) ? expectedAction : _opt.ExpectedAction;
if (!string.IsNullOrWhiteSpace(actionToCheck) &&
!string.Equals(actionToCheck, data.action, StringComparison.Ordinal))
{
_log.LogWarning("Captcha action mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}",
actionToCheck, data.action, userIp ?? "unknown");
return new CaptchaVerdictModel(false, "Captcha action mismatch", data.score);
}
if (!string.IsNullOrWhiteSpace(_opt.ExpectedHostname) &&
!string.Equals(_opt.ExpectedHostname, data.hostname, StringComparison.OrdinalIgnoreCase))
{
_log.LogWarning("Captcha hostname mismatch. Expected={Expected}, Actual={Actual}, IP={Ip}",
_opt.ExpectedHostname, data.hostname, userIp ?? "unknown");
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 CaptchaVerdictModel(true, null, data.score);
}
private sealed class RecaptchaResponse
{
public bool success { get; set; }
public double? score { get; set; } // v3
public string? action { get; set; } // v3
public string? hostname { get; set; }
public DateTimeOffset? challenge_ts { get; set; }
}
}
}
+239
View File
@@ -0,0 +1,239 @@
using Api.Services.Contracts;
using Microsoft.Extensions.Options;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using Models.Settings;
using Models.Requests;
using CvMatcher.Models.Responses;
namespace Api.Services
{
public sealed class SmtpEmailSender : IEmailSender
{
private readonly SmtpSettings _smtp;
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly ILogger<SmtpEmailSender> _log;
private readonly string _environmentName;
public SmtpEmailSender(IOptions<SmtpSettings> smtp,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
ILogger<SmtpEmailSender> log)
{
_smtp = smtp.Value;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_log = log;
// Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development"
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
}
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since contact requests are important to process.
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
{
_log.LogDebug("Contact email skipped - ToEmail not configured");
throw new InvalidOperationException("Contact email recipient is not configured.");
}
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
req.Email, _contact.ToEmail);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_contact.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_contact.SubjectPrefix} [{_environmentName}] {req.Subject}".Trim();
var body =
$@"New contact form submission:
Name: {req.Name}
Email: {req.Email}
Subject: {req.Subject}
Message:
{req.Message}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "contact email", ct);
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
}
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since subscription requests are important to process.
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
{
_log.LogDebug("Subscription email skipped - ToEmail not configured");
throw new InvalidOperationException("Subscription email recipient is not configured.");
}
_log.LogInformation("Processing subscription request for {Email}", req.Email);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_subscribe.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_subscribe.SubjectPrefix} [{_environmentName}]".Trim();
var body =
$@"New subscription request:
Email: {req.Email}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "subscription email", ct);
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
}
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
{
// Skip sending if ToEmail is not configured
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
{
_log.LogDebug("File download notification skipped - ToEmail not configured");
return;
}
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_fileStorage.ToEmail));
msg.Subject = $"{_fileStorage.SubjectPrefix} [{_environmentName}] {fileName}".Trim();
var body =
$@"File download notification:
File: {fileName}
Downloaded at: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
IP Address: {userIp ?? "Unknown"}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "file download notification email", ct);
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
}
/// <summary>
/// Connects to the SMTP server and authenticates if credentials are configured.
/// </summary>
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
{
// If you're in enterprise environments, you may need to tweak certificate validation.
// Don't disable it casually.
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
_log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}",
_smtp.Host, _smtp.Port, tls);
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
if (!string.IsNullOrWhiteSpace(_smtp.Username))
{
_log.LogDebug("Authenticating with SMTP server as {Username}", _smtp.Username);
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
}
}
/// <summary>
/// Sends an email message using SMTP.
/// </summary>
/// <param name="message">The email message to send.</param>
/// <param name="messageType">Description of the message type for logging purposes.</param>
/// <param name="ct">Cancellation token.</param>
private async Task SendEmailAsync(MimeMessage message, string messageType, CancellationToken ct)
{
using var client = new SmtpClient();
await ConnectAndAuthenticateAsync(client, ct);
_log.LogDebug("Sending {MessageType} message", messageType);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
{
var recipients = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitTo))
{
recipients.Add(explicitTo);
}
if (!string.IsNullOrWhiteSpace(_contact.ToEmail) &&
!recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase)))
{
recipients.Add(_contact.ToEmail);
}
if (recipients.Count == 0)
{
_log.LogDebug("Match email skipped - no recipients configured (user email and Contact:ToEmail missing)");
return;
}
foreach (var recipient in recipients)
{
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(recipient));
msg.Subject = $"[{_environmentName}] {subject}".Trim();
var builder = new BodyBuilder
{
TextBody = body
};
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
{
builder.Attachments.Add(attachmentPath);
}
msg.Body = builder.ToMessageBody();
await SendEmailAsync(msg, "cv match email", ct);
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
}
}
public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel) => $@"CV Matcher result
CV Document ID: {cvDocumentId}
Job: {jobLabel ?? "N/A"}
Job URL: {result.JobUrl ?? "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)}";
public static string BuildMatchEmailSubject(int score, string? jobLabel)
=> $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}";
}
}
+135
View File
@@ -0,0 +1,135 @@
{
"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
},
"Google": {
"TagManagerId": "",
"MapKey": ""
},
"Contact": {
"ToEmail": "",
"FromEmail": "",
"SubjectPrefix": ""
},
"Subscribe": {
"ToEmail": "",
"SubjectPrefix": ""
},
"Smtp": {
"Host": "mail.example.com",
"Port": 587,
"Username": "",
"Password": "",
"UseStartTls": false
},
"Captcha": {
"Provider": "Recaptcha",
"SecretKey": "",
"PublicKey": "",
"MinimumScore": 0.5
},
"FileStorage": {
"Path": "Files",
"DefaultFileName": "",
"ToEmail": "",
"FromEmail": "",
"SubjectPrefix": "[File Download]"
},
"CvMatcherApi": {
"BaseUrl": "",
"InternalApiKey": ""
},
"RateLimiting": {
"Global": {
"PermitLimit": 120,
"Window": "00:01:00",
"QueueLimit": 0,
"AutoReplenishment": true
},
"Policies": {
"contact": {
"PermitLimit": 5,
"Window": "00:01:00",
"QueueLimit": 0,
"AutoReplenishment": true
},
"cvMatcher": {
"PermitLimit": 10,
"Window": "00:10:00",
"QueueLimit": 0,
"AutoReplenishment": true
}
}
}
}
@@ -0,0 +1,9 @@
namespace CvMatcher.Models.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 CvMatcher.Models.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 CvMatcher.Models.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 CvMatcher.Models.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 CvMatcher.Models.Responses
{
public sealed class FindJobsResponse
{
public required string CvDocumentId { get; init; }
public IReadOnlyList<JobMatchResponse> Jobs { get; init; } = [];
}
}
@@ -0,0 +1,15 @@
namespace CvMatcher.Models.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 CvMatcher.Models.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 CvMatcher.Models.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,9 @@
using Shared.Models.Settings;
namespace CvMatcher.Models.Settings;
public sealed class AiSettings : Shared.Models.Settings.AiSettings
{
public OpenAiSettings OpenAI { get; set; } = new();
public OllamaSettings Ollama { get; set; } = new();
}
@@ -0,0 +1,8 @@
namespace CvMatcher.Models.Settings;
public sealed class MatcherSettings
{
public int TopK { get; set; } = 10;
public int DeepScoreTopN { get; set; } = 5;
public int MaxJobTextChars { get; set; } = 60000;
}
@@ -0,0 +1,7 @@
namespace CvMatcher.Models.Settings;
public sealed class RagApiSettings
{
public string BaseUrl { get; set; } = "http://localhost:8081";
public string InternalApiKey { get; set; } = string.Empty;
}
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>CvMatcher.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\shared-models\shared-models.csproj" />
</ItemGroup>
</Project>
@@ -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": &lt;UTC time&gt; }.
/// </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);
}
}
@@ -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
}
}
}
+105
View File
@@ -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();
}
}
+110
View File
@@ -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
}
}
@@ -0,0 +1,10 @@
namespace Rag.Models
{
public sealed class DocumentClassification
{
public required string DocumentType { get; init; }
public double Confidence { get; init; }
public required string Title { get; init; }
public Dictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace Rag.Models
{
public sealed class RagChunkRecord
{
public required string Id { get; init; }
public required string DocumentId { get; init; }
public int ChunkIndex { get; init; }
public required string Text { get; init; }
public required float[] Embedding { get; init; }
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Rag.Models
{
public sealed class RagDocumentRecord
{
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 double TypeConfidence { get; init; }
public string MetadataJson { get; init; } = "{}";
public DateTimeOffset CreatedAt { get; init; }
}
}
@@ -0,0 +1,11 @@
namespace Rag.Models.Requests
{
public class IndexDocumentRequest
{
public string? Text { get; set; }
public string? SourceUrl { get; set; }
public string? DocumentType { get; set; }
public string? Title { get; set; }
public Dictionary<string, string>? Metadata { get; set; }
}
}
@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Http;
namespace Rag.Models.Requests
{
public sealed class IndexDocumentUploadRequest: IndexDocumentRequest
{
public IFormFile? File { get; set; }
}
}
@@ -0,0 +1,9 @@
namespace Rag.Models.Requests
{
public sealed class SearchRequest
{
public required string QueryText { get; init; }
public IReadOnlyList<string>? TargetDocumentTypes { get; init; }
public int? TopK { get; init; }
}
}
@@ -0,0 +1,14 @@
namespace Rag.Models.Responses
{
public sealed class IndexDocumentResponse
{
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,13 @@
namespace Rag.Models.Responses
{
public sealed class RagDocumentDetailsResponse
{
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 DateTimeOffset CreatedAt { get; init; }
}
}
@@ -0,0 +1,25 @@
namespace Rag.Models.Responses
{
public sealed class SearchResponse
{
public IReadOnlyList<SearchDocumentResult> Results { get; init; } = System.Array.Empty<SearchDocumentResult>();
}
public sealed class SearchDocumentResult
{
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<SearchChunkResult> MatchedChunks { get; init; } = System.Array.Empty<SearchChunkResult>();
}
public sealed class SearchChunkResult
{
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,9 @@
namespace Rag.Models
{
public sealed class SearchCandidateChunk
{
public required RagDocumentRecord Document { get; init; }
public required RagChunkRecord Chunk { get; init; }
public double Score { get; init; }
}
}
@@ -0,0 +1,8 @@
namespace Rag.Models.Settings;
public sealed class AiSettings
{
public string Provider { get; set; } = "OpenAI";
public OpenAiSettings OpenAI { get; set; } = new();
public OllamaSettings Ollama { get; set; } = new();
}
@@ -0,0 +1,6 @@
namespace Rag.Models.Settings;
public sealed class OllamaSettings : Shared.Models.Settings.OllamaSettings
{
public string EmbeddingModel { get; set; } = "nomic-embed-text";
}
@@ -0,0 +1,6 @@
namespace Rag.Models.Settings;
public sealed class OpenAiSettings: Shared.Models.Settings.OpenAiSettings
{
public string EmbeddingModel { get; set; } = "text-embedding-3-small";
}
@@ -0,0 +1,12 @@
namespace Rag.Models.Settings;
public sealed class RagSettings
{
public int MaxFileSizeMb { get; set; } = 8;
public int ChunkSize { get; set; } = 900;
public int ChunkOverlap { get; set; } = 150;
public int MaxTextChars { get; set; } = 60000;
public int DefaultTopK { get; set; } = 20;
public int MaxTopK { get; set; } = 50;
public bool ClassifyWithAi { get; set; } = false;
}
+14
View File
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Rag.Models</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\shared-models\shared-models.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,54 @@
using Microsoft.Extensions.Options;
using Rag.Models.Settings;
using Api.Data.Repositories.Contracts;
using Api.Clients.Ai.Contracts;
using CommonHelpers;
namespace Api.Clients.Ai;
public sealed class CachedRagAiClient : IAiClient
{
private readonly RagAiClient _client;
private readonly IRagRepository _repository;
private readonly AiSettings _settings;
public CachedRagAiClient(RagAiClient client, IRagRepository repository, IOptions<AiSettings> options)
{
_client = client;
_repository = repository;
_settings = options.Value;
}
public async Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct)
{
var model = GetEmbeddingModel();
var textHash = HashHelper.Compute(input);
var cacheKey = HashHelper.Compute($"embedding:{_settings.Provider}:{model}:{textHash}");
var cached = await _repository.GetEmbeddingAsync(cacheKey, ct);
if (cached is not null) return cached;
var vector = await _client.CreateEmbeddingAsync(input, ct);
await _repository.SaveEmbeddingAsync(cacheKey, model, textHash, vector, ct);
return vector;
}
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 GetEmbeddingModel() => string.Equals(_settings.Provider, "Ollama", StringComparison.OrdinalIgnoreCase)
? _settings.Ollama.EmbeddingModel
: _settings.OpenAI.EmbeddingModel;
private string GetChatModel() => string.Equals(_settings.Provider, "Ollama", StringComparison.OrdinalIgnoreCase)
? _settings.Ollama.ChatModel
: _settings.OpenAI.ChatModel;
}
@@ -0,0 +1,7 @@
namespace Api.Clients.Ai.Contracts;
public interface IAiClient
{
Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct);
Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct);
}
+116
View File
@@ -0,0 +1,116 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using Rag.Models.Settings;
using Api.Clients.Ai.Contracts;
namespace Api.Clients.Ai;
public sealed class RagAiClient : IAiClient
{
private readonly HttpClient _http;
private readonly AiSettings _settings;
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public RagAiClient(HttpClient http, IOptions<AiSettings> options)
{
_http = http;
_settings = options.Value;
}
public async Task<float[]> CreateEmbeddingAsync(string input, CancellationToken ct)
{
return IsOllama() ? await CreateOllamaEmbeddingAsync(input, ct) : await CreateOpenAiEmbeddingAsync(input, ct);
}
public async Task<string> CreateChatCompletionAsync(string systemPrompt, string userPrompt, decimal temperature, CancellationToken ct)
{
return IsOllama()
? await CreateOllamaChatCompletionAsync(systemPrompt, userPrompt, temperature, ct)
: await CreateOpenAiChatCompletionAsync(systemPrompt, userPrompt, temperature, ct);
}
private bool IsOllama() => string.Equals(_settings.Provider, "Ollama", StringComparison.OrdinalIgnoreCase);
private async Task<float[]> CreateOpenAiEmbeddingAsync(string input, 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/embeddings");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _settings.OpenAI.ApiKey);
request.Content = ToJson(new { model = _settings.OpenAI.EmbeddingModel, input });
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 embeddings failed: {(int)response.StatusCode} {json}");
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("data")[0].GetProperty("embedding").EnumerateArray().Select(x => x.GetSingle()).ToArray();
}
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<float[]> CreateOllamaEmbeddingAsync(string input, 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/embeddings", ToJson(new { model = _settings.Ollama.EmbeddingModel, prompt = input }), cts.Token);
var json = await response.Content.ReadAsStringAsync(cts.Token);
if (!response.IsSuccessStatusCode) throw new InvalidOperationException($"Ollama embeddings failed: {(int)response.StatusCode} {json}");
using var doc = JsonDocument.Parse(json);
return doc.RootElement.GetProperty("embedding").EnumerateArray().Select(x => x.GetSingle()).ToArray();
}
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,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": &lt;UTC time&gt; }.
/// </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" });
}
}
}
+129
View File
@@ -0,0 +1,129 @@
using Microsoft.AspNetCore.Mvc;
using Api.Services.Contracts;
using Rag.Models.Requests;
using Rag.Models.Responses;
using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses;
namespace Api.Controllers;
[ApiController]
[Route("api/rag")]
public sealed class RagController : ControllerBase
{
private readonly IRagService _ragService;
private readonly ILogger<RagController> _logger;
public RagController(IRagService ragService, ILogger<RagController> logger)
{
_ragService = ragService;
_logger = logger;
}
[HttpPost("documents")]
[RequestSizeLimit(10 * 1024 * 1024)]
[SwaggerOperation(Summary = "Index document (multipart)", Description = "Indexes a PDF file or raw text document using multipart/form-data payload.")]
[SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid indexing request")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IndexDocumentResponse>> IndexDocument(
[FromForm] IndexDocumentUploadRequest request,
CancellationToken ct)
{
try
{
_logger.LogInformation("Index document request received. HasFile={HasFile}, DocumentType={DocumentType}, Title={Title}, SourceUrl={SourceUrl}",
request.File is not null, request.DocumentType, request.Title, request.SourceUrl);
if (request.File is not null)
{
var result = await _ragService.IndexPdfAsync(request.File, request.DocumentType, request.Title, request.SourceUrl, ct);
_logger.LogInformation("Indexed PDF document. DocumentId={DocumentId}, DocumentType={DocumentType}, Chunks={Chunks}, Cached={Cached}",
result.DocumentId, result.DocumentType, result.Chunks, result.Cached);
return Ok(result);
}
var textResult = await _ragService.IndexTextAsync(new IndexDocumentRequest
{
Text = request.Text,
DocumentType = request.DocumentType,
Title = request.Title,
SourceUrl = request.SourceUrl
}, ct);
_logger.LogInformation("Indexed text document. DocumentId={DocumentId}, DocumentType={DocumentType}, Chunks={Chunks}, Cached={Cached}",
textResult.DocumentId, textResult.DocumentType, textResult.Chunks, textResult.Cached);
return Ok(textResult);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Invalid document indexing request.");
return BadRequest(new ErrorResponse { Error = ex.Message, Code = "invalid_request" });
}
}
[HttpPost("documents/json")]
[SwaggerOperation(Summary = "Index document (JSON)", Description = "Indexes a text document sent as JSON.")]
[SwaggerResponse(StatusCodes.Status200OK, "JSON document indexed successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid JSON indexing request")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<IndexDocumentResponse>> IndexJsonDocument([FromBody] IndexDocumentRequest request, CancellationToken ct)
{
try
{
_logger.LogInformation("JSON document indexing request received. DocumentType={DocumentType}, Title={Title}, SourceUrl={SourceUrl}",
request.DocumentType, request.Title, request.SourceUrl);
var result = await _ragService.IndexTextAsync(request, ct);
_logger.LogInformation("Indexed JSON document. DocumentId={DocumentId}, DocumentType={DocumentType}, Chunks={Chunks}, Cached={Cached}",
result.DocumentId, result.DocumentType, result.Chunks, result.Cached);
return Ok(result);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Invalid JSON document indexing request.");
return BadRequest(new ErrorResponse { Error = ex.Message, Code = "invalid_request" });
}
}
[HttpPost("search")]
[SwaggerOperation(Summary = "Semantic search", Description = "Performs semantic retrieval over indexed documents.")]
[SwaggerResponse(StatusCodes.Status200OK, "Search results returned")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid search request")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<SearchResponse>> Search([FromBody] SearchRequest request, CancellationToken ct)
{
try
{
_logger.LogInformation("Semantic search request received. TargetTypes={TargetTypes}, TopK={TopK}",
string.Join(',', request.TargetDocumentTypes ?? System.Array.Empty<string>()), request.TopK);
var result = await _ragService.SearchAsync(request, ct);
_logger.LogInformation("Semantic search completed. ResultCount={ResultCount}", result.Results.Count);
return Ok(result);
}
catch (InvalidOperationException ex)
{
_logger.LogWarning(ex, "Invalid semantic search request.");
return BadRequest(new ErrorResponse { Error = ex.Message, Code = "invalid_request" });
}
}
[HttpGet("documents/{id}")]
[SwaggerOperation(Summary = "Get document details", Description = "Returns indexed document details for the provided document id.")]
[SwaggerResponse(StatusCodes.Status200OK, "Document details returned")]
[SwaggerResponse(StatusCodes.Status404NotFound, "Document was not found")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
public async Task<ActionResult<RagDocumentDetailsResponse>> GetDocument(string id, CancellationToken ct)
{
_logger.LogInformation("Get document request received. DocumentId={DocumentId}", id);
var document = await _ragService.GetDocumentAsync(id, ct);
if (document is null)
{
_logger.LogWarning("Document not found. DocumentId={DocumentId}", id);
return NotFound(new ErrorResponse { Error = "Document not found.", Code = "document_not_found" });
}
return Ok(document);
}
}
@@ -0,0 +1,10 @@
namespace Api.Data.Entities;
public sealed class RagChatCompletionCacheEntity
{
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 @@
namespace Api.Data.Entities;
public sealed class RagChunkEntity
{
public string Id { get; set; } = string.Empty;
public string DocumentId { get; set; } = string.Empty;
public int ChunkIndex { get; set; }
public string Text { get; set; } = string.Empty;
public byte[] Embedding { get; set; } = [];
public RagDocumentEntity? Document { get; set; }
}
@@ -0,0 +1,16 @@
namespace Api.Data.Entities;
public sealed class RagDocumentEntity
{
public string Id { get; set; } = string.Empty;
public string DocumentType { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? SourceUrl { get; set; }
public string RawText { get; set; } = string.Empty;
public string TextHash { get; set; } = string.Empty;
public double TypeConfidence { get; set; }
public string MetadataJson { get; set; } = "{}";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public ICollection<RagChunkEntity> Chunks { get; set; } = [];
}
@@ -0,0 +1,10 @@
namespace Api.Data.Entities;
public sealed class RagEmbeddingCacheEntity
{
public string CacheKey { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public string TextHash { get; set; } = string.Empty;
public byte[] Vector { get; set; } = [];
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+77
View File
@@ -0,0 +1,77 @@
using Api.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace Api.Data;
public sealed class RagDbContext : DbContext
{
public const string SchemaName = "rag";
public const string MigrationTableName = "_Migrations";
public RagDbContext(DbContextOptions<RagDbContext> options) : base(options)
{
}
public DbSet<RagDocumentEntity> RagDocuments => Set<RagDocumentEntity>();
public DbSet<RagChunkEntity> RagChunks => Set<RagChunkEntity>();
public DbSet<RagEmbeddingCacheEntity> RagEmbeddingCache => Set<RagEmbeddingCacheEntity>();
public DbSet<RagChatCompletionCacheEntity> RagChatCompletionCache => Set<RagChatCompletionCacheEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema(SchemaName);
modelBuilder.Entity<RagDocumentEntity>(entity =>
{
entity.ToTable("Documents");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).HasMaxLength(64);
entity.Property(x => x.DocumentType).HasMaxLength(80).IsRequired();
entity.Property(x => x.Title).HasMaxLength(300).IsRequired();
entity.Property(x => x.SourceUrl).HasMaxLength(1200);
entity.Property(x => x.RawText).IsRequired();
entity.Property(x => x.TextHash).HasMaxLength(64).IsRequired();
entity.Property(x => x.MetadataJson).HasDefaultValue("{}").IsRequired();
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.TextHash);
entity.HasIndex(x => x.DocumentType);
});
modelBuilder.Entity<RagChunkEntity>(entity =>
{
entity.ToTable("Chunks");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).HasMaxLength(64);
entity.Property(x => x.DocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.Text).IsRequired();
entity.Property(x => x.Embedding).IsRequired();
entity.HasOne(x => x.Document)
.WithMany(x => x.Chunks)
.HasForeignKey(x => x.DocumentId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RagEmbeddingCacheEntity>(entity =>
{
entity.ToTable("EmbeddingCache");
entity.HasKey(x => x.CacheKey);
entity.Property(x => x.CacheKey).HasMaxLength(64);
entity.Property(x => x.Model).HasMaxLength(120).IsRequired();
entity.Property(x => x.TextHash).HasMaxLength(64).IsRequired();
entity.Property(x => x.Vector).IsRequired();
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.TextHash);
});
modelBuilder.Entity<RagChatCompletionCacheEntity>(entity =>
{
entity.ToTable("ChatCompletionCache");
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,16 @@
using Rag.Models;
namespace Api.Data.Repositories.Contracts;
public interface IRagRepository
{
Task InitializeAsync(CancellationToken ct);
Task<RagDocumentRecord?> GetDocumentByTextHashAsync(string textHash, string? sourceUrl, CancellationToken ct);
Task<RagDocumentRecord?> GetDocumentByIdAsync(string id, CancellationToken ct);
Task SaveDocumentAsync(RagDocumentRecord document, IReadOnlyList<RagChunkRecord> chunks, CancellationToken ct);
Task<IReadOnlyList<SearchCandidateChunk>> SearchChunksAsync(float[] queryEmbedding, IReadOnlyList<string>? targetTypes, int topK, CancellationToken ct);
Task<float[]?> GetEmbeddingAsync(string cacheKey, CancellationToken ct);
Task SaveEmbeddingAsync(string cacheKey, string model, string textHash, float[] vector, CancellationToken ct);
Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct);
Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct);
}
@@ -0,0 +1,195 @@
using Api.Data;
using Api.Data.Entities;
using Microsoft.EntityFrameworkCore;
using Api.Data.Repositories.Contracts;
using Rag.Models;
namespace Api.Data.Repositories;
public sealed class EfRagRepository : IRagRepository
{
private readonly RagDbContext _db;
private readonly ILogger<EfRagRepository> _logger;
public EfRagRepository(RagDbContext db, ILogger<EfRagRepository> logger)
{
_db = db;
_logger = logger;
}
public async Task InitializeAsync(CancellationToken ct)
{
_logger.LogInformation("Ensuring RAG database schema exists using EF Core");
//await _db.Database.EnsureCreatedAsync(ct);
}
public async Task<RagDocumentRecord?> GetDocumentByTextHashAsync(string textHash, string? sourceUrl, CancellationToken ct)
{
var query = _db.RagDocuments
.AsNoTracking()
.Where(x => x.TextHash == textHash);
if (!string.IsNullOrWhiteSpace(sourceUrl))
{
query = query.Where(x => x.SourceUrl == sourceUrl);
}
var entity = await query
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
return entity is null ? null : ToRecord(entity);
}
public async Task<RagDocumentRecord?> GetDocumentByIdAsync(string id, CancellationToken ct)
{
var entity = await _db.RagDocuments
.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id, ct);
return entity is null ? null : ToRecord(entity);
}
public async Task SaveDocumentAsync(RagDocumentRecord document, IReadOnlyList<RagChunkRecord> chunks, CancellationToken ct)
{
var exists = await _db.RagDocuments.AnyAsync(x => x.Id == document.Id, ct);
if (exists)
{
_logger.LogInformation("RAG document already exists. DocumentId={DocumentId}", document.Id);
return;
}
var entity = new RagDocumentEntity
{
Id = document.Id,
DocumentType = document.DocumentType,
Title = document.Title,
SourceUrl = document.SourceUrl,
RawText = document.Text,
TextHash = document.TextHash,
TypeConfidence = document.TypeConfidence,
MetadataJson = document.MetadataJson,
CreatedAt = document.CreatedAt.UtcDateTime,
Chunks = chunks.Select(chunk => new RagChunkEntity
{
Id = chunk.Id,
DocumentId = chunk.DocumentId,
ChunkIndex = chunk.ChunkIndex,
Text = chunk.Text,
Embedding = VectorSerializer.ToBytes(chunk.Embedding)
}).ToList()
};
_db.RagDocuments.Add(entity);
await _db.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<SearchCandidateChunk>> SearchChunksAsync(
float[] queryEmbedding,
IReadOnlyList<string>? targetTypes,
int topK,
CancellationToken ct)
{
var types = targetTypes?
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim().ToLowerInvariant())
.Distinct()
.ToArray() ?? System.Array.Empty<string>();
var query = _db.RagChunks
.AsNoTracking()
.Include(x => x.Document)
.AsQueryable();
if (types.Length > 0)
{
query = query.Where(x => x.Document != null && types.Contains(x.Document.DocumentType.ToLower()));
}
var rows = await query.ToListAsync(ct);
return rows
.Where(x => x.Document is not null)
.Select(x => new SearchCandidateChunk
{
Document = ToRecord(x.Document!),
Chunk = new RagChunkRecord
{
Id = x.Id,
DocumentId = x.DocumentId,
ChunkIndex = x.ChunkIndex,
Text = x.Text,
Embedding = VectorSerializer.FromBytes(x.Embedding)
},
Score = VectorSerializer.CosineSimilarity(queryEmbedding, VectorSerializer.FromBytes(x.Embedding))
})
.OrderByDescending(x => x.Score)
.Take(Math.Max(topK * 4, topK))
.ToList();
}
public async Task<float[]?> GetEmbeddingAsync(string cacheKey, CancellationToken ct)
{
var entry = await _db.RagEmbeddingCache
.AsNoTracking()
.FirstOrDefaultAsync(x => x.CacheKey == cacheKey, ct);
return entry is null ? null : VectorSerializer.FromBytes(entry.Vector);
}
public async Task SaveEmbeddingAsync(string cacheKey, string model, string textHash, float[] vector, CancellationToken ct)
{
var exists = await _db.RagEmbeddingCache.AnyAsync(x => x.CacheKey == cacheKey, ct);
if (exists) return;
_db.RagEmbeddingCache.Add(new RagEmbeddingCacheEntity
{
CacheKey = cacheKey,
Model = model,
TextHash = textHash,
Vector = VectorSerializer.ToBytes(vector),
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync(ct);
}
public async Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct)
{
return await _db.RagChatCompletionCache
.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.RagChatCompletionCache.AnyAsync(x => x.CacheKey == cacheKey, ct);
if (exists) return;
_db.RagChatCompletionCache.Add(new RagChatCompletionCacheEntity
{
CacheKey = cacheKey,
Model = model,
Temperature = temperature,
ResponseText = responseText,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync(ct);
}
private static RagDocumentRecord ToRecord(RagDocumentEntity entity) => new()
{
Id = entity.Id,
DocumentType = entity.DocumentType,
Title = entity.Title,
SourceUrl = entity.SourceUrl,
Text = entity.RawText,
TextHash = entity.TextHash,
TypeConfidence = entity.TypeConfidence,
MetadataJson = entity.MetadataJson,
CreatedAt = new DateTimeOffset(DateTime.SpecifyKind(entity.CreatedAt, DateTimeKind.Utc))
};
}
@@ -0,0 +1,31 @@
namespace Api.Data.Repositories;
public static class VectorSerializer
{
public static byte[] ToBytes(float[] vector)
{
var bytes = new byte[vector.Length * sizeof(float)];
Buffer.BlockCopy(vector, 0, bytes, 0, bytes.Length);
return bytes;
}
public static float[] FromBytes(byte[] bytes)
{
var vector = new float[bytes.Length / sizeof(float)];
Buffer.BlockCopy(bytes, 0, vector, 0, bytes.Length);
return vector;
}
public static double CosineSimilarity(float[] a, float[] b)
{
if (a.Length == 0 || a.Length != b.Length) return 0;
double dot = 0, magA = 0, 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];
}
return magA == 0 || magB == 0 ? 0 : dot / (Math.Sqrt(magA) * Math.Sqrt(magB));
}
}
@@ -0,0 +1,188 @@
// <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(RagDbContext))]
[Migration("20260507140305_InitialRagSchema")]
partial class InitialRagSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("rag")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Api.Data.Entities.RagChatCompletionCacheEntity", 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("ChatCompletionCache", "rag");
});
modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("ChunkIndex")
.HasColumnType("int");
b.Property<string>("DocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("Embedding")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("DocumentId");
b.ToTable("Chunks", "rag");
});
modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("DocumentType")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("MetadataJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("nvarchar(max)")
.HasDefaultValue("{}");
b.Property<string>("RawText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("SourceUrl")
.HasMaxLength(1200)
.HasColumnType("nvarchar(1200)");
b.Property<string>("TextHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<double>("TypeConfidence")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("DocumentType");
b.HasIndex("TextHash");
b.ToTable("Documents", "rag");
});
modelBuilder.Entity("Api.Data.Entities.RagEmbeddingCacheEntity", 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>("TextHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("Vector")
.IsRequired()
.HasColumnType("varbinary(max)");
b.HasKey("CacheKey");
b.HasIndex("TextHash");
b.ToTable("EmbeddingCache", "rag");
});
modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b =>
{
b.HasOne("Api.Data.Entities.RagDocumentEntity", "Document")
.WithMany("Chunks")
.HasForeignKey("DocumentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Document");
});
modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b =>
{
b.Navigation("Chunks");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,137 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Api.Migrations
{
/// <inheritdoc />
public partial class InitialRagSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "rag");
migrationBuilder.CreateTable(
name: "ChatCompletionCache",
schema: "rag",
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_ChatCompletionCache", x => x.CacheKey);
});
migrationBuilder.CreateTable(
name: "Documents",
schema: "rag",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DocumentType = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Title = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
SourceUrl = table.Column<string>(type: "nvarchar(1200)", maxLength: 1200, nullable: true),
RawText = table.Column<string>(type: "nvarchar(max)", nullable: false),
TextHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
TypeConfidence = table.Column<double>(type: "float", nullable: false),
MetadataJson = table.Column<string>(type: "nvarchar(max)", nullable: false, defaultValue: "{}"),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_Documents", x => x.Id);
});
migrationBuilder.CreateTable(
name: "EmbeddingCache",
schema: "rag",
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),
TextHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Vector = table.Column<byte[]>(type: "varbinary(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_EmbeddingCache", x => x.CacheKey);
});
migrationBuilder.CreateTable(
name: "Chunks",
schema: "rag",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
DocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ChunkIndex = table.Column<int>(type: "int", nullable: false),
Text = table.Column<string>(type: "nvarchar(max)", nullable: false),
Embedding = table.Column<byte[]>(type: "varbinary(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chunks", x => x.Id);
table.ForeignKey(
name: "FK_Chunks_Documents_DocumentId",
column: x => x.DocumentId,
principalSchema: "rag",
principalTable: "Documents",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Chunks_DocumentId",
schema: "rag",
table: "Chunks",
column: "DocumentId");
migrationBuilder.CreateIndex(
name: "IX_Documents_DocumentType",
schema: "rag",
table: "Documents",
column: "DocumentType");
migrationBuilder.CreateIndex(
name: "IX_Documents_TextHash",
schema: "rag",
table: "Documents",
column: "TextHash");
migrationBuilder.CreateIndex(
name: "IX_EmbeddingCache_TextHash",
schema: "rag",
table: "EmbeddingCache",
column: "TextHash");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChatCompletionCache",
schema: "rag");
migrationBuilder.DropTable(
name: "Chunks",
schema: "rag");
migrationBuilder.DropTable(
name: "EmbeddingCache",
schema: "rag");
migrationBuilder.DropTable(
name: "Documents",
schema: "rag");
}
}
}
@@ -0,0 +1,185 @@
// <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(RagDbContext))]
partial class RagDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("rag")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Api.Data.Entities.RagChatCompletionCacheEntity", 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("ChatCompletionCache", "rag");
});
modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("ChunkIndex")
.HasColumnType("int");
b.Property<string>("DocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("Embedding")
.IsRequired()
.HasColumnType("varbinary(max)");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("DocumentId");
b.ToTable("Chunks", "rag");
});
modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("DocumentType")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("MetadataJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("nvarchar(max)")
.HasDefaultValue("{}");
b.Property<string>("RawText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("SourceUrl")
.HasMaxLength(1200)
.HasColumnType("nvarchar(1200)");
b.Property<string>("TextHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<double>("TypeConfidence")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("DocumentType");
b.HasIndex("TextHash");
b.ToTable("Documents", "rag");
});
modelBuilder.Entity("Api.Data.Entities.RagEmbeddingCacheEntity", 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>("TextHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<byte[]>("Vector")
.IsRequired()
.HasColumnType("varbinary(max)");
b.HasKey("CacheKey");
b.HasIndex("TextHash");
b.ToTable("EmbeddingCache", "rag");
});
modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b =>
{
b.HasOne("Api.Data.Entities.RagDocumentEntity", "Document")
.WithMany("Chunks")
.HasForeignKey("DocumentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Document");
});
modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b =>
{
b.Navigation("Chunks");
});
#pragma warning restore 612, 618
}
}
}
+93
View File
@@ -0,0 +1,93 @@
using System.Reflection;
using Api.Clients.Ai;
using Api.Clients.Ai.Contracts;
using Api.Data;
using Api.Data.Repositories;
using Api.Data.Repositories.Contracts;
using Api.Services;
using Api.Services.Contracts;
using Microsoft.EntityFrameworkCore;
using Rag.Models.Settings;
using Serilog;
using Shared.Models.Settings;
using StartupHelpers;
StartupExtensions.LoadDotEnvFile();
const string ServiceName = "rag-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<RagSettings>(builder.Configuration.GetSection("Rag"));
builder.Services.Configure<Rag.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
builder.Services.Configure<InternalApiSettings>(builder.Configuration.GetSection("InternalApi"));
builder.Services.AddDbContext<RagDbContext>(options =>
{
var configuration = builder.Configuration;
var connectionString = builder.Services.GetConfiguredDbConnectionString(configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsHistoryTable(RagDbContext.MigrationTableName, RagDbContext.SchemaName);
});
});
builder.Services.AddHttpClient<RagAiClient>();
builder.Services.AddScoped<IRagRepository, EfRagRepository>();
builder.Services.AddHttpClient<RagAiClient>();
builder.Services.AddScoped<IRagRepository, EfRagRepository>();
builder.Services.AddScoped<IAiClient, CachedRagAiClient>();
builder.Services.AddSingleton<ITextExtractor, TextExtractor>();
builder.Services.AddSingleton<ITextChunker, TextChunker>();
builder.Services.AddSingleton<IDocumentClassifier, DocumentClassifier>();
builder.Services.AddScoped<IRagService, RagService>();
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<IRagRepository>();
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<RagDbContext>();
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": {
"rag-api": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:58424;http://localhost:58426"
}
}
}
@@ -0,0 +1,8 @@
using Rag.Models;
namespace Api.Services.Contracts;
public interface IDocumentClassifier
{
Task<DocumentClassification> ClassifyAsync(string text, string? providedType, string? providedTitle, CancellationToken ct);
}
@@ -0,0 +1,12 @@
using Rag.Models.Requests;
using Rag.Models.Responses;
namespace Api.Services.Contracts;
public interface IRagService
{
Task<IndexDocumentResponse> IndexTextAsync(IndexDocumentRequest request, CancellationToken ct);
Task<IndexDocumentResponse> IndexPdfAsync(IFormFile file, string? documentType, string? title, string? sourceUrl, CancellationToken ct);
Task<SearchResponse> SearchAsync(SearchRequest request, CancellationToken ct);
Task<RagDocumentDetailsResponse?> GetDocumentAsync(string documentId, CancellationToken ct);
}
@@ -0,0 +1,6 @@
namespace Api.Services.Contracts;
public interface ITextChunker
{
IReadOnlyList<string> Chunk(string text, int chunkSize, int overlap);
}
@@ -0,0 +1,7 @@
namespace Api.Services.Contracts;
public interface ITextExtractor
{
Task<string> ExtractPdfAsync(Stream stream, CancellationToken ct);
string Normalize(string value);
}

Some files were not shown because too many files have changed in this diff Show More