feat: add application package generation and grouped readiness workflows
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user