Files
jobtrackingapp/JobTrackerApi/Controllers/ProfileCvController.cs
T
cesnimda cdcc7163fa
CI and Deploy / test (pull_request) Successful in 5m24s
CI and Deploy / deploy (pull_request) Has been skipped
refactor(storage): scope exports by owner
2026-08-15 18:19:59 +02:00

638 lines
34 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;
[ApiController]
[Route("api/profile-cv")]
[Authorize(AuthenticationSchemes = "local")]
public sealed partial class ProfileCvController : ControllerBase
{
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".txt",
".md",
".pdf",
".docx",
".png",
".jpg",
".jpeg",
".webp",
};
private static readonly Dictionary<string, string> 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<ApplicationUser> _users;
private readonly ISummarizerService _aiService;
private readonly ICvAiClassifier _cvAiClassifier;
private readonly ICvAiNormalizer _cvAiNormalizer;
private readonly JobTrackerContext _db;
private readonly AppPaths _paths;
private readonly ILogger<ProfileCvController> _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<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? 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<ProfileCvController>.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<CvPdfArtifact> ExportAsync(string ownerUserId, 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<string>? 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<IActionResult> 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<ActionResult<IEnumerable<CvExtractionRunListItem>>> 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<IActionResult> 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<IActionResult> 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<string>()).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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count, metrics.Healthy, metrics.LastError ?? "<none>");
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<IEnumerable<CvTemplateDescriptor>> GetTemplates()
{
return Ok(GetCvTemplateDescriptors());
}
[HttpPost("rewrite-preview")]
[Authorize(Policy = ProEntitlement.Policy)]
public async Task<ActionResult<ProfileCvPreviewDto>> 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<IActionResult> 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 user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var artifact = await _cvPdfExporter.ExportAsync(user.Id, 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<ActionResult<object>> 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<IActionResult> 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+)?(?<name>[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<CvTemplateDescriptor> 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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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<string> { "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));
}
}