638 lines
34 KiB
C#
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));
|
|
}
|
|
|
|
}
|