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; [ApiController] [Route("api/profile-cv")] [Authorize(AuthenticationSchemes = "local")] public sealed partial class ProfileCvController : ControllerBase { private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".txt", ".md", ".pdf", ".docx", ".png", ".jpg", ".jpeg", ".webp", }; private static readonly Dictionary SectionAliases = new(StringComparer.OrdinalIgnoreCase) { ["professional summary"] = "Professional Summary", ["summary"] = "Professional Summary", ["profile"] = "Professional Summary", ["about me"] = "Professional Summary", ["contact"] = "Contact", ["contact details"] = "Contact", ["core skills"] = "Skills", ["skills"] = "Skills", ["technical skills"] = "Skills", ["experience"] = "Work Experience", ["experience highlights"] = "Work Experience", ["work experience"] = "Work Experience", ["employment history"] = "Work Experience", ["selected achievements"] = "Selected Achievements", ["achievements"] = "Selected Achievements", ["projects"] = "Projects", ["education"] = "Education", ["certifications"] = "Certifications", ["certificates"] = "Certifications", ["languages"] = "Languages", ["interests"] = "Interests", ["hobbies"] = "Interests", ["awards"] = "Awards", ["honours"] = "Awards", ["honors"] = "Awards", ["publications"] = "Publications", ["organisations"] = "Organisations", ["organizations"] = "Organisations", ["memberships"] = "Organisations", ["references"] = "References", }; private const long MaxFileSizeBytes = 5 * 1024 * 1024; private const int ExtractionRunRetentionCount = 20; private const string ParserVersion = "m005-s01"; private const string NormalizerVersion = "m005-s01"; private const string LlmPromptVersion = "m005-s01"; private readonly UserManager _users; private readonly ISummarizerService _aiService; private readonly ICvAiClassifier _cvAiClassifier; private readonly ICvAiNormalizer _cvAiNormalizer; private readonly JobTrackerContext _db; private readonly AppPaths _paths; private readonly ILogger _logger; private readonly ICvTemplateRenderer _cvTemplateRenderer; private readonly ICvPdfExporter _cvPdfExporter; private readonly ICvProcessingQueue _cvProcessingQueue; private readonly IAppEmailSender _emailSender; private readonly ICareerProfileService _careerProfileService; private readonly ICvProfileDiffService _cvProfileDiffService; public ProfileCvController(UserManager users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvAiNormalizer? cvAiNormalizer = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, ICvProcessingQueue? cvProcessingQueue = null, IAppEmailSender? emailSender = null, ICareerProfileService? careerProfileService = null, ICvProfileDiffService? cvProfileDiffService = null) { _users = users; _aiService = aiService; _cvAiClassifier = cvAiClassifier ?? NoOpCvAiClassifier.Instance; _cvAiNormalizer = cvAiNormalizer ?? NoOpCvAiNormalizer.Instance; _db = db; _paths = paths; _logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; _cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer(); _cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter(); _cvProcessingQueue = cvProcessingQueue ?? NoOpCvProcessingQueue.Instance; _emailSender = emailSender ?? NoOpEmailSender.Instance; _careerProfileService = careerProfileService ?? new CareerProfileService(db); _cvProfileDiffService = cvProfileDiffService ?? new CvProfileDiffService(); } private sealed class NoOpEmailSender : IAppEmailSender { public static readonly NoOpEmailSender Instance = new(); public Task SendAsync(string toEmail, string subject, string bodyText, CancellationToken cancellationToken = default) => Task.CompletedTask; } private sealed class ThrowingCvPdfExporter : ICvPdfExporter { public Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) { throw new InvalidOperationException("CV PDF export is not configured for this controller instance."); } } public sealed class RewriteSectionRequest { public string? SectionName { get; set; } public string? Style { get; set; } public string? TargetRole { get; set; } public JsonElement? JobApplicationId { get; set; } public string? TemplateId { get; set; } public string? SourceText { get; set; } public string? PromptBackground { get; set; } public string? Tone { get; set; } public string? Language { get; set; } } // ParseCvRequest / CvTemplateDescriptor / ProfileCvPreviewDto / CvRewriteFailureDto are // defined in ProfileCvDtos.cs on main (extracted after this branch forked). The branch's // in-file copies are dropped here to avoid duplicate definitions. The LayoutFamily/AtsRating // fields the branch added to CvTemplateDescriptor belong to the deferred ATS-badge work // (Phase 4), not this foundation integration. public sealed record AcceptCvRunRequest(List? AcceptedLowConfidenceIds); private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv); private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification); [HttpPost("upload")] [Authorize(Policy = ProEntitlement.Policy)] [RequestSizeLimit(MaxFileSizeBytes)] public async Task Upload([FromForm] IFormFile file) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); if (file is null || file.Length == 0) return BadRequest("Select a CV file to upload."); if (file.Length > MaxFileSizeBytes) return BadRequest("CV import file is too large. Keep it under 5 MB."); var extension = Path.GetExtension(file.FileName ?? string.Empty); if (!AllowedExtensions.Contains(extension)) { return BadRequest("Only .txt, .md, .pdf, .docx, .png, .jpg, .jpeg, and .webp CV imports are supported right now."); } var artifact = await SaveUploadArtifactAsync(user, file, HttpContext.RequestAborted); var activeRun = await FindActiveRunAsync(user.Id, "upload", null, artifact.Sha256, HttpContext.RequestAborted); if (activeRun is not null) { TryDeleteCvArtifactFile(artifact.StoragePath); return await EnqueueRunAsync(activeRun, HttpContext.RequestAborted); } _db.CvUploadArtifacts.Add(artifact); await _db.SaveChangesAsync(HttpContext.RequestAborted); var run = new CvExtractionRun { OwnerUserId = user.Id, ArtifactId = artifact.Id, Trigger = "upload", ParserVersion = ParserVersion, NormalizerVersion = NormalizerVersion, LlmPromptVersion = LlmPromptVersion, Status = "queued", StartedAtUtc = DateTimeOffset.UtcNow, }; _db.CvExtractionRuns.Add(run); await _db.SaveChangesAsync(HttpContext.RequestAborted); return await EnqueueRunAsync(run, HttpContext.RequestAborted); } [HttpGet("runs")] public async Task>> GetRuns() { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var runsQuery = _db.CvExtractionRuns .AsNoTracking() .Where(x => x.OwnerUserId == user.Id) .Select(x => new CvExtractionRunListItem( x.Id, x.Trigger, x.Status, x.Artifact != null ? x.Artifact.OriginalFileName : null, x.StartedAtUtc, x.CompletedAtUtc, x.AppliedAtUtc, x.ParserVersion, x.NormalizerVersion, x.LlmPromptVersion, x.ErrorMessage, null)); var runs = _db.Database.IsSqlite() ? (await runsQuery.ToListAsync(HttpContext.RequestAborted)).OrderByDescending(x => x.StartedAtUtc).Take(10).ToList() : await runsQuery.OrderByDescending(x => x.StartedAtUtc).Take(10).ToListAsync(HttpContext.RequestAborted); var runIds = runs.Select(run => run.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToList(); var operations = await _db.UserOperations.AsNoTracking() .Where(operation => operation.TaskType == CvProcessingQueue.TaskType && operation.SubjectId != null && runIds.Contains(operation.SubjectId)) .ToListAsync(HttpContext.RequestAborted); var latestOperations = operations .GroupBy(operation => operation.SubjectId!, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.MaxBy(operation => operation.CreatedAtUtc)!); runs = runs.Select(run => latestOperations.TryGetValue(run.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), out var operation) ? run with { Operation = OperationDto.From(operation) } : run).ToList(); return Ok(runs); } [HttpGet("runs/{id:int}/diff")] public async Task GetRunDiff([FromRoute] int id) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var run = await _db.CvExtractionRuns.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted); if (run is null) return NotFound(); if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile."); var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted); var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson); return Ok(new { runId = run.Id, run.Status, diff = _cvProfileDiffService.Diff(current, extracted) }); } [HttpPost("runs/{id:int}/accept")] public async Task AcceptRun([FromRoute] int id, [FromBody] AcceptCvRunRequest? request = null) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted); if (run is null) return NotFound(); if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review."); if (string.IsNullOrWhiteSpace(run.StructuredProfileJson)) return Conflict("This extraction run has no reviewable profile."); var current = await _careerProfileService.LoadStructuredAsync(user.Id, HttpContext.RequestAborted); var extracted = StructuredCvProfileJson.Deserialize(run.StructuredProfileJson); var diff = _cvProfileDiffService.Diff(current, extracted); var acceptedLowConfidenceIds = (request?.AcceptedLowConfidenceIds ?? new List()).ToHashSet(StringComparer.Ordinal); var merged = _cvProfileDiffService.Merge(current, extracted, acceptedLowConfidenceIds); merged.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1; merged.Metadata.AppliedExtractionRunId = run.Id; merged.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow; await _careerProfileService.SaveVersionAsync(user.Id, merged, $"{run.Trigger}:accepted", HttpContext.RequestAborted); user.ProfileCvStructureJson = StructuredCvProfileJson.SerializePersisted(merged); if (string.IsNullOrWhiteSpace(user.ProfileCvText)) user.ProfileCvText = run.NormalizedText; user.CurrentCvExtractionRunId = run.Id; user.CurrentCvProfileVersion = merged.Metadata.ProfileVersion; if (run.ArtifactId.HasValue) user.CurrentCvUploadArtifactId = run.ArtifactId.Value; run.Status = "applied"; run.AppliedAtUtc = DateTimeOffset.UtcNow; var update = await _users.UpdateAsync(user); if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(e => e.Description))); await _db.SaveChangesAsync(HttpContext.RequestAborted); return Ok(new { runId = run.Id, run.Status, diff, structuredCv = merged }); } [HttpPost("runs/{id:int}/discard")] public async Task DiscardRun([FromRoute] int id) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == user.Id, HttpContext.RequestAborted); if (run is null) return NotFound(); if (run.Status != "pending_review") return Conflict("This extraction run is not awaiting review."); run.Status = "discarded"; await _db.SaveChangesAsync(HttpContext.RequestAborted); return NoContent(); } [HttpPost("reprocess")] [Authorize(Policy = ProEntitlement.Policy)] public async Task Reprocess() { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var artifactQuery = _db.CvUploadArtifacts.AsNoTracking().Where(x => x.OwnerUserId == user.Id); var artifact = _db.Database.IsSqlite() ? (await artifactQuery.ToListAsync(HttpContext.RequestAborted)).MaxBy(x => x.UploadedAtUtc) : await artifactQuery.OrderByDescending(x => x.UploadedAtUtc).FirstOrDefaultAsync(HttpContext.RequestAborted); if (artifact is null) return BadRequest("Upload a CV before reprocessing it."); if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath)) { return BadRequest("The stored CV artifact could not be found for reprocessing."); } var run = await CreateQueuedRunAsync(user.Id, artifact.Id, "reprocess", HttpContext.RequestAborted); return await EnqueueRunAsync(run, HttpContext.RequestAborted); } [HttpPost("rebuild")] [Authorize(Policy = ProEntitlement.Policy)] public async Task Rebuild() { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before rebuilding it."); var run = await CreateQueuedRunAsync(user.Id, user.CurrentCvUploadArtifactId, "rebuild", HttpContext.RequestAborted); return await EnqueueRunAsync(run, HttpContext.RequestAborted); } [HttpPost("rewrite-section")] [Authorize(Policy = ProEntitlement.Policy)] public async Task RewriteSection([FromBody] RewriteSectionRequest request) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var structuredCv = StructuredCvProfileJson.DeserializePersisted(user.ProfileCvStructureJson); var sourceText = string.IsNullOrWhiteSpace(request.SourceText) ? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim()) : request.SourceText.Trim(); if (string.IsNullOrWhiteSpace(sourceText) && structuredCv.Sections.Count == 0) { return BadRequest("Add or import CV text before rewriting your CV."); } var sectionName = NormalizeRewriteSectionName(request.SectionName); var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim(); var templateId = NormalizeTemplateId(request.TemplateId ?? style); var targetRole = string.IsNullOrWhiteSpace(request.TargetRole) ? null : request.TargetRole.Trim(); var tone = string.IsNullOrWhiteSpace(request.Tone) ? null : request.Tone.Trim(); var language = string.IsNullOrWhiteSpace(request.Language) ? null : request.Language.Trim(); var promptBackground = string.IsNullOrWhiteSpace(request.PromptBackground) ? null : request.PromptBackground.Trim(); var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId); var jobContext = jobApplicationId.HasValue ? await _db.JobApplications .AsNoTracking() .Include(job => job.Company) .Where(job => job.Id == jobApplicationId.Value && job.OwnerUserId == user.Id) .Select(job => new { job.Id, job.JobTitle, job.Description, job.TranslatedDescription, job.ShortSummary, job.Notes, job.JobUrl, job.Status, CompanyName = job.Company != null ? job.Company.Name : null, RecruiterName = job.Company != null ? job.Company.RecruiterName : null, RecruiterEmail = job.Company != null ? job.Company.RecruiterEmail : null }) .FirstOrDefaultAsync(HttpContext.RequestAborted) : null; var effectiveTargetRole = targetRole ?? jobContext?.JobTitle; var rewriteSource = BuildRewriteSourceText(sectionName, sourceText, structuredCv); var templateGuidance = DescribeRewriteTemplate(templateId); var roleGuidance = jobContext is not null ? $"Target this toward the saved job '{jobContext.JobTitle}' at '{jobContext.CompanyName ?? "Unknown company"}'. Use the full job record below to sharpen wording without inventing facts.\nJob status: {jobContext.Status}\nJob summary: {jobContext.ShortSummary ?? "-"}\nJob description: {jobContext.Description ?? "-"}\nTranslated description: {jobContext.TranslatedDescription ?? "-"}\nNotes: {jobContext.Notes ?? "-"}\nJob URL: {jobContext.JobUrl ?? "-"}\nRecruiter name: {jobContext.RecruiterName ?? "-"}\nRecruiter email: {jobContext.RecruiterEmail ?? "-"}" : effectiveTargetRole is not null ? $"Target role: {effectiveTargetRole}. Keep it broadly reusable but clearly aligned to that role family." : "Keep it broadly reusable for future tailoring."; var toneGuidance = tone is not null ? $"Tone guidance: {tone}." : "Tone guidance: confident, professional, concise, and factual."; var languageGuidance = language is not null ? $"Write the CV in {language}." : "Write the CV in English unless the source clearly requires another language."; var backgroundGuidance = promptBackground is not null ? $"Candidate background and emphasis: {promptBackground}" : string.Empty; var subject = sectionName is null ? "this CV" : $"the '{sectionName}' section of this CV"; var instruction = $"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, salaries, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} {toneGuidance} {languageGuidance} {backgroundGuidance} Return only the rewritten CV text with clean headings and strong bullet phrasing when useful."; var rewritten = await _aiService.SummarizeSectionAsync( instruction, rewriteSource, sectionName is null ? 1800 : 900, sectionName is null ? 400 : 180); if (string.IsNullOrWhiteSpace(rewritten)) { var metrics = await _aiService.GetMetricsAsync(HttpContext.RequestAborted); var detail = metrics.Healthy ? "The rewrite request reached the AI service, but it returned no usable text." : "The AI rewrite service is unavailable or not ready."; var failureCode = metrics.Healthy ? "rewrite-empty" : "ai-service-unavailable"; var message = metrics.Healthy ? "The AI service returned an empty CV rewrite." : "The AI service could not rewrite your CV right now."; _logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount} AiHealthy={AiHealthy} AiLastError={AiLastError}", sectionName ?? "", templateId, effectiveTargetRole ?? "", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count, metrics.Healthy, metrics.LastError ?? ""); return StatusCode(StatusCodes.Status502BadGateway, new CvRewriteFailureDto( failureCode, message, detail, metrics.LastError)); } return Ok(new { sectionName, style, templateId, targetRole = effectiveTargetRole, jobApplicationId = jobContext?.Id, text = rewritten.Trim() }); } [HttpGet("templates")] public ActionResult> GetTemplates() { return Ok(GetCvTemplateDescriptors()); } [HttpPost("rewrite-preview")] [Authorize(Policy = ProEntitlement.Policy)] public async Task> BuildRewritePreview([FromBody] RewriteSectionRequest request) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var structuredCv = StructuredCvProfileJson.DeserializePersisted(user.ProfileCvStructureJson); var sourceText = string.IsNullOrWhiteSpace(request.SourceText) ? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim()) : request.SourceText.Trim(); if (string.IsNullOrWhiteSpace(sourceText) && structuredCv.Sections.Count == 0) { return BadRequest("Add or import CV text before rewriting your CV."); } var sectionName = NormalizeRewriteSectionName(request.SectionName); var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim(); var templateId = NormalizeTemplateId(request.TemplateId ?? style); var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId); var job = jobApplicationId.HasValue ? await _db.JobApplications.AsNoTracking().Include(job => job.Company) .FirstOrDefaultAsync(job => job.Id == jobApplicationId.Value && job.OwnerUserId == user.Id, HttpContext.RequestAborted) : null; var effectiveTargetRole = string.IsNullOrWhiteSpace(request.TargetRole) ? job?.JobTitle : request.TargetRole.Trim(); var rewriteResult = await RewriteSection(request); if (rewriteResult is not OkObjectResult ok) return StatusCode((rewriteResult as ObjectResult)?.StatusCode ?? 500, (rewriteResult as ObjectResult)?.Value); var rewrittenText = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)).RootElement.GetProperty("text").GetString()?.Trim() ?? string.Empty; var baseText = string.IsNullOrWhiteSpace(sourceText) ? string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}")) : sourceText!; var fullText = sectionName is null ? rewrittenText : ReplaceOrAppendCvSection(baseText, sectionName, rewrittenText); var previewStructured = await BuildStructuredCvAsync(fullText, HttpContext.RequestAborted); var document = BuildMasterCvDocument(previewStructured, templateId, effectiveTargetRole, job?.JobTitle, job?.Company?.Name); var rendered = RenderProfileCv(document, user, effectiveTargetRole ?? user.DisplayName ?? "General CV", job?.Company?.Name); return Ok(new ProfileCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName, fullText, rewrittenText, sectionName, previewStructured, document, effectiveTargetRole, job?.Id)); } [HttpPost("export-pdf")] [Authorize(Policy = ProEntitlement.Policy)] public async Task ExportProfileCvPdf([FromBody] RewriteSectionRequest request, CancellationToken cancellationToken) { var previewResult = await BuildRewritePreview(request); if (previewResult.Result is ObjectResult errorResult && errorResult.StatusCode >= 400) { return StatusCode(errorResult.StatusCode ?? 500, errorResult.Value); } var ok = previewResult.Result as OkObjectResult; if (ok?.Value is not ProfileCvPreviewDto preview) { return StatusCode(StatusCodes.Status500InternalServerError, "The CV preview could not be prepared for PDF export."); } var artifact = await _cvPdfExporter.ExportAsync(new TailoredCvRenderResult(preview.TemplateId, preview.SuggestedFileName, preview.Html), cancellationToken); return File(artifact.Bytes, "application/pdf", artifact.FileName); } [HttpPost("parse")] [Authorize(Policy = ProEntitlement.Policy)] public async Task> Parse([FromBody] ParseCvRequest? request) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var source = string.IsNullOrWhiteSpace(request?.Text) ? user.ProfileCvText : request!.Text; if (string.IsNullOrWhiteSpace(source)) return BadRequest("Add or import CV text before parsing sections."); var normalizedSource = await MaybeReconstructStructuredCvAsync(source, HttpContext.RequestAborted); var structuredCv = await BuildStructuredCvAsync(normalizedSource, HttpContext.RequestAborted); if (string.IsNullOrWhiteSpace(request?.Text)) { user.ProfileCvText = normalizedSource; } await ApplyTextExtractionRunAsync(user, "parse", source, normalizedSource, structuredCv, user.CurrentCvUploadArtifactId, HttpContext.RequestAborted); return Ok(new { structuredCv, sections = structuredCv.Sections, totalWords = CountWords(normalizedSource), extractionRunId = user.CurrentCvExtractionRunId, profileVersion = user.CurrentCvProfileVersion }); } [HttpPost("improve")] [Authorize(Policy = ProEntitlement.Policy)] public async Task Improve() { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); if (string.IsNullOrWhiteSpace(user.ProfileCvText)) return BadRequest("Add or import CV text before improving it."); var run = await CreateQueuedRunAsync(user.Id, user.CurrentCvUploadArtifactId, "improve", HttpContext.RequestAborted); return await EnqueueRunAsync(run, HttpContext.RequestAborted); } private static string BuildRewriteSourceText(string? sectionName, string? sourceText, StructuredCvProfile structuredCv) { if (string.IsNullOrWhiteSpace(sectionName)) { return !string.IsNullOrWhiteSpace(sourceText) ? sourceText.Trim() : string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}")); } var matchingSection = structuredCv.Sections.FirstOrDefault(section => string.Equals(section.Name, sectionName, StringComparison.OrdinalIgnoreCase)); if (matchingSection is not null && !string.IsNullOrWhiteSpace(matchingSection.Content)) { return $"## {matchingSection.Name}\n{matchingSection.Content}"; } return !string.IsNullOrWhiteSpace(sourceText) ? sourceText.Trim() : string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}")); } private static string DescribeRewriteTemplate(string templateId) { return templateId.ToLowerInvariant() switch { "harvard" => "Harvard template: refined, traditional, strong hierarchy, restrained and credible.", "auckland" => "Auckland template: modern sidebar layout, crisp highlights, confident but readable.", "edinburgh" => "Edinburgh template: polished editorial layout with stronger visual personality and premium spacing.", "monarch" => "Monarch template: executive, premium, high-contrast emphasis on summary and leadership signals.", "fjord" => "Fjord template: calm technical layout with clear information density and practical scanability.", _ => "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor." }; } private static string NormalizeTemplateId(string? value) { var normalized = (value ?? string.Empty).Trim().ToLowerInvariant(); return normalized switch { "base" => "ats-minimal", "legacy-text" => "ats-minimal", "harvard" => "harvard", "auckland" => "auckland", "edinburgh" => "edinburgh", "monarch" => "monarch", "fjord" => "fjord", _ => "ats-minimal" }; } private static string? NormalizeRewriteSectionName(string? value) { var trimmed = value?.Trim(); if (string.IsNullOrWhiteSpace(trimmed)) return null; return SectionAliases.TryGetValue(trimmed, out var canonical) ? canonical : trimmed; } private static int? ParseFlexibleNullableInt(JsonElement? value) { if (value is null) return null; if (value.Value.ValueKind == JsonValueKind.Number && value.Value.TryGetInt32(out var number)) return number; if (value.Value.ValueKind == JsonValueKind.String) { var raw = value.Value.GetString(); if (int.TryParse(raw, out var parsed)) return parsed; } return null; } private static string ReplaceOrAppendCvSection(string source, string sectionName, string sectionDraft) { var trimmedSource = (source ?? string.Empty).Trim(); var trimmedDraft = (sectionDraft ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(trimmedDraft)) return trimmedSource; if (string.IsNullOrWhiteSpace(trimmedSource)) return $"## {sectionName}\n{trimmedDraft}"; var normalizedHeading = sectionName.Trim().ToLowerInvariant(); var headingPattern = new Regex(@"^(##\s+|#\s+)?(?[A-Z][A-Za-z &/]+):?\s*$", RegexOptions.Multiline); var matches = headingPattern.Matches(trimmedSource).ToList(); var targetIndex = matches.FindIndex(match => string.Equals(match.Groups["name"].Value.Trim(), normalizedHeading, StringComparison.OrdinalIgnoreCase)); if (targetIndex < 0) { return $"{trimmedSource}\n\n## {sectionName}\n{trimmedDraft}".Trim(); } var start = matches[targetIndex].Index; var end = targetIndex + 1 < matches.Count ? matches[targetIndex + 1].Index : trimmedSource.Length; var before = trimmedSource[..start].TrimEnd(); var after = trimmedSource[end..].TrimStart(); return string.Join("\n\n", new[] { before, $"## {sectionName}\n{trimmedDraft}", after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim(); } private static IReadOnlyList GetCvTemplateDescriptors() { // 7-arg shape matches CvTemplateDescriptor in ProfileCvDtos.cs. The LayoutFamily/AtsRating // fields the branch added here are deferred with the rest of the ATS-badge work (Phase 4). return new[] { new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }), new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List { "Classic serif rhythm", "Strong chronology", "Credible tone" }), new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List { "Sidebar details", "Compact highlights", "Modern contrast" }), new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List { "Premium spacing", "Stronger personality", "Readable density" }), new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }), new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List { "Technical depth", "Dense but readable", "Practical hierarchy" }), }; } private TailoredCvRenderResult RenderProfileCv(TailoredCvDocument document, ApplicationUser user, string targetRole, string? companyName) { var candidateName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(value => !string.IsNullOrWhiteSpace(value))); if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.DisplayName?.Trim(); if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.UserName?.Trim(); if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.Email?.Trim(); if (string.IsNullOrWhiteSpace(candidateName)) candidateName = "Your Name"; return _cvTemplateRenderer.Render(document, document.TemplateId, candidateName!, targetRole, companyName, AvatarStorage.Resolve(user.AvatarImageDataUrl)); } }