Improve comments and Swagger annotations across services (#26)
- EmailController: add class summary, full SwaggerResponse/ProducesResponseType for 400 and 500, and Description on SwaggerOperation - ContactController: fix terse "Failed." error message to "Could not process subscription." - FileDownloadController: remove redundant XML <response code> tags from the public action doc block; convert private-method /// <summary> to // (project convention: no XML doc on internal code) - CvMatcherService: remove two dead commented-out blocks (old email send and BuildEmailBody helper) - JobTokenService: comment the phone/contact-line regex filter in ExtractKeywords - DocumentClassifier: comment the keyword-frequency scoring approach and the confidence formula - TextChunker: comment the sliding-window step (chunkSize - overlap) - CvSearchJobTask: comment the GdprConsent = true rationale and the BuildCvFileName sanitisation logic - HtmlJobSearcher: comment GetLeftPart(UriPartial.Path) query-strip dedup Closes #26 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -115,7 +115,7 @@ namespace Api.Controllers
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email);
|
_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" });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ namespace Api.Controllers
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided)</param>
|
/// <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>
|
/// <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?}")]
|
[HttpGet("{fileName?}")]
|
||||||
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")]
|
[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.Status200OK, "Full file content returned")]
|
||||||
@@ -135,9 +131,7 @@ namespace Api.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// Handles HTTP range requests for partial content downloads and resume support.
|
||||||
/// Handles HTTP range requests for partial content downloads and resume support.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<IActionResult> HandleRangeRequest(
|
private async Task<IActionResult> HandleRangeRequest(
|
||||||
string filePath,
|
string filePath,
|
||||||
long fileLength,
|
long fileLength,
|
||||||
@@ -194,9 +188,7 @@ namespace Api.Controllers
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// Efficiently streams a specific byte range from source to destination.
|
||||||
/// Efficiently streams a specific byte range from source to destination.
|
|
||||||
/// </summary>
|
|
||||||
private static async Task StreamRangeAsync(Stream source, Stream destination, long bytesToRead)
|
private static async Task StreamRangeAsync(Stream source, Stream destination, long bytesToRead)
|
||||||
{
|
{
|
||||||
var buffer = new byte[BufferSize];
|
var buffer = new byte[BufferSize];
|
||||||
|
|||||||
@@ -135,13 +135,6 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
result.JobUrl = job.SourceUrl;
|
result.JobUrl = job.SourceUrl;
|
||||||
result.Cached = false;
|
result.Cached = false;
|
||||||
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct);
|
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;
|
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 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)}
|
|
||||||
// """;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ public sealed class JobTokenService : IJobTokenService
|
|||||||
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
|
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(l => l.Trim())
|
.Select(l => l.Trim())
|
||||||
.Where(l => l.Length > 5 && l.Length < 200)
|
.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\+\-\(\)\@\.]+$"))
|
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
|
||||||
.Take(5)
|
.Take(5)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ using Swashbuckle.AspNetCore.Annotations;
|
|||||||
|
|
||||||
namespace EmailApi.Controllers;
|
namespace EmailApi.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/email")]
|
[Route("api/email")]
|
||||||
public sealed class EmailController : ControllerBase
|
public sealed class EmailController : ControllerBase
|
||||||
@@ -13,9 +18,27 @@ public sealed class EmailController : ControllerBase
|
|||||||
|
|
||||||
public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher;
|
public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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
|
||||||
|
/// <see cref="SendEmailRequest.AttachmentPath"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Email payload: recipients, subject, HTML body fragment, optional attachment path.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>204 No Content on success.</returns>
|
||||||
[HttpPost("send")]
|
[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.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
public async Task<IActionResult> Send([FromBody] SendEmailRequest request, CancellationToken ct)
|
public async Task<IActionResult> Send([FromBody] SendEmailRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _dispatcher.SendAsync(request, ct);
|
await _dispatcher.SendAsync(request, ct);
|
||||||
|
|||||||
@@ -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 lower = text.ToLowerInvariant();
|
||||||
var scores = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
var scores = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
@@ -37,6 +39,8 @@ public sealed class DocumentClassifier : IDocumentClassifier
|
|||||||
|
|
||||||
var best = scores.OrderByDescending(x => x.Value).First();
|
var best = scores.OrderByDescending(x => x.Value).First();
|
||||||
var type = best.Value <= 0 ? "unknown" : best.Key;
|
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);
|
var confidence = best.Value <= 0 ? 0.25 : Math.Min(0.95, 0.45 + best.Value * 0.08);
|
||||||
|
|
||||||
return Task.FromResult(new DocumentClassification
|
return Task.FromResult(new DocumentClassification
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public sealed class TextChunker : ITextChunker
|
|||||||
chunkSize = Math.Clamp(chunkSize, 300, 3000);
|
chunkSize = Math.Clamp(chunkSize, 300, 3000);
|
||||||
overlap = Math.Clamp(overlap, 0, chunkSize / 2);
|
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<string>();
|
var chunks = new List<string>();
|
||||||
var start = 0;
|
var start = 0;
|
||||||
while (start < text.Length)
|
while (start < text.Length)
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ public sealed class HtmlJobSearcher
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strip query string and fragment so different tracking variants of the same URL collapse to one.
|
||||||
var url = absoluteUri.GetLeftPart(UriPartial.Path);
|
var url = absoluteUri.GetLeftPart(UriPartial.Path);
|
||||||
if (seen.Add(url))
|
if (seen.Add(url))
|
||||||
results.Add(url);
|
results.Add(url);
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ public sealed class CvSearchJobTask : IJobTask
|
|||||||
{
|
{
|
||||||
CvDocumentId = session.CvDocumentId,
|
CvDocumentId = session.CvDocumentId,
|
||||||
JobUrl = url,
|
JobUrl = url,
|
||||||
|
// User already gave GDPR consent when they clicked the one-time job search link
|
||||||
GdprConsent = true
|
GdprConsent = true
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,6 +192,7 @@ public sealed class CvSearchJobTask : IJobTask
|
|||||||
|
|
||||||
private static string BuildCvFileName(string cvDocumentId)
|
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));
|
var safeId = string.Concat(cvDocumentId.Where(char.IsLetterOrDigit));
|
||||||
if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv";
|
if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv";
|
||||||
return $"{safeId}.pdf";
|
return $"{safeId}.pdf";
|
||||||
|
|||||||
Reference in New Issue
Block a user