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)
|
||||
{
|
||||
_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>
|
||||
/// <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")]
|
||||
@@ -135,9 +131,7 @@ namespace Api.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles HTTP range requests for partial content downloads and resume support.
|
||||
/// </summary>
|
||||
// Handles HTTP range requests for partial content downloads and resume support.
|
||||
private async Task<IActionResult> HandleRangeRequest(
|
||||
string filePath,
|
||||
long fileLength,
|
||||
@@ -194,9 +188,7 @@ namespace Api.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Efficiently streams a specific byte range from source to destination.
|
||||
/// </summary>
|
||||
// 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];
|
||||
|
||||
@@ -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)}
|
||||
// """;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,6 +5,11 @@ using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
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]
|
||||
[Route("api/email")]
|
||||
public sealed class EmailController : ControllerBase
|
||||
@@ -13,9 +18,27 @@ public sealed class EmailController : ControllerBase
|
||||
|
||||
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")]
|
||||
[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<IActionResult> Send([FromBody] SendEmailRequest request, CancellationToken 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 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 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
|
||||
|
||||
@@ -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<string>();
|
||||
var start = 0;
|
||||
while (start < text.Length)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user