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:
2026-05-28 09:07:23 +03:00
parent 7d92f2f8d9
commit 4ee4a59b5e
9 changed files with 37 additions and 40 deletions
+1 -1
View File
@@ -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" });
} }
} }
+2 -10
View File
@@ -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();
+24 -1
View File
@@ -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
+2
View File
@@ -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";