diff --git a/Apis/api/Controllers/ContactController.cs b/Apis/api/Controllers/ContactController.cs index b95e2ea..122d772 100644 --- a/Apis/api/Controllers/ContactController.cs +++ b/Apis/api/Controllers/ContactController.cs @@ -115,7 +115,7 @@ namespace Api.Controllers 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" }); + return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Could not process subscription.", Code = "subscription_failed" }); } } diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index f12a5fb..e28223e 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -44,10 +44,6 @@ namespace Api.Controllers /// /// The name of the file to download (optional - uses default from settings if not provided) /// File stream with appropriate headers for resumable downloads - /// Full file content - /// Partial file content (range request) - /// File not found - /// Requested range not satisfiable [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")] @@ -135,9 +131,7 @@ namespace Api.Controllers } } - /// - /// Handles HTTP range requests for partial content downloads and resume support. - /// + // Handles HTTP range requests for partial content downloads and resume support. private async Task HandleRangeRequest( string filePath, long fileLength, @@ -194,9 +188,7 @@ namespace Api.Controllers } } - /// - /// Efficiently streams a specific byte range from source to destination. - /// + // Efficiently streams a specific byte range from source to destination. private static async Task StreamRangeAsync(Stream source, Stream destination, long bytesToRead) { var buffer = new byte[BufferSize]; diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 023de6d..1dd361d 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -135,13 +135,6 @@ public sealed class CvMatcherService : ICvMatcherService result.JobUrl = job.SourceUrl; result.Cached = false; await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct); - - //await _email.SendMatchAsync( - // email, - // $"MyAi.ro CV Match: {result.Score}% - {job.Title}", - // BuildEmailBody(cv, job, result), - // ct); - return result; } @@ -188,25 +181,4 @@ public sealed class CvMatcherService : ICvMatcherService }; 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)} - // """; } diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 8b1f2d8..bf4036e 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -92,6 +92,7 @@ public sealed class JobTokenService : IJobTokenService .Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries) .Select(l => l.Trim()) .Where(l => l.Length > 5 && l.Length < 200) + // Skip lines that are purely digits, spaces, and phone/contact punctuation (phone numbers, emails, etc.) .Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$")) .Take(5) .ToList(); diff --git a/Apis/email-api/Controllers/EmailController.cs b/Apis/email-api/Controllers/EmailController.cs index e7e628f..6cafe16 100644 --- a/Apis/email-api/Controllers/EmailController.cs +++ b/Apis/email-api/Controllers/EmailController.cs @@ -5,6 +5,11 @@ using Swashbuckle.AspNetCore.Annotations; namespace EmailApi.Controllers; +/// +/// Internal email relay. Accepts an HTML body fragment from trusted callers +/// (api, cv-search-job), wraps it in the branded HTML shell, and dispatches +/// via SMTP. Protected by X-Internal-Api-Key. +/// [ApiController] [Route("api/email")] public sealed class EmailController : ControllerBase @@ -13,9 +18,27 @@ public sealed class EmailController : ControllerBase public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher; + /// + /// Sends an HTML email via SMTP. The supplied body fragment is wrapped in + /// the branded HTML shell before dispatch. Attachments are resolved from + /// the shared file storage volume using the relative path in + /// . + /// + /// Email payload: recipients, subject, HTML body fragment, optional attachment path. + /// Cancellation token. + /// 204 No Content on success. [HttpPost("send")] - [SwaggerOperation(Summary = "Send an HTML email via SMTP")] + [SwaggerOperation( + Summary = "Send an HTML email via SMTP", + Description = "Wraps the provided HTML body in the branded shell and sends via SMTP. " + + "If AttachmentPath is set, resolves the file from the shared file-storage volume. " + + "Returns 204 on success; 400 when the request body is invalid; 500 on SMTP failure.")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Email dispatched successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request body is missing or invalid")] + [SwaggerResponse(StatusCodes.Status500InternalServerError, "SMTP dispatch failed")] [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task Send([FromBody] SendEmailRequest request, CancellationToken ct) { await _dispatcher.SendAsync(request, ct); diff --git a/Apis/rag-api/Services/DocumentClassifier.cs b/Apis/rag-api/Services/DocumentClassifier.cs index ae64279..28c8b8c 100644 --- a/Apis/rag-api/Services/DocumentClassifier.cs +++ b/Apis/rag-api/Services/DocumentClassifier.cs @@ -24,6 +24,8 @@ public sealed class DocumentClassifier : IDocumentClassifier }); } + // Keyword-frequency heuristic: count how many characteristic terms each document + // type contributes to the text, then pick the type with the highest hit count. var lower = text.ToLowerInvariant(); var scores = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -37,6 +39,8 @@ public sealed class DocumentClassifier : IDocumentClassifier var best = scores.OrderByDescending(x => x.Value).First(); var type = best.Value <= 0 ? "unknown" : best.Key; + // Confidence baseline 0.45 + 0.08 per matched keyword term, capped at 0.95. + // Zero hits → 0.25 (effectively unknown). var confidence = best.Value <= 0 ? 0.25 : Math.Min(0.95, 0.45 + best.Value * 0.08); return Task.FromResult(new DocumentClassification diff --git a/Apis/rag-api/Services/TextChunker.cs b/Apis/rag-api/Services/TextChunker.cs index 434f2b9..0b011fb 100644 --- a/Apis/rag-api/Services/TextChunker.cs +++ b/Apis/rag-api/Services/TextChunker.cs @@ -10,6 +10,8 @@ public sealed class TextChunker : ITextChunker chunkSize = Math.Clamp(chunkSize, 300, 3000); overlap = Math.Clamp(overlap, 0, chunkSize / 2); + // Sliding window: step forward by (chunkSize - overlap) each iteration so + // adjacent chunks share `overlap` characters, preserving cross-boundary context. var chunks = new List(); var start = 0; while (start < text.Length) diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs index fe03132..7fba235 100644 --- a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -75,6 +75,7 @@ public sealed class HtmlJobSearcher continue; } + // Strip query string and fragment so different tracking variants of the same URL collapse to one. var url = absoluteUri.GetLeftPart(UriPartial.Path); if (seen.Add(url)) results.Add(url); diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 593baf7..16b0087 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -125,6 +125,7 @@ public sealed class CvSearchJobTask : IJobTask { CvDocumentId = session.CvDocumentId, JobUrl = url, + // User already gave GDPR consent when they clicked the one-time job search link GdprConsent = true }; @@ -191,6 +192,7 @@ public sealed class CvSearchJobTask : IJobTask private static string BuildCvFileName(string cvDocumentId) { + // Strip non-alphanumeric characters so the filename is safe for all OS/email clients. var safeId = string.Concat(cvDocumentId.Where(char.IsLetterOrDigit)); if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv"; return $"{safeId}.pdf";