Staging to Production #51

Merged
claude merged 165 commits from main into production 2026-06-08 18:28:46 +00:00
9 changed files with 37 additions and 40 deletions
Showing only changes of commit 4ee4a59b5e - Show all commits
+1 -1
View File
@@ -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" });
}
}
+2 -10
View File
@@ -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();
+24 -1
View File
@@ -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
+2
View File
@@ -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";