feat: add application package generation and grouped readiness workflows

This commit is contained in:
cesnimda
2026-03-22 18:28:02 +01:00
parent f1c7c38a19
commit 9188039e9d
14 changed files with 1014 additions and 373 deletions
@@ -461,6 +461,25 @@ namespace JobTrackerApi.Controllers
var upcoming = j.FollowUpAt is not null && j.FollowUpAt.Value <= upcomingTo;
if (!d.NeedsFollowUp && !upcoming) continue;
var shortSummary = j.ShortSummary;
var reminderReason = d.Reason;
if (string.IsNullOrWhiteSpace(j.TailoredCvText))
{
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
? "Tailored CV missing for an active role."
: $"{reminderReason} Tailored CV missing.";
}
if (j.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(j.Notes))
{
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
? "Interview coming up but prep notes are missing."
: $"{reminderReason} Interview prep notes missing.";
}
if (!j.ResponseReceived && j.FollowUpAt is null)
{
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
? "No response yet and no follow-up date is scheduled."
: $"{reminderReason} No follow-up date is scheduled.";
}
dtos.Add(new JobApplicationDto(
Id: j.Id,
@@ -492,7 +511,7 @@ namespace JobTrackerApi.Controllers
DeletedAt: j.DeletedAt,
DaysSince: j.DaysSince,
NeedsFollowUp: d.NeedsFollowUp,
FollowUpReason: d.Reason,
FollowUpReason: reminderReason,
ShortSummary: shortSummary,
FullSummary: null
));
@@ -1186,6 +1205,290 @@ namespace JobTrackerApi.Controllers
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt);
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
public sealed record CandidateFitChannelGuidanceDto(List<string> Cv, List<string> CoverLetter, List<string> Interview, List<string> RecruiterMessage);
public sealed record CandidateFitDto(
string MatchSummary,
string FitLevel,
int MatchScore,
List<string> Strengths,
List<string> Gaps,
List<string> Mention,
List<string> Avoid,
List<string> CvImprovements,
List<string> MissingKeywords,
List<string> InterviewPrep,
string TailoredPitch,
CandidateFitChannelGuidanceDto Guidance,
string? CoverLetterDraft,
string? RecruiterMessageDraft);
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints);
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
[HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
{
return BadRequest("Add your profile CV text on the Profile page before running candidate fit analysis.");
}
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to compare against your CV.");
}
var normalizedCv = cvText.ToLowerInvariant();
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
var jobContext = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name}
Status: {job.Status}
Job description and notes:
{jobText}
Candidate CV/profile:
{cvText}";
var matchSummary = await _summarizer.SummarizeSectionAsync(
"Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.",
jobContext,
220,
90) ?? "No fit summary available yet.";
var strengthCount = strengths.Count;
var gapCount = gaps.Count;
var rawScore = 35 + (strengthCount * 10) - (gapCount * 4);
var matchScore = Math.Clamp(rawScore, 20, 96);
var fitLevel = matchScore >= 75 ? "Strong match" : matchScore >= 55 ? "Potential match" : "Stretch role";
var mention = strengths.Select(x => $"Show evidence of {x} with concrete results and outcomes.").Take(5).ToList();
if (!mention.Any() && jobTags.Any()) mention.Add($"Highlight directly relevant experience with {jobTags.First()}. ");
var avoid = new List<string>();
if (gaps.Any())
{
avoid.AddRange(gaps.Take(4).Select(x => $"Do not overclaim deep expertise in {x} unless you can back it up with recent examples."));
}
avoid.Add("Avoid generic claims without metrics, outcomes, or ownership details.");
var cvImprovements = new List<string>();
cvImprovements.AddRange(gaps.Take(4).Select(x => $"If you have experience with {x}, make it easier to find in your CV with a specific bullet and result."));
cvImprovements.Add("Quantify impact with numbers, scope, speed, revenue, quality, or customer outcomes where possible.");
cvImprovements.Add("Mirror the wording of the role where it is accurate, especially in your summary and recent experience.");
var missingKeywords = gaps.Take(6).ToList();
var interviewPrep = new List<string>();
interviewPrep.AddRange(strengths.Take(3).Select(x => $"Prepare a STAR example that proves your experience with {x}."));
interviewPrep.AddRange(gaps.Take(2).Select(x => $"Prepare a credible learning story for {x}: related work, fast ramp-up, and how you would close the gap."));
if (!interviewPrep.Any())
{
interviewPrep.Add("Prepare two strong examples showing measurable impact, collaboration, and delivery under constraints.");
}
var tailoredPitch = await _summarizer.SummarizeSectionAsync(
"Write a short tailored candidate pitch for this role in first person. Keep it practical and credible.",
jobContext,
120,
45) ?? "I bring relevant experience, measurable outcomes, and a clear understanding of the role priorities.";
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
"Draft a short cover letter opening and value proposition for this candidate and job. Keep it specific and credible.",
jobContext,
180,
70);
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
"Draft a concise recruiter message for this candidate and job. Keep it warm, direct, and under 120 words.",
jobContext,
120,
45);
var guidance = new CandidateFitChannelGuidanceDto(
Cv: mention.Take(4).ToList(),
CoverLetter: strengths.Take(3).Select(x => $"Connect {x} to why you are interested in this company and role now.").ToList(),
Interview: interviewPrep.Take(5).ToList(),
RecruiterMessage: new List<string>
{
$"Lead with your strongest overlap: {(strengths.FirstOrDefault() ?? jobTags.FirstOrDefault() ?? "relevant experience")}. ",
"Keep the note concise and outcome-focused.",
"Close with a clear expression of interest and availability."
});
return Ok(new CandidateFitDto(
MatchSummary: matchSummary,
FitLevel: fitLevel,
MatchScore: matchScore,
Strengths: strengths,
Gaps: gaps,
Mention: mention,
Avoid: avoid.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
CvImprovements: cvImprovements.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
MissingKeywords: missingKeywords,
InterviewPrep: interviewPrep.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
TailoredPitch: tailoredPitch,
Guidance: guidance,
CoverLetterDraft: coverLetterDraft,
RecruiterMessageDraft: recruiterMessageDraft));
}
[HttpGet("{id:int}/interview-prep")]
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
.Where(x => !string.IsNullOrWhiteSpace(x)));
var tags = SkillTagger.Detect(context).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var talkingPoints = tags.Take(4).Select(x => $"Describe a concrete example where you delivered results with {x}.").ToList();
var likelyQuestions = tags.Take(4).Select(x => $"How have you applied {x} in practice, and what impact did it have?").ToList();
var weakSpots = new List<string>();
if (string.IsNullOrWhiteSpace(job.TailoredCvText)) weakSpots.Add("You have not saved a tailored CV for this role yet.");
if (string.IsNullOrWhiteSpace(job.CoverLetterText)) weakSpots.Add("You do not have a saved cover letter draft for this role yet.");
if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.NextAction)) weakSpots.Add("Your next action is not clearly documented.");
if (!weakSpots.Any()) weakSpots.Add("Prepare to explain why this role and company are a strong fit right now.");
var summary = await _summarizer.SummarizeSectionAsync(
"Create a concise interview prep brief. Focus on strongest talking points, likely topics, and preparation priorities.",
context,
180,
70) ?? "Prepare concise, outcome-focused stories that match the core role requirements.";
return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots));
}
[HttpGet("{id:int}/readiness")]
public async Task<ActionResult<ReadinessDto>> GetReadiness([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var completed = new List<string>();
var missing = new List<string>();
var reminders = new List<string>();
if (!string.IsNullOrWhiteSpace(job.TailoredCvText)) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role");
if (!string.IsNullOrWhiteSpace(job.CoverLetterText)) completed.Add("Cover letter draft ready"); else missing.Add("Create a cover letter draft");
if (job.HasPortfolio) completed.Add("Portfolio attached"); else missing.Add("Consider adding a relevant portfolio example");
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) completed.Add("Recruiter contact available"); else missing.Add("Capture recruiter contact details if possible");
if (!string.IsNullOrWhiteSpace(job.NextAction)) completed.Add("Next action captured"); else missing.Add("Write the next action so follow-up is clear");
if (job.FollowUpAt is not null) completed.Add("Follow-up scheduled"); else missing.Add("Schedule a follow-up date");
if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.TailoredCvText)) reminders.Add("This role is active but still missing a tailored CV.");
if (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(job.Notes)) reminders.Add("Interview stage reached but prep notes are still missing.");
if (!job.ResponseReceived && job.FollowUpAt is null) reminders.Add("No response yet and no follow-up is scheduled.");
var score = Math.Clamp(completed.Count * 15 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100);
var level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work";
return Ok(new ReadinessDto(score, level, completed, missing, reminders));
}
[HttpPut("{id:int}/tailored-cv")]
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
job.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim();
job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("{id:int}/generate-application-package")]
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
{
return BadRequest("Add your profile CV text on the Profile page before generating an application package.");
}
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to generate an application package.");
}
var packageContext = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name}
Status: {job.Status}
Job context:
{jobText}
Candidate master CV:
{cvText}";
var tailoredCvText = await _summarizer.SummarizeSectionAsync(
"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job.",
packageContext,
256,
120) ?? cvText;
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
"Write a concise, tailored cover letter for this candidate and job. Keep it specific, credible, and directly aligned to the role.",
packageContext,
220,
90);
var applicationAnswerDraft = await _summarizer.SummarizeSectionAsync(
"Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words.",
packageContext,
170,
70);
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
"Write a short recruiter intro message for this candidate and role. Keep it warm, direct, and concise.",
packageContext,
120,
45);
var keyPoints = SkillTagger.Detect(jobText)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.Select(x => $"Lead with evidence of {x}.")
.ToList();
return Ok(new GenerateApplicationPackageDto(
TailoredCvText: tailoredCvText,
CoverLetterDraft: coverLetterDraft,
ApplicationAnswerDraft: applicationAnswerDraft,
RecruiterMessageDraft: recruiterMessageDraft,
KeyPoints: keyPoints));
}
[HttpGet("analytics-overview")]
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)