Add CV template preview and PDF export pipeline

This commit is contained in:
2026-03-29 00:43:54 +01:00
parent 2392b135c2
commit 839a2ed80d
15 changed files with 2288 additions and 97 deletions
@@ -5,6 +5,7 @@ using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Services.JobImport;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Identity;
@@ -20,14 +21,26 @@ namespace JobTrackerApi.Controllers
private readonly IAppEmailSender _email;
private readonly UserManager<ApplicationUser> _users;
private readonly ILogger<JobApplicationsController> _logger;
private readonly ICvTemplateRenderer _cvTemplateRenderer;
private readonly ICvPdfExporter _cvPdfExporter;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger)
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
{
_db = db;
_summarizer = summarizer;
_email = email;
_users = users;
_logger = logger;
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
}
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
{
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
{
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
}
}
private string? CurrentUserId =>
@@ -153,6 +166,275 @@ namespace JobTrackerApi.Controllers
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
}
private static string ComputeGenerationContextHash(string value)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
private static int ScoreTailoredExperience(StructuredCvJob job, IEnumerable<string> matchedTags)
{
var corpus = string.Join("\n", new[] { job.Title, job.Company, job.Location, string.Join("\n", job.Bullets), string.Join("\n", job.Skills) }
.Where(value => !string.IsNullOrWhiteSpace(value)))
.ToLowerInvariant();
var score = 0;
foreach (var tag in matchedTags.Where(tag => !string.IsNullOrWhiteSpace(tag)))
{
if (corpus.Contains(tag.ToLowerInvariant(), StringComparison.Ordinal)) score += 4;
}
score += Math.Min(job.Bullets.Count, 4);
return score;
}
private static List<string> SelectTailoredSkills(StructuredCvProfile structured, string jobText)
{
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var prioritized = structured.Skills
.Select(skill => new
{
Skill = skill,
Score = jobTags.Any(tag => skill.Contains(tag, StringComparison.OrdinalIgnoreCase) || tag.Contains(skill, StringComparison.OrdinalIgnoreCase)) ? 2 : 0
})
.OrderByDescending(entry => entry.Score)
.ThenBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase)
.Select(entry => entry.Skill)
.ToList();
if (prioritized.Count == 0)
{
prioritized = structured.Jobs.SelectMany(job => job.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
return prioritized.Take(10).ToList();
}
private static TailoredCvDocument BuildLegacyTailoredCvFallback(JobApplication job)
{
var text = (job.TailoredCvText ?? string.Empty).Trim();
var document = new TailoredCvDocument
{
Headline = job.JobTitle,
CustomSections = string.IsNullOrWhiteSpace(text)
? new List<TailoredCvCustomSection>()
: new List<TailoredCvCustomSection>
{
new TailoredCvCustomSection
{
Title = "Legacy draft text",
Items = text.Split(new[] { "\r\n\r\n", "\n\n" }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(),
}
}
};
return TailoredCvDraftJson.Normalize(document);
}
private static TailoredCvDraftDto ToTailoredCvDraftDto(TailoredCvDraft draft)
{
var document = TailoredCvDraftJson.FromDraft(draft);
return new TailoredCvDraftDto(
draft.Id,
draft.CanonicalProfileVersion,
draft.TemplateId,
document.Headline,
document.Summary,
document.SelectedSkills,
document.Experience,
document.Education,
document.CustomSections,
document.RenderOptions,
draft.GenerationContextHash,
draft.LastGeneratedAtUtc,
draft.LastEditedAtUtc,
draft.Status,
TailoredCvDraftJson.RenderPlainText(document),
false);
}
private static TailoredCvDraftDto ToLegacyTailoredCvDraftDto(JobApplication job)
{
var document = BuildLegacyTailoredCvFallback(job);
return new TailoredCvDraftDto(
null,
null,
"legacy-text",
document.Headline,
document.Summary,
document.SelectedSkills,
document.Experience,
document.Education,
document.CustomSections,
document.RenderOptions,
null,
null,
job.TailoredCvUpdatedAt,
string.IsNullOrWhiteSpace(job.TailoredCvText) ? "empty" : "legacy-import",
TailoredCvDraftJson.RenderPlainText(document),
true);
}
private static TailoredCvDocument BuildTailoredCvDocumentForRender(SaveTailoredCvDraftRequest? request, TailoredCvDraft? draft, JobApplication job)
{
var baseDocument = draft is not null ? TailoredCvDraftJson.FromDraft(draft) : BuildLegacyTailoredCvFallback(job);
if (request is null)
{
return baseDocument;
}
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
{
TemplateId = request.TemplateId ?? baseDocument.TemplateId ?? "ats-minimal",
Headline = request.Headline ?? baseDocument.Headline,
Summary = request.Summary ?? baseDocument.Summary,
SelectedSkills = request.SelectedSkills ?? baseDocument.SelectedSkills,
Experience = request.Experience ?? baseDocument.Experience,
Education = request.Education ?? baseDocument.Education,
CustomSections = request.CustomSections ?? baseDocument.CustomSections,
RenderOptions = request.RenderOptions ?? baseDocument.RenderOptions,
});
}
private async Task<TailoredCvDraft?> FindTailoredCvDraftAsync(int jobId, CancellationToken cancellationToken)
{
return await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == jobId, cancellationToken);
}
private TailoredCvRenderResult RenderTailoredCv(JobApplication job, TailoredCvDocument document, ApplicationUser? user, string? photoDataUrl)
{
return _cvTemplateRenderer.Render(
document,
document.TemplateId,
GetPreferredDisplayName(user),
job.JobTitle,
job.Company?.Name,
photoDataUrl);
}
public sealed record TailoredCvPreviewDto(string TemplateId, string Html, string SuggestedFileName);
public sealed record TailoredCvRenderRequest(
string? TemplateId,
string? Headline,
List<string>? Summary,
List<string>? SelectedSkills,
List<TailoredCvExperienceItem>? Experience,
List<TailoredCvEducationItem>? Education,
List<TailoredCvCustomSection>? CustomSections,
TailoredCvRenderOptions? RenderOptions,
string? PhotoDataUrl,
bool? UseProfileAvatar);
private async Task<TailoredCvDraft> UpsertGeneratedTailoredCvDraftAsync(JobApplication job, ApplicationUser user, string? mode, CancellationToken cancellationToken)
{
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, job.JobUrl }
.Where(value => !string.IsNullOrWhiteSpace(value)));
var structuredCvContext = BuildStructuredCvContext(user);
var generationContext = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name}
Status: {job.Status}
Generation mode: {mode ?? "default"}
Job context:
{jobText}
Canonical profile:
{structuredCvContext}
";
var headline = await _summarizer.SummarizeSectionAsync(
"Write a short, role-specific CV headline for this candidate. Keep it factual, scannable, and under 12 words. Return headline text only.",
generationContext,
48,
24);
var summary = await BuildListFromAiAsync(
$"Write 4 short CV summary bullets tailored to this job. Use only facts supported by the canonical profile. Keep each line tight and credible. {BuildPackageModeInstruction(mode)}",
generationContext,
cancellationToken,
fallbackPrefix: job.JobTitle);
var selectedSkills = SelectTailoredSkills(structured, jobText);
var matchedTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var experience = structured.Jobs
.OrderByDescending(entry => ScoreTailoredExperience(entry, matchedTags))
.ThenByDescending(entry => entry.IsCurrent)
.Take(4)
.Select(entry => new TailoredCvExperienceItem
{
Title = entry.Title,
Company = entry.Company,
Location = entry.Location,
Start = entry.Start,
End = entry.End,
IsCurrent = entry.IsCurrent,
Bullets = entry.Bullets.Take(4).ToList(),
})
.ToList();
var education = structured.Education
.Take(3)
.Select(entry => new TailoredCvEducationItem
{
Qualification = entry.Qualification,
Institution = entry.Institution,
Location = entry.Location,
Start = entry.Start,
End = entry.End,
Details = entry.Details.Take(3).ToList(),
})
.ToList();
var customSections = new List<TailoredCvCustomSection>();
if (structured.Languages.Count > 0)
{
customSections.Add(new TailoredCvCustomSection
{
Title = "Languages",
Items = structured.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
});
}
customSections.AddRange(structured.OtherSections.Take(2).Select(section => new TailoredCvCustomSection
{
Title = section.Title,
Items = section.Items.Take(4).ToList(),
}));
var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument
{
TemplateId = "ats-minimal",
Headline = string.IsNullOrWhiteSpace(headline) ? structured.Contact.Headline ?? job.JobTitle : headline.Trim(),
Summary = summary,
SelectedSkills = selectedSkills,
Experience = experience,
Education = education,
CustomSections = customSections,
RenderOptions = new TailoredCvRenderOptions(),
});
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == job.Id, cancellationToken)
?? new TailoredCvDraft
{
OwnerUserId = user.Id,
JobApplicationId = job.Id,
};
draft.OwnerUserId = user.Id;
draft.CanonicalProfileVersion = user.CurrentCvProfileVersion;
draft.GenerationContextHash = ComputeGenerationContextHash(generationContext);
draft.LastGeneratedAtUtc = DateTimeOffset.UtcNow;
draft.Status = "generated";
TailoredCvDraftJson.ApplyToDraft(draft, document);
if (draft.Id == 0)
{
_db.TailoredCvDrafts.Add(draft);
}
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
job.TailoredCvUpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return draft;
}
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
{
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
@@ -1729,6 +2011,33 @@ namespace JobTrackerApi.Controllers
string? CoverLetterDraft,
string? RecruiterMessageDraft);
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
public sealed record TailoredCvDraftDto(
int? Id,
int? CanonicalProfileVersion,
string TemplateId,
string? Headline,
List<string> Summary,
List<string> SelectedSkills,
List<TailoredCvExperienceItem> Experience,
List<TailoredCvEducationItem> Education,
List<TailoredCvCustomSection> CustomSections,
TailoredCvRenderOptions RenderOptions,
string? GenerationContextHash,
DateTimeOffset? LastGeneratedAtUtc,
DateTimeOffset? LastEditedAtUtc,
string Status,
string RenderedText,
bool IsLegacyFallback);
public sealed record SaveTailoredCvDraftRequest(
string? TemplateId,
string? Headline,
List<string>? Summary,
List<string>? SelectedSkills,
List<TailoredCvExperienceItem>? Experience,
List<TailoredCvEducationItem>? Education,
List<TailoredCvCustomSection>? CustomSections,
TailoredCvRenderOptions? RenderOptions,
string? Status);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints, List<string> AttachmentSignals, List<string> AttachmentFilesUsed, List<string> CoverLetterVariants, List<string> RecruiterMessageVariants);
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes, string? RecruiterMessageDraft);
private sealed record SavedPackageMaterial(string? TailoredCvText, string? CoverLetterText, string? RecruiterMessageDraft, string? Notes);
@@ -2029,6 +2338,155 @@ Candidate master CV:
return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal));
}
[HttpGet("{id:int}/tailored-cv-draft")]
public async Task<ActionResult<TailoredCvDraftDto>> GetTailoredCvDraft([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.AsNoTracking()
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var draft = await _db.TailoredCvDrafts
.AsNoTracking()
.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken);
return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft));
}
[HttpPost("{id:int}/tailored-cv-preview")]
public async Task<ActionResult<TailoredCvPreviewDto>> PreviewTailoredCv([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var user = await GetCurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
request.TemplateId,
request.Headline,
request.Summary,
request.SelectedSkills,
request.Experience,
request.Education,
request.CustomSections,
request.RenderOptions,
draft?.Status ?? "generated"), draft, job);
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
? request!.PhotoDataUrl
: request?.UseProfileAvatar == false
? null
: user.AvatarImageDataUrl;
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
return Ok(new TailoredCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName));
}
[HttpPost("{id:int}/export-tailored-cv-pdf")]
public async Task<IActionResult> ExportTailoredCvPdf([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var user = await GetCurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
request.TemplateId,
request.Headline,
request.Summary,
request.SelectedSkills,
request.Experience,
request.Education,
request.CustomSections,
request.RenderOptions,
draft?.Status ?? "generated"), draft, job);
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
? request!.PhotoDataUrl
: request?.UseProfileAvatar == false
? null
: user.AvatarImageDataUrl;
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
}
[HttpPost("{id:int}/generate-tailored-cv-draft")]
public async Task<ActionResult<TailoredCvDraftDto>> GenerateTailoredCvDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var user = await GetCurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(user.ProfileCvText))
{
return BadRequest("Add your profile CV text on the Profile page before generating a tailored CV draft.");
}
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
if (structured.Summary.Count == 0 && structured.Jobs.Count == 0 && structured.Skills.Count == 0)
{
return BadRequest("Build and review your canonical structured CV on the Profile page before generating a tailored draft.");
}
var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken);
return Ok(ToTailoredCvDraftDto(draft));
}
[HttpPut("{id:int}/tailored-cv-draft")]
public async Task<IActionResult> SaveTailoredCvDraft([FromRoute] int id, [FromBody] SaveTailoredCvDraftRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var user = await GetCurrentUserAsync(cancellationToken);
if (user is null) return Unauthorized();
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken)
?? new TailoredCvDraft
{
OwnerUserId = user.Id,
JobApplicationId = id,
CanonicalProfileVersion = user.CurrentCvProfileVersion,
};
var document = new TailoredCvDocument
{
TemplateId = request.TemplateId ?? draft.TemplateId,
Headline = request.Headline,
Summary = request.Summary ?? new List<string>(),
SelectedSkills = request.SelectedSkills ?? new List<string>(),
Experience = request.Experience ?? new List<TailoredCvExperienceItem>(),
Education = request.Education ?? new List<TailoredCvEducationItem>(),
CustomSections = request.CustomSections ?? new List<TailoredCvCustomSection>(),
RenderOptions = request.RenderOptions ?? new TailoredCvRenderOptions(),
};
draft.OwnerUserId = user.Id;
draft.CanonicalProfileVersion ??= user.CurrentCvProfileVersion;
draft.Status = string.IsNullOrWhiteSpace(request.Status) ? "edited" : request.Status.Trim();
draft.LastEditedAtUtc = DateTimeOffset.UtcNow;
TailoredCvDraftJson.ApplyToDraft(draft, document);
if (draft.Id == 0)
{
_db.TailoredCvDrafts.Add(draft);
}
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
job.TailoredCvUpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPut("{id:int}/tailored-cv")]
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
{