Files
jobtrackingapp/JobTrackerApi/Controllers/ProfileCvController.Pipeline.cs
T
cesnimda c3c5af8329
CI and Deploy / test (pull_request) Failing after 1m39s
CI and Deploy / deploy (pull_request) Has been skipped
feat(cv)!: queue durable processing
CV upload now returns 202 with an owner-scoped operation instead of holding the request through parsing. Existing review approval remains required.

BREAKING CHANGE: profile-cv upload responses use the durable operation contract.
2026-08-09 15:11:03 +02:00

603 lines
28 KiB
C#

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Services;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
public sealed partial class ProfileCvController : ControllerBase
{
private static TailoredCvDocument BuildMasterCvDocument(StructuredCvProfile structuredCv, string templateId, string? targetRole, string? fallbackHeadline, string? companyName)
{
var normalized = StructuredCvProfileJson.Normalize(structuredCv);
var customSections = new List<TailoredCvCustomSection>();
if (normalized.Certifications.Count > 0)
{
customSections.Add(new TailoredCvCustomSection
{
Title = "Certifications",
Items = normalized.Certifications.Select(certification => string.Join(" | ", new[] { certification.Name, certification.Issuer, certification.Location, certification.Date }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
});
}
if (normalized.Projects.Count > 0)
{
customSections.Add(new TailoredCvCustomSection
{
Title = "Projects",
Items = normalized.Projects.Select(project => string.Join(" | ", new[] { project.Name, project.Role, project.Location, FormatDateRangeForSection(project.Start, project.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
});
}
if (normalized.Languages.Count > 0)
{
customSections.Add(new TailoredCvCustomSection
{
Title = "Languages",
Items = normalized.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
});
}
customSections.AddRange(normalized.OtherSections.Select(section => new TailoredCvCustomSection { Title = section.Title, Items = section.Items }));
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
{
TemplateId = templateId,
Headline = normalized.Contact.Headline ?? targetRole ?? fallbackHeadline ?? companyName,
Summary = normalized.Summary,
SelectedSkills = normalized.Skills,
Experience = normalized.Jobs.Select(job => new TailoredCvExperienceItem
{
Title = job.Title,
Company = job.Company,
Location = job.Location,
Start = job.Start,
End = job.End,
IsCurrent = job.IsCurrent,
Bullets = job.Bullets,
}).ToList(),
Education = normalized.Education.Select(education => new TailoredCvEducationItem
{
Qualification = education.Qualification,
QualificationLevel = education.QualificationLevel,
Institution = education.Institution,
Location = education.Location,
Start = education.Start,
End = education.End,
Details = education.Details,
}).ToList(),
CustomSections = customSections,
RenderOptions = new TailoredCvRenderOptions
{
ShowPhoto = true,
AccentColor = templateId switch
{
"harvard" => "brick",
"auckland" => "emerald",
"edinburgh" => "plum",
"monarch" => "#7c2d12",
"fjord" => "#0f4c5c",
_ => "slate",
},
SectionOrder = new List<string> { "summary", "skills", "experience", "education", "custom" },
}
});
}
private async Task<StructuredCvProfile> BuildStructuredCvAsync(string text, CancellationToken cancellationToken)
{
if (LooksLikeNormalizedMarkdownCv(text))
{
var normalized = BuildStructuredCvFromNormalizedMarkdown(text);
AnnotateStructuredCv(normalized, "normalized-markdown", 0.78);
return StructuredCvProfileJson.Normalize(normalized);
}
var parseSource = NormalizeTextForStructuredParsing(text);
var parsedSections = ParseSections(parseSource)
.Select(section => new StructuredCvSection
{
Name = section.Name,
Content = section.Content,
WordCount = CountWords(section.Content),
})
.ToList();
var hasRealSections = parsedSections.Any(section => !string.Equals(section.Name, "General", StringComparison.OrdinalIgnoreCase));
List<ClassifiedCvBlock> classifiedBlocks = new();
List<StructuredCvSection> fallbackSections = parsedSections;
StructuredCvProfile? classifierFallback = null;
if (!hasRealSections)
{
classifiedBlocks = await ClassifyBlocksAsync(parseSource, cancellationToken);
var hasMeaningfulClassifierStructure = classifiedBlocks.Any(block => !string.Equals(block.SectionName, "General", StringComparison.OrdinalIgnoreCase));
if (hasMeaningfulClassifierStructure)
{
fallbackSections = BuildSectionsFromClassifiedBlocks(classifiedBlocks);
classifierFallback = BuildStructuredCvFromClassifiedBlocks(classifiedBlocks);
}
}
var sectionFallback = StructuredCvProfileJson.FromSections(fallbackSections);
AnnotateStructuredCv(sectionFallback, "repair", 0.56);
var heuristicFallback = BuildHeuristicStructuredCv(parseSource, text);
AnnotateStructuredCv(heuristicFallback, "deterministic", 0.68);
heuristicFallback.Sections = new List<StructuredCvSection>();
var fallback = StructuredCvProfileJson.Merge(heuristicFallback, sectionFallback);
if (classifierFallback is not null)
{
fallback = StructuredCvProfileJson.Merge(classifierFallback, fallback);
}
fallback.Contact.FullName ??= GuessFullName(text) ?? GuessFullNameFromEmail(fallback.Contact.Email);
var extracted = await TryExtractStructuredCvAsync(parseSource, cancellationToken);
var merged = StructuredCvProfileJson.Merge(extracted, fallback);
merged.Contact.FullName ??= GuessFullName(text) ?? GuessFullNameFromEmail(merged.Contact.Email);
if (!IsPlausibleLocationValue(merged.Contact.Location, merged.Contact.FullName))
{
merged.Contact.Location = PreferDetectedLocation(text, null, merged.Contact.FullName);
}
merged.Jobs = merged.Jobs
.Where(job => !LooksLikePersonName(job.Title ?? string.Empty))
.ToList();
var reparsedJobs = ParseJobsHeuristically(text)
.Where(job => !LooksLikePersonName(job.Title ?? string.Empty))
.ToList();
var existingFirstTitle = merged.Jobs.FirstOrDefault()?.Title;
var reparsedFirstTitle = reparsedJobs.FirstOrDefault()?.Title;
if (LooksLikePersonName(existingFirstTitle ?? string.Empty)
&& LooksLikeRoleOrHeadline(reparsedFirstTitle ?? string.Empty)
&& ArePlausibleJobs(reparsedJobs, merged.Contact.FullName))
{
merged.Jobs = reparsedJobs;
}
else if (ArePlausibleJobs(merged.Jobs, merged.Contact.FullName))
{
if (ScoreJobs(reparsedJobs, merged.Contact.FullName) > ScoreJobs(merged.Jobs, merged.Contact.FullName))
{
merged.Jobs = reparsedJobs;
}
}
else if (ArePlausibleJobs(reparsedJobs, merged.Contact.FullName))
{
merged.Jobs = reparsedJobs;
}
return StructuredCvProfileJson.Normalize(merged);
}
private async Task<CvUploadArtifact> SaveUploadArtifactAsync(ApplicationUser user, IFormFile file, CancellationToken cancellationToken)
{
var extension = Path.GetExtension(file.FileName ?? string.Empty);
var userRoot = Path.Combine(_paths.CvArtifactsRoot, user.Id);
Directory.CreateDirectory(userRoot);
var storedFileName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension}";
var storagePath = Path.Combine(userRoot, storedFileName);
await using (var target = System.IO.File.Create(storagePath))
await using (var source = file.OpenReadStream())
{
await source.CopyToAsync(target, cancellationToken);
}
await using var hashStream = System.IO.File.OpenRead(storagePath);
var shaBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
return new CvUploadArtifact
{
OwnerUserId = user.Id,
OriginalFileName = file.FileName ?? storedFileName,
StoredFileName = storedFileName,
MimeType = file.ContentType ?? "application/octet-stream",
ByteSize = file.Length,
Sha256 = Convert.ToHexString(shaBytes),
StoragePath = storagePath,
UploadedAtUtc = DateTimeOffset.UtcNow,
};
}
private async Task<ExtractionPipelineResult> ExtractStructuredCvFromFileAsync(IFormFile file, string extension, CancellationToken cancellationToken)
{
string text;
var canUseAiExtraction = string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".webp", StringComparison.OrdinalIgnoreCase);
if (canUseAiExtraction)
{
await using var uploadStream = file.OpenReadStream();
var extracted = await _aiService.ExtractTextAsync(uploadStream, file.FileName ?? $"cv{extension}", file.ContentType, cancellationToken);
text = extracted?.Text?.Trim() ?? string.Empty;
}
else
{
text = string.Empty;
}
if (string.IsNullOrWhiteSpace(text))
{
text = (await ExtractTextAsync(file, extension)).Trim();
}
if (string.IsNullOrWhiteSpace(text))
{
throw new InvalidOperationException("The uploaded CV file could not be read or was empty.");
}
text = RepairKnownMojibake(text);
var normalizedText = (await MaybeReconstructStructuredCvAsync(text, cancellationToken)).Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
}
private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
{
var run = new CvExtractionRun
{
OwnerUserId = user.Id,
ArtifactId = artifactId,
Trigger = trigger,
ParserVersion = ParserVersion,
NormalizerVersion = NormalizerVersion,
LlmPromptVersion = LlmPromptVersion,
Status = "applied",
RawExtractedText = rawText,
NormalizedText = normalizedText,
StartedAtUtc = DateTimeOffset.UtcNow,
CompletedAtUtc = DateTimeOffset.UtcNow,
AppliedAtUtc = DateTimeOffset.UtcNow,
};
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(cancellationToken);
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.StructuredProfileJson = structuredJson;
user.ProfileCvText = normalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
if (artifactId.HasValue)
{
user.CurrentCvUploadArtifactId = artifactId.Value;
}
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(cancellationToken);
throw new InvalidOperationException(run.ErrorMessage);
}
await _db.SaveChangesAsync(cancellationToken);
await PruneExtractionRunsAsync(user.Id, cancellationToken);
}
private async Task<CvExtractionRun> CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken)
{
var active = await FindActiveRunAsync(ownerUserId, trigger, artifactId, null, cancellationToken);
if (active is not null) return active;
var run = new CvExtractionRun
{
OwnerUserId = ownerUserId,
ArtifactId = artifactId,
Trigger = trigger,
ParserVersion = ParserVersion,
NormalizerVersion = NormalizerVersion,
LlmPromptVersion = LlmPromptVersion,
Status = "queued",
StartedAtUtc = DateTimeOffset.UtcNow,
};
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(cancellationToken);
return run;
}
private async Task<CvExtractionRun?> FindActiveRunAsync(
string ownerUserId,
string trigger,
int? artifactId,
string? artifactSha256,
CancellationToken cancellationToken)
{
var activeStatuses = new[]
{
OperationStatuses.Queued,
OperationStatuses.Running,
OperationStatuses.WaitingForRetry,
OperationStatuses.WaitingForExternalFallback,
};
var subjectIds = await _db.UserOperations.AsNoTracking()
.Where(operation => operation.TaskType == CvProcessingQueue.TaskType && activeStatuses.Contains(operation.Status))
.Select(operation => operation.SubjectId)
.ToListAsync(cancellationToken);
var runIds = subjectIds
.Select(value => int.TryParse(value, out var id) ? id : 0)
.Where(id => id > 0)
.ToList();
if (runIds.Count == 0) return null;
var candidates = await _db.CvExtractionRuns
.Include(run => run.Artifact)
.Where(run => run.OwnerUserId == ownerUserId && run.Trigger == trigger && runIds.Contains(run.Id))
.ToListAsync(cancellationToken);
return candidates
.Where(run => artifactSha256 is not null
? string.Equals(run.Artifact?.Sha256, artifactSha256, StringComparison.OrdinalIgnoreCase)
: run.ArtifactId == artifactId)
.MaxBy(run => run.StartedAtUtc);
}
private async Task<IActionResult> EnqueueRunAsync(CvExtractionRun run, CancellationToken cancellationToken)
{
try
{
var admission = await _cvProcessingQueue.EnqueueAsync(run.Id, cancellationToken);
return Accepted(
admission?.StatusUrl,
new CvProcessingOperationResponse(
true,
run.Id,
run.Status,
admission is null ? null : OperationDto.From(admission.Operation),
admission?.StatusUrl,
admission?.Created ?? false));
}
catch (AiOperationAdmissionException exception)
{
run.Status = "failed";
run.ErrorMessage = exception.Message;
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
if (exception.RetryAfterSeconds is int seconds) Response.Headers.RetryAfter = seconds.ToString();
return StatusCode(exception.StatusCode, new { code = exception.Code, message = exception.Message });
}
}
private void TryDeleteCvArtifactFile(string path)
{
try
{
if (System.IO.File.Exists(path)) System.IO.File.Delete(path);
}
catch (Exception exception)
{
_logger.LogWarning(exception, "Could not remove duplicate CV upload artifact {ArtifactPath}", path);
}
}
// Invoked by CvProcessingOperationHandler (this controller is also registered as a
// transient service). NonAction keeps it off the HTTP surface: without it the
// controller-level [Route] exposes it as an any-verb endpoint.
[NonAction]
public async Task<CvProcessingOutcome?> ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken)
{
var ownerUserId = _db.CurrentUserId;
var run = ownerUserId is null
? await _db.CvExtractionRuns.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == runId, cancellationToken)
: await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == runId && x.OwnerUserId == ownerUserId, cancellationToken);
if (run is null) return null;
var user = await _users.FindByIdAsync(run.OwnerUserId);
if (user is null)
{
run.Status = "failed";
run.ErrorMessage = "CV processing user was not found.";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return new CvProcessingOutcome(false, "cv_user_not_found", run.ErrorMessage);
}
if (!user.AiEnabled || !AccountPlans.ForRoles(await _users.GetRolesAsync(user)).Ai)
{
run.Status = "failed";
run.ErrorMessage = user.AiEnabled
? "This AI feature requires Pro."
: "AI is disabled in your privacy settings.";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return new CvProcessingOutcome(false, "entitlement_changed", run.ErrorMessage);
}
run.Status = "running";
run.ErrorMessage = null;
await _db.SaveChangesAsync(cancellationToken);
try
{
AiGenerationResult? generation = null;
switch (run.Trigger)
{
case "rebuild":
{
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) throw new InvalidOperationException("Add or import CV text before rebuilding it.");
generation = await _aiService.GenerateSectionWithMetadataAsync(
"Rewrite this CV into a stronger master CV with clear sections such as Professional Summary, Core Skills, Experience Highlights, and Selected Achievements. Preserve only factual claims, avoid inventing employers or metrics, and make the output clean and ready for tailoring to job applications. Return only the rebuilt CV text.",
user.ProfileCvText,
2200,
700,
cancellationToken);
var rebuilt = generation?.Text;
if (string.IsNullOrWhiteSpace(rebuilt)) throw new InvalidOperationException("The AI service could not rebuild your CV text right now.");
var normalizedText = rebuilt.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "improve":
{
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) throw new InvalidOperationException("Add or import CV text before improving it.");
generation = await _aiService.GenerateSectionWithMetadataAsync(
"Rewrite this CV into a cleaner, better-structured master CV profile. Preserve factual claims, employers, skills, and measurable results. Improve clarity, tighten wording, use strong bullet-style phrasing, and keep it ready for further tailoring to specific roles. Return only the improved CV text.",
user.ProfileCvText,
1800,
500,
cancellationToken);
var improved = generation?.Text;
if (string.IsNullOrWhiteSpace(improved)) throw new InvalidOperationException("The AI service could not improve your CV text right now.");
var normalizedText = improved.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "upload":
case "reprocess":
{
var artifact = await _db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken);
if (artifact is null) throw new InvalidOperationException("Upload a CV before reprocessing it.");
if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath))
{
throw new InvalidOperationException("The stored CV artifact could not be found for reprocessing.");
}
await using var stream = System.IO.File.OpenRead(artifact.StoragePath);
var file = new FormFile(stream, 0, stream.Length, "file", artifact.OriginalFileName)
{
Headers = new HeaderDictionary(),
ContentType = artifact.MimeType
};
var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty);
var result = await ExtractStructuredCvFromFileAsync(file, extension, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, result.RawText, result.NormalizedText, result.StructuredCv, cancellationToken);
break;
}
default:
throw new InvalidOperationException($"Unsupported CV processing trigger '{run.Trigger}'.");
}
await SendRunCompletionEmailAsync(user, run, true, cancellationToken);
return new CvProcessingOutcome(
true,
Provider: generation?.Provider,
Model: generation?.Model,
RouteReason: generation?.RouteReason);
}
catch (OperationCanceledException)
{
run.Status = "queued";
run.ErrorMessage = "CV processing was interrupted before completion.";
run.CompletedAtUtc = null;
await _db.SaveChangesAsync(CancellationToken.None);
throw;
}
catch (Exception ex)
{
var generationFailure = ex as AiGenerationException;
var retryable = generationFailure?.Retryable == true;
run.Status = retryable ? "queued" : "failed";
run.ErrorMessage = ex.Message;
run.CompletedAtUtc = retryable ? null : DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
if (!retryable)
{
await PruneExtractionRunsAsync(user.Id, cancellationToken);
await SendRunCompletionEmailAsync(user, run, false, cancellationToken);
}
_logger.LogWarning(ex, "CV processing run {RunId} failed for user {UserId}", run.Id, user.Id);
return new CvProcessingOutcome(
false,
generationFailure?.Category ?? "cv_processing_failed",
ex.Message,
retryable,
generationFailure?.Provider,
generationFailure?.Model,
generationFailure?.RouteReason);
}
}
private async Task CompleteQueuedRunForReviewAsync(CvExtractionRun run, string rawText, string normalizedText, StructuredCvProfile structuredCv, CancellationToken cancellationToken)
{
run.RawExtractedText = rawText;
run.NormalizedText = normalizedText;
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(structuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
await PruneExtractionRunsAsync(run.OwnerUserId, cancellationToken);
}
private async Task PruneExtractionRunsAsync(string ownerUserId, CancellationToken cancellationToken)
{
var completedRuns = _db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && x.Status != "queued" && x.Status != "running");
var expired = _db.Database.IsSqlite()
? (await completedRuns.ToListAsync(cancellationToken)).OrderByDescending(x => x.StartedAtUtc).Skip(ExtractionRunRetentionCount).ToList()
: await completedRuns.OrderByDescending(x => x.StartedAtUtc).Skip(ExtractionRunRetentionCount).ToListAsync(cancellationToken);
if (expired.Count > 0)
{
_db.CvExtractionRuns.RemoveRange(expired);
await _db.SaveChangesAsync(cancellationToken);
}
var referencedArtifactIds = await _db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && x.ArtifactId != null)
.Select(x => x.ArtifactId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
var user = await _users.FindByIdAsync(ownerUserId);
if (user?.CurrentCvUploadArtifactId is int currentArtifactId)
{
referencedArtifactIds.Add(currentArtifactId);
}
var orphanedArtifacts = await _db.CvUploadArtifacts.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && !referencedArtifactIds.Contains(x.Id))
.ToListAsync(cancellationToken);
if (orphanedArtifacts.Count == 0) return;
_db.CvUploadArtifacts.RemoveRange(orphanedArtifacts);
await _db.SaveChangesAsync(cancellationToken);
foreach (var artifact in orphanedArtifacts)
{
try
{
if (!string.IsNullOrWhiteSpace(artifact.StoragePath)) System.IO.File.Delete(artifact.StoragePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete unreferenced CV artifact {ArtifactId} at {Path}", artifact.Id, artifact.StoragePath);
}
}
}
private async Task SendRunCompletionEmailAsync(ApplicationUser user, CvExtractionRun run, bool success, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(user.Email)) return;
var subject = success ? $"Your CV {run.Trigger} is complete" : $"Your CV {run.Trigger} failed";
var body = success
? $"Your CV {run.Trigger} request finished successfully.\n\nRun ID: {run.Id}\nStatus: {run.Status}\nCompleted: {run.CompletedAtUtc:O}\n"
: $"Your CV {run.Trigger} request failed.\n\nRun ID: {run.Id}\nStatus: {run.Status}\nError: {run.ErrorMessage}\nCompleted: {run.CompletedAtUtc:O}\n";
try
{
await _emailSender.SendAsync(user.Email, subject, body, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "CV processing completion email failed for run {RunId} user {UserId}", run.Id, user.Id);
}
}
}